| 1 | import { create } from "zustand"; |
| 2 | |
| 3 | import { app } from "../lib/bridge"; |
| 4 | import type { TerminalSessionView, TerminalWorkspaceView } from "../lib/types"; |
| 5 | import { forgetTerminalSession, registerTerminalExitListener } from "../lib/terminalEvents"; |
| 6 | |
| 7 | type TerminalState = { |
| 8 | tabId: string; |
| 9 | generation: number; |
| 10 | workspace: TerminalWorkspaceView | null; |
| 11 | loading: boolean; |
| 12 | error: string | null; |
| 13 | activeSessionId: string | null; |
| 14 | syncWorkspace: (tabId: string, force?: boolean) => Promise<TerminalWorkspaceView | null>; |
| 15 | ensureReady: (tabId: string) => Promise<TerminalWorkspaceView | null>; |
| 16 | createSession: (tabId: string, relativePath?: string, shellId?: string) => Promise<TerminalSessionView | null>; |
| 17 | write: (tabId: string, sessionId: string, data: string) => Promise<void>; |
| 18 | resize: (tabId: string, sessionId: string, cols: number, rows: number) => Promise<void>; |
| 19 | closeSession: (tabId: string, sessionId: string) => Promise<void>; |
| 20 | renameSession: (tabId: string, sessionId: string, title: string) => Promise<void>; |
| 21 | clearError: () => void; |
| 22 | setActiveSession: (sessionId: string | null) => void; |
| 23 | }; |
| 24 | |
| 25 | let inFlight: { tabId: string; promise: Promise<TerminalWorkspaceView | null> } | null = null; |
| 26 | |
| 27 | function errorMessage(error: unknown): string { |
| 28 | return error instanceof Error ? error.message : String(error); |
| 29 | } |
| 30 | |
| 31 | function normalizedWorkspace(value: TerminalWorkspaceView): TerminalWorkspaceView { |
| 32 | return { |
| 33 | ...value, |
| 34 | sessions: Array.isArray(value.sessions) ? value.sessions : [], |
| 35 | shells: Array.isArray(value.shells) ? value.shells : [], |
| 36 | }; |
| 37 | } |
| 38 | |
| 39 | export const useTerminalStore = create<TerminalState>((set, get) => ({ |
| 40 | tabId: "", |
| 41 | generation: 0, |
| 42 | workspace: null, |
| 43 | loading: false, |
| 44 | error: null, |
| 45 | activeSessionId: null, |
| 46 | async syncWorkspace(tabId, force = false) { |
| 47 | const normalizedTabId = tabId.trim(); |
| 48 | if (!normalizedTabId) { |
| 49 | set({ tabId: "", workspace: null, activeSessionId: null, loading: false, error: null }); |
| 50 | return null; |
| 51 | } |
| 52 | if (!force && inFlight?.tabId === normalizedTabId) return inFlight.promise; |
| 53 | const generation = get().generation + 1; |
| 54 | set({ tabId: normalizedTabId, generation, loading: true, workspace: null, activeSessionId: null, error: null }); |
| 55 | const request = app.TerminalWorkspaceForTab(normalizedTabId) |
| 56 | .then((value) => { |
| 57 | const workspace = normalizedWorkspace(value); |
| 58 | const current = get(); |
| 59 | if (current.tabId !== normalizedTabId || current.generation !== generation) return null; |
| 60 | const active = workspace.sessions.find((session) => session.running)?.id ?? workspace.sessions[0]?.id ?? null; |
| 61 | set({ workspace, loading: false, error: null, activeSessionId: active }); |
| 62 | return workspace; |
| 63 | }) |
| 64 | .catch((error) => { |
| 65 | const current = get(); |
| 66 | if (current.tabId === normalizedTabId && current.generation === generation) { |
| 67 | set({ workspace: null, loading: false, error: errorMessage(error), activeSessionId: null }); |
| 68 | } |
| 69 | throw error; |
| 70 | }); |
| 71 | inFlight = { tabId: normalizedTabId, promise: request }; |
| 72 | try { |
| 73 | return await request; |
| 74 | } finally { |
| 75 | if (inFlight?.promise === request) inFlight = null; |
| 76 | } |
| 77 | }, |
| 78 | async ensureReady(tabId) { |
| 79 | const normalizedTabId = tabId.trim(); |
| 80 | const current = get(); |
| 81 | if (current.tabId === normalizedTabId && current.workspace && !current.loading) return current.workspace; |
| 82 | if (inFlight?.tabId === normalizedTabId) return inFlight.promise; |
| 83 | return get().syncWorkspace(normalizedTabId); |
| 84 | }, |
| 85 | async createSession(tabId, relativePath = ".", shellId = "default") { |
| 86 | const normalizedTabId = tabId.trim(); |
| 87 | set({ error: null }); |
| 88 | try { |
| 89 | const workspace = await get().ensureReady(normalizedTabId); |
| 90 | if (!workspace?.available || workspace.readOnly) return null; |
| 91 | const session = await app.CreateTerminalForTab(normalizedTabId, relativePath, shellId); |
| 92 | set((state) => { |
| 93 | if (state.tabId !== normalizedTabId || !state.workspace) return {}; |
| 94 | const sessions = state.workspace.sessions.some((item) => item.id === session.id) |
| 95 | ? state.workspace.sessions.map((item) => item.id === session.id ? session : item) |
| 96 | : [...state.workspace.sessions, session]; |
| 97 | return { workspace: { ...state.workspace, sessions }, error: null, activeSessionId: session.id }; |
| 98 | }); |
| 99 | return session; |
| 100 | } catch (error) { |
| 101 | if (get().tabId === normalizedTabId) set({ error: errorMessage(error) }); |
| 102 | throw error; |
| 103 | } |
| 104 | }, |
| 105 | async write(tabId, sessionId, data) { |
| 106 | try { |
| 107 | await app.WriteTerminalForTab(tabId, sessionId, data); |
| 108 | } catch (error) { |
| 109 | const current = get(); |
| 110 | if (current.tabId === tabId && current.workspace?.sessions.some((session) => session.id === sessionId)) { |
| 111 | set({ error: errorMessage(error) }); |
| 112 | } |
| 113 | throw error; |
| 114 | } |
| 115 | }, |
| 116 | async resize(tabId, sessionId, cols, rows) { |
| 117 | await app.ResizeTerminalForTab(tabId, sessionId, cols, rows); |
| 118 | }, |
| 119 | async closeSession(tabId, sessionId) { |
| 120 | try { |
| 121 | await app.CloseTerminalForTab(tabId, sessionId); |
| 122 | forgetTerminalSession(sessionId); |
| 123 | set((state) => { |
| 124 | if (state.tabId !== tabId || !state.workspace) return {}; |
| 125 | const sessions = state.workspace.sessions.filter((session) => session.id !== sessionId); |
| 126 | const activeSessionId = state.activeSessionId === sessionId |
| 127 | ? sessions.find((session) => session.running)?.id ?? sessions[0]?.id ?? null |
| 128 | : state.activeSessionId; |
| 129 | return { workspace: { ...state.workspace, sessions }, error: null, activeSessionId }; |
| 130 | }); |
| 131 | } catch (error) { |
| 132 | if (get().tabId === tabId) set({ error: errorMessage(error) }); |
| 133 | throw error; |
| 134 | } |
| 135 | }, |
| 136 | async renameSession(tabId, sessionId, title) { |
| 137 | try { |
| 138 | await app.RenameTerminalForTab(tabId, sessionId, title); |
| 139 | const current = get(); |
| 140 | if (current.tabId !== tabId || !current.workspace) return; |
| 141 | const sessions = current.workspace.sessions.map((session) => session.id === sessionId ? { ...session, title } : session); |
| 142 | set({ workspace: { ...current.workspace, sessions }, error: null }); |
| 143 | } catch (error) { |
| 144 | if (get().tabId === tabId) set({ error: errorMessage(error) }); |
| 145 | throw error; |
| 146 | } |
| 147 | }, |
| 148 | clearError: () => set({ error: null }), |
| 149 | setActiveSession: (activeSessionId) => set({ activeSessionId }), |
| 150 | })); |
| 151 | |
| 152 | registerTerminalExitListener((event) => { |
| 153 | useTerminalStore.setState((state) => { |
| 154 | if (!state.workspace) return {}; |
| 155 | if (event.removed) { |
| 156 | const sessions = state.workspace.sessions.filter((session) => session.id !== event.id); |
| 157 | const activeSessionId = state.activeSessionId === event.id |
| 158 | ? sessions.find((session) => session.running)?.id ?? sessions[0]?.id ?? null |
| 159 | : state.activeSessionId; |
| 160 | return { workspace: { ...state.workspace, sessions }, activeSessionId }; |
| 161 | } |
| 162 | const sessions = state.workspace.sessions.map((session) => session.id === event.id |
| 163 | ? { ...session, running: false, exitCode: event.exitCode } |
| 164 | : session); |
| 165 | return { workspace: { ...state.workspace, sessions } }; |
| 166 | }); |
| 167 | }); |
| 168 | |
| 169 | export function resetTerminalStoreForTests(): void { |
| 170 | inFlight = null; |
| 171 | useTerminalStore.setState({ tabId: "", generation: 0, workspace: null, loading: false, error: null, activeSessionId: null }); |
| 172 | } |
| 173 |