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