返回 DeepSeek-Reasonix
terminal.ts
根目录 / desktop / frontend / src / store / terminal.ts
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
190 lines TYPESCRIPT