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