Repository Public
1import { create } from 'zustand';2import { consumeMods, NO_MODS, tapMod, type ModName, type Mods } from '@/components/terminal/keys';34export type TermStatus =5 | 'idle' | 'connecting' | 'open' | 'reconnecting' | 'offline'6 | 'ended' | 'recoverable' | 'notfound' | 'forbidden' | 'unauthorized';7export interface TermConn {8 status: TermStatus;9 latencyMs: number | null;10 exitCode: number | null;11 /** Human-readable reason while reconnecting (e.g. "Runtime unavailable"). */12 detail: string | null;13 /** The user scrolled up into the history. */14 scrolledUp: boolean;15 /** Output arrived while scrolled up. */16 unread: boolean;17 /** Number of successful re-attachments (drives the "Back online" banner). */18 reconnects: number;19}20export const IDLE_CONN: TermConn = { status: 'idle', latencyMs: null, exitCode: null, detail: null, scrolledUp: false, unread: false, reconnects: 0 };2122interface TerminalsState {23 openIds: string[];24 activeId: string | null;25 splitId: string | null;26 conns: Record<string, TermConn>;27 mods: Mods;28 open: (id: string, activate?: boolean) => void;29 close: (id: string) => void;30 activate: (id: string | null) => void;31 setSplit: (id: string | null) => void;32 prune: (existing: Set<string>) => void;33 setConn: (id: string, patch: Partial<TermConn>) => void;34 tapMod: (name: ModName, doubleTap: boolean) => void;35 consumeMods: () => void;36}3738const KEY = 'cr.tabs';39function load(): Pick<TerminalsState, 'openIds' | 'activeId' | 'splitId'> {40 try {41 const raw = typeof window === 'undefined' ? null : localStorage.getItem(KEY);42 const v = raw ? (JSON.parse(raw) as { openIds?: unknown; activeId?: unknown }) : null;43 const openIds = Array.isArray(v?.openIds) ? v.openIds.filter((x): x is string => typeof x === 'string') : [];44 const activeId = typeof v?.activeId === 'string' && openIds.includes(v.activeId) ? v.activeId : (openIds[0] ?? null);45 return { openIds, activeId, splitId: null };46 } catch {47 return { openIds: [], activeId: null, splitId: null };48 }49}50function persist(s: TerminalsState): void {51 try {52 // Layout only (which tabs are open). Session ids are not credentials.53 localStorage.setItem(KEY, JSON.stringify({ openIds: s.openIds, activeId: s.activeId }));54 } catch {55 /* storage unavailable */56 }57}5859export const useTerminals = create<TerminalsState>((set, get) => ({60 ...load(),61 conns: {},62 mods: NO_MODS,63 open: (id, activate = true) => {64 set((s) => ({ openIds: s.openIds.includes(id) ? s.openIds : [...s.openIds, id], activeId: activate ? id : s.activeId, splitId: activate && s.splitId === id ? null : s.splitId }));65 persist(get());66 },67 close: (id) => {68 set((s) => {69 const i = s.openIds.indexOf(id);70 const openIds = s.openIds.filter((x) => x !== id);71 const activeId = s.activeId === id ? (openIds[Math.min(i, openIds.length - 1)] ?? null) : s.activeId;72 const { [id]: _gone, ...conns } = s.conns;73 return { openIds, activeId, splitId: s.splitId === id || s.splitId === activeId ? null : s.splitId, conns };74 });75 persist(get());76 },77 activate: (id) => {78 set((s) => ({ activeId: id, splitId: s.splitId === id ? null : s.splitId }));79 persist(get());80 },81 setSplit: (id) => set((s) => ({ splitId: id && id !== s.activeId ? id : null })),82 prune: (existing) => {83 const s = get();84 const openIds = s.openIds.filter((id) => existing.has(id));85 if (openIds.length === s.openIds.length) return;86 set({ openIds, activeId: s.activeId && existing.has(s.activeId) ? s.activeId : (openIds[0] ?? null), splitId: s.splitId && existing.has(s.splitId) ? s.splitId : null });87 persist(get());88 },89 setConn: (id, patch) => set((s) => ({ conns: { ...s.conns, [id]: { ...(s.conns[id] ?? IDLE_CONN), ...patch } } })),90 tapMod: (name, doubleTap) => set((s) => ({ mods: { ...s.mods, [name]: tapMod(s.mods[name], doubleTap) } })),91 consumeMods: () => set((s) => ({ mods: consumeMods(s.mods) })),92}));93