1/**
2 * Single Zustand store for terminal UI state keyed by scoped thread identity.
3 *
4 * Terminal UI transition helpers are intentionally private to keep the public
5 * API constrained to store actions/selectors.
6 */
7
8import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment";
9import { type ScopedThreadRef } from "@t3tools/contracts";
10import { create } from "zustand";
11import { createJSONStorage, persist } from "zustand/middleware";
12import { resolveStorage } from "./lib/storage";
13import {
14 DEFAULT_THREAD_TERMINAL_HEIGHT,
15 DEFAULT_THREAD_TERMINAL_ID,
16 MAX_TERMINALS_PER_GROUP,
17 type ThreadTerminalGroup,
18} from "./types";
19
20interface ThreadTerminalUiState {
21 terminalOpen: boolean;
22 terminalHeight: number;
23 terminalIds: string[];
24 activeTerminalId: string;
25 terminalGroups: ThreadTerminalGroup[];
26 activeTerminalGroupId: string;
27}
28
29// Keep the old storage key so existing drawer layout preferences migrate.
30const TERMINAL_UI_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
31
32interface PersistedTerminalUiStateStoreState {
33 terminalUiStateByThreadKey?: Record<string, ThreadTerminalUiState>;
34 terminalStateByThreadKey?: Record<string, ThreadTerminalUiState>;
35}
36
37export function migratePersistedTerminalUiStateStoreState(
38 persistedState: unknown,
39 _version: number,
40): PersistedTerminalUiStateStoreState {
41 if (!persistedState || typeof persistedState !== "object") {
42 return { terminalUiStateByThreadKey: {} };
43 }
44
45 const candidate = persistedState as PersistedTerminalUiStateStoreState;
46 const persistedUiStateByThreadKey =
47 candidate.terminalUiStateByThreadKey ?? candidate.terminalStateByThreadKey ?? {};
48 const terminalUiStateByThreadKey = Object.fromEntries(
49 Object.entries(persistedUiStateByThreadKey).filter(([threadKey]) =>
50 parseScopedThreadKey(threadKey),
51 ),
52 );
53
54 return { terminalUiStateByThreadKey };
55}
56
57function createTerminalUiStateStorage() {
58 return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined);
59}
60
61function normalizeTerminalIds(terminalIds: string[]): string[] {
62 const normalizedIds: string[] = [];
63 const seen = new Set<string>();
64 for (const id of terminalIds) {
65 const trimmedId = id.trim();
66 if (trimmedId.length === 0 || seen.has(trimmedId)) continue;
67 seen.add(trimmedId);
68 normalizedIds.push(trimmedId);
69 }
70 return normalizedIds;
71}
72
73function fallbackGroupId(terminalId: string): string {
74 return `group-${terminalId}`;
75}
76
77function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string {
78 let candidate = baseId;
79 let index = 2;
80 while (usedGroupIds.has(candidate)) {
81 candidate = `${baseId}-${index}`;
82 index += 1;
83 }
84 usedGroupIds.add(candidate);
85 return candidate;
86}
87
88function findGroupIndexByTerminalId(
89 terminalGroups: ThreadTerminalGroup[],
90 terminalId: string,
91): number {
92 return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId));
93}
94
95function normalizeTerminalGroupIds(terminalIds: string[]): string[] {
96 return normalizeTerminalIds(terminalIds);
97}
98
99function normalizeTerminalGroups(
100 terminalGroups: ThreadTerminalGroup[],
101 terminalIds: string[],
102): ThreadTerminalGroup[] {
103 if (terminalIds.length === 0) {
104 return [];
105 }
106
107 const validTerminalIdSet = new Set(terminalIds);
108 const assignedTerminalIds = new Set<string>();
109 const nextGroups: ThreadTerminalGroup[] = [];
110 const usedGroupIds = new Set<string>();
111
112 for (const group of terminalGroups) {
113 const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => {
114 if (!validTerminalIdSet.has(terminalId)) return false;
115 if (assignedTerminalIds.has(terminalId)) return false;
116 return true;
117 });
118 if (groupTerminalIds.length === 0) continue;
119 for (const terminalId of groupTerminalIds) {
120 assignedTerminalIds.add(terminalId);
121 }
122 const baseGroupId =
123 group.id.trim().length > 0
124 ? group.id.trim()
125 : fallbackGroupId(groupTerminalIds[0] ?? terminalIds[0] ?? "");
126 nextGroups.push({
127 id: assignUniqueGroupId(baseGroupId, usedGroupIds),
128 terminalIds: groupTerminalIds,
129 ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}),
130 });
131 }
132
133 for (const terminalId of terminalIds) {
134 if (assignedTerminalIds.has(terminalId)) continue;
135 nextGroups.push({
136 id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds),
137 terminalIds: [terminalId],
138 });
139 }
140
141 return nextGroups;
142}
143
144function arraysEqual(a: string[], b: string[]): boolean {
145 if (a.length !== b.length) return false;
146 for (let index = 0; index < a.length; index += 1) {
147 if (a[index] !== b[index]) return false;
148 }
149 return true;
150}
151
152function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalGroup[]): boolean {
153 if (left.length !== right.length) return false;
154 for (let index = 0; index < left.length; index += 1) {
155 const leftGroup = left[index];
156 const rightGroup = right[index];
157 if (!leftGroup || !rightGroup) return false;
158 if (leftGroup.id !== rightGroup.id) return false;
159 if (
160 (leftGroup.splitDirection ?? "horizontal") !== (rightGroup.splitDirection ?? "horizontal")
161 ) {
162 return false;
163 }
164 if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false;
165 }
166 return true;
167}
168
169function threadTerminalUiStateEqual(
170 left: ThreadTerminalUiState,
171 right: ThreadTerminalUiState,
172): boolean {
173 return (
174 left.terminalOpen === right.terminalOpen &&
175 left.terminalHeight === right.terminalHeight &&
176 left.activeTerminalId === right.activeTerminalId &&
177 left.activeTerminalGroupId === right.activeTerminalGroupId &&
178 arraysEqual(left.terminalIds, right.terminalIds) &&
179 terminalGroupsEqual(left.terminalGroups, right.terminalGroups)
180 );
181}
182
183const DEFAULT_THREAD_TERMINAL_UI_STATE: ThreadTerminalUiState = Object.freeze({
184 terminalOpen: false,
185 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
186 terminalIds: [],
187 activeTerminalId: "",
188 terminalGroups: [],
189 activeTerminalGroupId: "",
190});
191
192function createDefaultThreadTerminalUiState(): ThreadTerminalUiState {
193 return {
194 ...DEFAULT_THREAD_TERMINAL_UI_STATE,
195 terminalIds: [...DEFAULT_THREAD_TERMINAL_UI_STATE.terminalIds],
196 terminalGroups: copyTerminalGroups(DEFAULT_THREAD_TERMINAL_UI_STATE.terminalGroups),
197 };
198}
199
200function getDefaultThreadTerminalUiState(): ThreadTerminalUiState {
201 return DEFAULT_THREAD_TERMINAL_UI_STATE;
202}
203
204function normalizeThreadTerminalUiState(state: ThreadTerminalUiState): ThreadTerminalUiState {
205 const nextTerminalIds = normalizeTerminalIds(state.terminalIds);
206 const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId)
207 ? state.activeTerminalId
208 : (nextTerminalIds[0] ?? "");
209 const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds);
210 const activeGroupIdFromState = terminalGroups.some(
211 (group) => group.id === state.activeTerminalGroupId,
212 )
213 ? state.activeTerminalGroupId
214 : null;
215 const activeGroupIdFromTerminal =
216 terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null;
217
218 const normalized: ThreadTerminalUiState = {
219 terminalOpen: state.terminalOpen,
220 terminalHeight:
221 Number.isFinite(state.terminalHeight) && state.terminalHeight > 0
222 ? state.terminalHeight
223 : DEFAULT_THREAD_TERMINAL_HEIGHT,
224 terminalIds: nextTerminalIds,
225 activeTerminalId,
226 terminalGroups,
227 activeTerminalGroupId:
228 activeGroupIdFromState ?? activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? "",
229 };
230 return threadTerminalUiStateEqual(state, normalized) ? state : normalized;
231}
232
233function isDefaultThreadTerminalUiState(state: ThreadTerminalUiState): boolean {
234 const normalized = normalizeThreadTerminalUiState(state);
235 return threadTerminalUiStateEqual(normalized, DEFAULT_THREAD_TERMINAL_UI_STATE);
236}
237
238function isValidTerminalId(terminalId: string): boolean {
239 return terminalId.trim().length > 0;
240}
241
242function terminalThreadKey(threadRef: ScopedThreadRef): string {
243 return scopedThreadKey(threadRef);
244}
245
246function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] {
247 return groups.map((group) => ({
248 id: group.id,
249 terminalIds: [...group.terminalIds],
250 ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}),
251 }));
252}
253
254function upsertTerminalIntoGroups(
255 state: ThreadTerminalUiState,
256 terminalId: string,
257 mode: "split" | "new",
258 splitDirection: "horizontal" | "vertical" = "horizontal",
259): ThreadTerminalUiState {
260 const normalized = normalizeThreadTerminalUiState(state);
261 const effectiveMode: "split" | "new" = normalized.terminalIds.length === 0 ? "new" : mode;
262 if (!isValidTerminalId(terminalId)) {
263 return normalized;
264 }
265
266 const isNewTerminal = !normalized.terminalIds.includes(terminalId);
267 const terminalIds = isNewTerminal
268 ? [...normalized.terminalIds, terminalId]
269 : normalized.terminalIds;
270 const terminalGroups = copyTerminalGroups(normalized.terminalGroups);
271
272 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId);
273 if (existingGroupIndex >= 0) {
274 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[
275 existingGroupIndex
276 ]!.terminalIds.filter((id) => id !== terminalId);
277 if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {
278 terminalGroups.splice(existingGroupIndex, 1);
279 }
280 }
281
282 if (effectiveMode === "new") {
283 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
284 const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds);
285 terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] });
286 return normalizeThreadTerminalUiState({
287 ...normalized,
288 terminalOpen: true,
289 terminalIds,
290 activeTerminalId: terminalId,
291 terminalGroups,
292 activeTerminalGroupId: nextGroupId,
293 });
294 }
295
296 let activeGroupIndex = terminalGroups.findIndex(
297 (group) => group.id === normalized.activeTerminalGroupId,
298 );
299 if (activeGroupIndex < 0) {
300 activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId);
301 }
302 if (activeGroupIndex < 0) {
303 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
304 const nextGroupId = assignUniqueGroupId(
305 fallbackGroupId(normalized.activeTerminalId),
306 usedGroupIds,
307 );
308 terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] });
309 activeGroupIndex = terminalGroups.length - 1;
310 }
311
312 const destinationGroup = terminalGroups[activeGroupIndex];
313 if (!destinationGroup) {
314 return normalized;
315 }
316 const destinationTerminalIdSet = new Set(destinationGroup.terminalIds);
317
318 if (
319 isNewTerminal &&
320 !destinationTerminalIdSet.has(terminalId) &&
321 destinationGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP
322 ) {
323 return normalized;
324 }
325
326 if (!destinationTerminalIdSet.has(terminalId)) {
327 const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId);
328 if (anchorIndex >= 0) {
329 destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId);
330 } else {
331 destinationGroup.terminalIds.push(terminalId);
332 }
333 }
334 if (splitDirection === "vertical") {
335 destinationGroup.splitDirection = "vertical";
336 } else {
337 delete destinationGroup.splitDirection;
338 }
339
340 return normalizeThreadTerminalUiState({
341 ...normalized,
342 terminalOpen: true,
343 terminalIds,
344 activeTerminalId: terminalId,
345 terminalGroups,
346 activeTerminalGroupId: destinationGroup.id,
347 });
348}
349
350function setThreadTerminalOpen(state: ThreadTerminalUiState, open: boolean): ThreadTerminalUiState {
351 const normalized = normalizeThreadTerminalUiState(state);
352 if (open && normalized.terminalIds.length === 0) {
353 return upsertTerminalIntoGroups(normalized, DEFAULT_THREAD_TERMINAL_ID, "new");
354 }
355 if (normalized.terminalOpen === open) return normalized;
356 return { ...normalized, terminalOpen: open };
357}
358
359function setThreadTerminalHeight(
360 state: ThreadTerminalUiState,
361 height: number,
362): ThreadTerminalUiState {
363 const normalized = normalizeThreadTerminalUiState(state);
364 if (!Number.isFinite(height) || height <= 0 || normalized.terminalHeight === height) {
365 return normalized;
366 }
367 return { ...normalized, terminalHeight: height };
368}
369
370function splitThreadTerminal(
371 state: ThreadTerminalUiState,
372 terminalId: string,
373 direction: "horizontal" | "vertical" = "horizontal",
374): ThreadTerminalUiState {
375 return upsertTerminalIntoGroups(state, terminalId, "split", direction);
376}
377
378function newThreadTerminal(
379 state: ThreadTerminalUiState,
380 terminalId: string,
381): ThreadTerminalUiState {
382 return upsertTerminalIntoGroups(state, terminalId, "new");
383}
384
385function setThreadActiveTerminal(
386 state: ThreadTerminalUiState,
387 terminalId: string,
388): ThreadTerminalUiState {
389 const normalized = normalizeThreadTerminalUiState(state);
390 if (!normalized.terminalIds.includes(terminalId)) {
391 return normalized;
392 }
393 const activeTerminalGroupId =
394 normalized.terminalGroups.find((group) => group.terminalIds.includes(terminalId))?.id ??
395 normalized.activeTerminalGroupId;
396 if (
397 normalized.activeTerminalId === terminalId &&
398 normalized.activeTerminalGroupId === activeTerminalGroupId
399 ) {
400 return normalized;
401 }
402 return {
403 ...normalized,
404 activeTerminalId: terminalId,
405 activeTerminalGroupId,
406 };
407}
408
409function closeThreadTerminal(
410 state: ThreadTerminalUiState,
411 terminalId: string,
412): ThreadTerminalUiState {
413 const normalized = normalizeThreadTerminalUiState(state);
414 if (!normalized.terminalIds.includes(terminalId)) {
415 return normalized;
416 }
417
418 const remainingTerminalIds = normalized.terminalIds.filter((id) => id !== terminalId);
419 if (remainingTerminalIds.length === 0) {
420 return createDefaultThreadTerminalUiState();
421 }
422
423 const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId);
424 const nextActiveTerminalId =
425 normalized.activeTerminalId === terminalId
426 ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ??
427 remainingTerminalIds[0] ??
428 "")
429 : normalized.activeTerminalId;
430
431 const terminalGroups: ThreadTerminalGroup[] = [];
432 for (const group of normalized.terminalGroups) {
433 const terminalIds = group.terminalIds.filter((id) => id !== terminalId);
434 if (terminalIds.length > 0) {
435 terminalGroups.push({ ...group, terminalIds });
436 }
437 }
438
439 const nextActiveTerminalGroupId =
440 terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ??
441 terminalGroups[0]?.id ??
442 fallbackGroupId(nextActiveTerminalId);
443
444 return normalizeThreadTerminalUiState({
445 terminalOpen: normalized.terminalOpen,
446 terminalHeight: normalized.terminalHeight,
447 terminalIds: remainingTerminalIds,
448 activeTerminalId: nextActiveTerminalId,
449 terminalGroups,
450 activeTerminalGroupId: nextActiveTerminalGroupId,
451 });
452}
453
454function reconcileThreadTerminalSessionIds(
455 state: ThreadTerminalUiState,
456 nextIds: string[],
457): ThreadTerminalUiState {
458 const normalized = normalizeThreadTerminalUiState(state);
459 if (arraysEqual(normalized.terminalIds, nextIds)) {
460 return normalized;
461 }
462
463 const nextActiveTerminalId = nextIds.includes(normalized.activeTerminalId)
464 ? normalized.activeTerminalId
465 : (nextIds[0] ?? "");
466
467 const terminalGroups = normalizeTerminalGroups(normalized.terminalGroups, nextIds);
468 const activeGroupIdFromTerminal =
469 terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? null;
470
471 return normalizeThreadTerminalUiState({
472 ...normalized,
473 terminalIds: nextIds,
474 activeTerminalId: nextActiveTerminalId,
475 terminalGroups,
476 activeTerminalGroupId: activeGroupIdFromTerminal ?? terminalGroups[0]?.id ?? "",
477 });
478}
479
480export function selectThreadTerminalUiState(
481 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>,
482 threadRef: ScopedThreadRef | null | undefined,
483): ThreadTerminalUiState {
484 if (!threadRef || threadRef.threadId.length === 0) {
485 return getDefaultThreadTerminalUiState();
486 }
487 return (
488 terminalUiStateByThreadKey[terminalThreadKey(threadRef)] ?? getDefaultThreadTerminalUiState()
489 );
490}
491
492function updateTerminalUiStateByThreadKey(
493 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>,
494 threadRef: ScopedThreadRef,
495 updater: (state: ThreadTerminalUiState) => ThreadTerminalUiState,
496): Record<string, ThreadTerminalUiState> {
497 if (threadRef.threadId.length === 0) {
498 return terminalUiStateByThreadKey;
499 }
500
501 const threadKey = terminalThreadKey(threadRef);
502 const current = selectThreadTerminalUiState(terminalUiStateByThreadKey, threadRef);
503 const next = updater(current);
504 if (next === current) {
505 return terminalUiStateByThreadKey;
506 }
507
508 if (isDefaultThreadTerminalUiState(next)) {
509 if (terminalUiStateByThreadKey[threadKey] === undefined) {
510 return terminalUiStateByThreadKey;
511 }
512 const { [threadKey]: _removed, ...rest } = terminalUiStateByThreadKey;
513 return rest;
514 }
515
516 return {
517 ...terminalUiStateByThreadKey,
518 [threadKey]: next,
519 };
520}
521
522function updateSuppressedTerminalId(
523 suppressedTerminalIdsByThreadKey: Record<string, string[]>,
524 threadRef: ScopedThreadRef,
525 terminalId: string,
526 suppressed: boolean,
527): Record<string, string[]> {
528 const normalizedTerminalId = terminalId.trim();
529 if (normalizedTerminalId.length === 0) {
530 return suppressedTerminalIdsByThreadKey;
531 }
532 const threadKey = terminalThreadKey(threadRef);
533 const currentIds = suppressedTerminalIdsByThreadKey[threadKey] ?? [];
534 const currentlySuppressed = currentIds.includes(normalizedTerminalId);
535 if (currentlySuppressed === suppressed) {
536 return suppressedTerminalIdsByThreadKey;
537 }
538 if (suppressed) {
539 return {
540 ...suppressedTerminalIdsByThreadKey,
541 [threadKey]: [...currentIds, normalizedTerminalId],
542 };
543 }
544
545 const remainingIds = currentIds.filter((id) => id !== normalizedTerminalId);
546 if (remainingIds.length > 0) {
547 return {
548 ...suppressedTerminalIdsByThreadKey,
549 [threadKey]: remainingIds,
550 };
551 }
552 return removeRecordEntry(suppressedTerminalIdsByThreadKey, threadKey);
553}
554
555function removeRecordEntry<T>(record: Record<string, T>, key: string): Record<string, T> {
556 if (record[key] === undefined) {
557 return record;
558 }
559 const { [key]: _removed, ...remaining } = record;
560 return remaining;
561}
562
563interface TerminalUiStateStoreState {
564 terminalUiStateByThreadKey: Record<string, ThreadTerminalUiState>;
565 /** Closed ids hidden from stale server metadata until that id is explicitly opened again. */
566 suppressedTerminalIdsByThreadKey: Record<string, string[]>;
567 setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void;
568 setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void;
569 splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
570 splitTerminalVertical: (threadRef: ScopedThreadRef, terminalId: string) => void;
571 newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
572 ensureTerminal: (
573 threadRef: ScopedThreadRef,
574 terminalId: string,
575 options?: { open?: boolean; active?: boolean },
576 ) => void;
577 setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
578 closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void;
579 reconcileTerminalIds: (threadRef: ScopedThreadRef, nextIds: string[]) => void;
580 clearTerminalUiState: (threadRef: ScopedThreadRef) => void;
581 removeTerminalUiState: (threadRef: ScopedThreadRef) => void;
582 removeOrphanedTerminalUiStates: (activeThreadKeys: Set<string>) => void;
583}
584
585export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()(
586 persist(
587 (set, get) => {
588 const updateTerminal = (
589 threadRef: ScopedThreadRef,
590 updater: (
591 state: ThreadTerminalUiState,
592 suppressedTerminalIds: readonly string[],
593 ) => ThreadTerminalUiState,
594 suppression?: { terminalId: string; suppressed: boolean },
595 ) => {
596 set((state) => {
597 const threadKey = terminalThreadKey(threadRef);
598 const suppressedTerminalIds = state.suppressedTerminalIdsByThreadKey[threadKey] ?? [];
599 const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey(
600 state.terminalUiStateByThreadKey,
601 threadRef,
602 (terminalState) => updater(terminalState, suppressedTerminalIds),
603 );
604 const nextSuppressedTerminalIdsByThreadKey = suppression
605 ? updateSuppressedTerminalId(
606 state.suppressedTerminalIdsByThreadKey,
607 threadRef,
608 suppression.terminalId,
609 suppression.suppressed,
610 )
611 : state.suppressedTerminalIdsByThreadKey;
612 if (
613 nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey &&
614 nextSuppressedTerminalIdsByThreadKey === state.suppressedTerminalIdsByThreadKey
615 ) {
616 return state;
617 }
618 return {
619 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,
620 suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey,
621 };
622 });
623 };
624
625 return {
626 terminalUiStateByThreadKey: {},
627 suppressedTerminalIdsByThreadKey: {},
628 setTerminalOpen: (threadRef, open) => {
629 const terminalState = selectThreadTerminalUiState(
630 get().terminalUiStateByThreadKey,
631 threadRef,
632 );
633 updateTerminal(
634 threadRef,
635 (state) => setThreadTerminalOpen(state, open),
636 open && terminalState.terminalIds.length === 0
637 ? { terminalId: DEFAULT_THREAD_TERMINAL_ID, suppressed: false }
638 : undefined,
639 );
640 },
641 setTerminalHeight: (threadRef, height) =>
642 updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)),
643 splitTerminal: (threadRef, terminalId) =>
644 updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId), {
645 terminalId,
646 suppressed: false,
647 }),
648 splitTerminalVertical: (threadRef, terminalId) =>
649 updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId, "vertical"), {
650 terminalId,
651 suppressed: false,
652 }),
653 newTerminal: (threadRef, terminalId) =>
654 updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId), {
655 terminalId,
656 suppressed: false,
657 }),
658 ensureTerminal: (threadRef, terminalId, options) =>
659 updateTerminal(
660 threadRef,
661 (state) => {
662 let nextState = state;
663 if (!state.terminalIds.includes(terminalId)) {
664 nextState = newThreadTerminal(nextState, terminalId);
665 }
666 if (options?.active === false) {
667 nextState = {
668 ...nextState,
669 activeTerminalId: state.activeTerminalId,
670 activeTerminalGroupId: state.activeTerminalGroupId,
671 };
672 }
673 if (options?.active ?? true) {
674 nextState = setThreadActiveTerminal(nextState, terminalId);
675 }
676 if (options?.open) {
677 nextState = setThreadTerminalOpen(nextState, true);
678 }
679 return normalizeThreadTerminalUiState(nextState);
680 },
681 { terminalId, suppressed: false },
682 ),
683 setActiveTerminal: (threadRef, terminalId) =>
684 updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)),
685 closeTerminal: (threadRef, terminalId) =>
686 updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId), {
687 terminalId,
688 suppressed: true,
689 }),
690 reconcileTerminalIds: (threadRef, nextIds) =>
691 updateTerminal(threadRef, (state, suppressedTerminalIds) => {
692 if (suppressedTerminalIds.length === 0) {
693 return reconcileThreadTerminalSessionIds(state, nextIds);
694 }
695 const suppressedIds = new Set(suppressedTerminalIds);
696 return reconcileThreadTerminalSessionIds(
697 state,
698 nextIds.filter((terminalId) => !suppressedIds.has(terminalId)),
699 );
700 }),
701 clearTerminalUiState: (threadRef) =>
702 set((state) => {
703 const threadKey = terminalThreadKey(threadRef);
704 const nextTerminalUiStateByThreadKey = updateTerminalUiStateByThreadKey(
705 state.terminalUiStateByThreadKey,
706 threadRef,
707 () => createDefaultThreadTerminalUiState(),
708 );
709 const hadSuppressedTerminalIds =
710 state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined;
711 if (
712 nextTerminalUiStateByThreadKey === state.terminalUiStateByThreadKey &&
713 !hadSuppressedTerminalIds
714 ) {
715 return state;
716 }
717 return {
718 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,
719 suppressedTerminalIdsByThreadKey: removeRecordEntry(
720 state.suppressedTerminalIdsByThreadKey,
721 threadKey,
722 ),
723 };
724 }),
725 removeTerminalUiState: (threadRef) =>
726 set((state) => {
727 const threadKey = terminalThreadKey(threadRef);
728 const hadTerminalUiState = state.terminalUiStateByThreadKey[threadKey] !== undefined;
729 const hadSuppressedTerminalIds =
730 state.suppressedTerminalIdsByThreadKey[threadKey] !== undefined;
731 if (!hadTerminalUiState && !hadSuppressedTerminalIds) {
732 return state;
733 }
734 return {
735 terminalUiStateByThreadKey: removeRecordEntry(
736 state.terminalUiStateByThreadKey,
737 threadKey,
738 ),
739 suppressedTerminalIdsByThreadKey: removeRecordEntry(
740 state.suppressedTerminalIdsByThreadKey,
741 threadKey,
742 ),
743 };
744 }),
745 removeOrphanedTerminalUiStates: (activeThreadKeys) =>
746 set((state) => {
747 const orphanedIds = new Set(
748 [
749 ...Object.keys(state.terminalUiStateByThreadKey),
750 ...Object.keys(state.suppressedTerminalIdsByThreadKey),
751 ].filter((key) => !activeThreadKeys.has(key)),
752 );
753 if (orphanedIds.size === 0) {
754 return state;
755 }
756 const nextTerminalUiStateByThreadKey = { ...state.terminalUiStateByThreadKey };
757 const nextSuppressedTerminalIdsByThreadKey = {
758 ...state.suppressedTerminalIdsByThreadKey,
759 };
760 for (const id of orphanedIds) {
761 delete nextTerminalUiStateByThreadKey[id];
762 delete nextSuppressedTerminalIdsByThreadKey[id];
763 }
764 return {
765 terminalUiStateByThreadKey: nextTerminalUiStateByThreadKey,
766 suppressedTerminalIdsByThreadKey: nextSuppressedTerminalIdsByThreadKey,
767 };
768 }),
769 };
770 },
771 {
772 name: TERMINAL_UI_STATE_STORAGE_KEY,
773 version: 4,
774 storage: createJSONStorage(createTerminalUiStateStorage),
775 migrate: migratePersistedTerminalUiStateStoreState,
776 partialize: (state) => ({
777 terminalUiStateByThreadKey: state.terminalUiStateByThreadKey,
778 }),
779 },
780 ),
781);