返回 DeepSeek-Reasonix
TerminalPanel.tsx
根目录 / desktop / frontend / src / components / TerminalPanel.tsx
1 import { ErrorMessage } from "./ErrorMessage";
2 import { AlertTriangle, MessageSquare, MessageSquarePlus, PanelBottomClose, Plus, RefreshCw, TerminalSquare, X } from "lucide-react";
3 import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4 import { createPortal } from "react-dom";
5
6 import {
7 detectShortcutPlatform,
8 formatShortcutCombo,
9 onShortcutsChanged,
10 resolvedShortcutCombo,
11 useGlobalShortcut,
12 } from "../lib/keyboardShortcuts";
13 import { useT } from "../lib/i18n";
14 import { startTerminalEventBridge } from "../lib/terminalEvents";
15 import { useTerminalStore } from "../store/terminal";
16 import { TerminalSessionRail } from "./TerminalSessionRail";
17 import { TerminalView, type TerminalSelectionAction, type TerminalViewHandle } from "./TerminalView";
18
19 const SELECTION_ACTION_EDGE_GAP = 8;
20
21 export function TerminalPanel({
22 tabId,
23 cwd,
24 readOnly,
25 open,
26 fitEnabled = true,
27 onClose,
28 onAddOutput,
29 onAddToChat,
30 }: {
31 tabId: string;
32 cwd?: string;
33 readOnly: boolean;
34 open: boolean;
35 fitEnabled?: boolean;
36 onClose: () => void;
37 onAddOutput: (sessionId: string) => void;
38 onAddToChat: (text: string) => void;
39 }) {
40 const t = useT();
41 const [selectedShellId, setSelectedShellId] = useState("default");
42 const [selectionAction, setSelectionAction] = useState<TerminalSelectionAction | null>(null);
43 const [actionPoint, setActionPoint] = useState<{ left: number; top: number } | null>(null);
44 const selectionActionRef = useRef<HTMLDivElement>(null);
45 const terminalViewRef = useRef<TerminalViewHandle>(null);
46 // App can render the new tab before this panel's sync effect advances the
47 // process-wide terminal store. Never expose the previous tab's terminal in
48 // that gap: even one painted frame could route input or a stale overlay to
49 // the new tab with the old session id.
50 const workspace = useTerminalStore((state) => state.tabId === tabId ? state.workspace : null);
51 const loading = useTerminalStore((state) => state.tabId === tabId ? state.loading : true);
52 const error = useTerminalStore((state) => state.tabId === tabId ? state.error : null);
53 const activeSessionId = useTerminalStore((state) => state.tabId === tabId ? state.activeSessionId : null);
54 const syncWorkspace = useTerminalStore((state) => state.syncWorkspace);
55 const ensureReady = useTerminalStore((state) => state.ensureReady);
56 const createSession = useTerminalStore((state) => state.createSession);
57 const closeSession = useTerminalStore((state) => state.closeSession);
58 const clearError = useTerminalStore((state) => state.clearError);
59 const setActiveSession = useTerminalStore((state) => state.setActiveSession);
60 const capabilityRef = useRef({ tabId, readOnly });
61 const shortcutPlatform = useMemo(() => detectShortcutPlatform(), []);
62 const [shortcutRevision, setShortcutRevision] = useState(0);
63 useEffect(() => onShortcutsChanged(() => setShortcutRevision((value) => value + 1)), []);
64 const addShortcut = useMemo(
65 () => formatShortcutCombo(resolvedShortcutCombo("selection.addToChat", shortcutPlatform), shortcutPlatform),
66 // shortcutRevision re-resolves the combo after the user changes it.
67 // eslint-disable-next-line react-hooks/exhaustive-deps
68 [shortcutPlatform, shortcutRevision],
69 );
70
71 useLayoutEffect(() => {
72 if (!selectionAction) {
73 setActionPoint(null);
74 return;
75 }
76 const rect = selectionActionRef.current?.getBoundingClientRect();
77 if (!rect) {
78 setActionPoint(selectionAction.point);
79 return;
80 }
81 setActionPoint({
82 left: Math.min(
83 Math.max(SELECTION_ACTION_EDGE_GAP, selectionAction.point.left),
84 Math.max(SELECTION_ACTION_EDGE_GAP, window.innerWidth - rect.width - SELECTION_ACTION_EDGE_GAP),
85 ),
86 top: Math.min(
87 Math.max(SELECTION_ACTION_EDGE_GAP, selectionAction.point.top),
88 Math.max(SELECTION_ACTION_EDGE_GAP, window.innerHeight - rect.height - SELECTION_ACTION_EDGE_GAP),
89 ),
90 });
91 }, [selectionAction]);
92
93 // The floating action only outlives the gesture when the terminal keeps its
94 // focus: Escape, an outside click, a resize, or a scroll dismisses it.
95 useEffect(() => {
96 if (!selectionAction) return;
97 const close = () => setSelectionAction(null);
98 const onPointerDown = (event: PointerEvent) => {
99 if (event.target instanceof Node && selectionActionRef.current?.contains(event.target)) return;
100 setSelectionAction(null);
101 };
102 const onKeyDown = (event: KeyboardEvent) => {
103 if (event.key === "Escape") setSelectionAction(null);
104 };
105 document.addEventListener("pointerdown", onPointerDown, true);
106 document.addEventListener("keydown", onKeyDown);
107 window.addEventListener("resize", close);
108 window.addEventListener("scroll", close, true);
109 return () => {
110 document.removeEventListener("pointerdown", onPointerDown, true);
111 document.removeEventListener("keydown", onKeyDown);
112 window.removeEventListener("resize", close);
113 window.removeEventListener("scroll", close, true);
114 };
115 }, [selectionAction]);
116
117 useEffect(() => {
118 if (!open) setSelectionAction(null);
119 }, [open]);
120
121 const addSelectionToChat = useCallback(() => {
122 if (!selectionAction) return;
123 onAddToChat(selectionAction.text);
124 terminalViewRef.current?.clearSelection();
125 setSelectionAction(null);
126 }, [onAddToChat, selectionAction]);
127
128 useGlobalShortcut(
129 "selection.addToChat",
130 addSelectionToChat,
131 [],
132 open && Boolean(selectionAction),
133 );
134
135 useEffect(startTerminalEventBridge, []);
136 useEffect(() => {
137 const previous = capabilityRef.current;
138 const capabilityChanged = previous.tabId === tabId && previous.readOnly !== readOnly;
139 capabilityRef.current = { tabId, readOnly };
140 void syncWorkspace(tabId, capabilityChanged).catch(() => {});
141 }, [readOnly, syncWorkspace, tabId]);
142
143 useEffect(() => {
144 if (workspace && !workspace.shells.some((shell) => shell.id === selectedShellId)) {
145 setSelectedShellId("default");
146 }
147 }, [selectedShellId, workspace]);
148
149 const newSession = useCallback(() => {
150 void createSession(tabId, ".", selectedShellId).catch(() => {});
151 }, [createSession, selectedShellId, tabId]);
152 const sessions = workspace?.sessions ?? [];
153 const shellOptions = workspace?.shells.length
154 ? workspace.shells
155 : [{ id: "default", label: t("terminal.defaultShell") }];
156 const active = sessions.find((session) => session.id === activeSessionId) ?? sessions[0];
157 const terminalReadOnly = readOnly || Boolean(workspace?.readOnly);
158
159 useLayoutEffect(() => {
160 setSelectionAction(null);
161 }, [active?.id, tabId]);
162
163 return (
164 <section className="terminal-panel" aria-label={t("terminal.title")}>
165 <header className="terminal-panel__header">
166 <div className="terminal-panel__identity"><TerminalSquare size={15} /><strong>{t("terminal.title")}</strong>{cwd && <span title={cwd}>{cwd}</span>}</div>
167 <div className="terminal-panel__actions">
168 <select
169 className="terminal-shell-select"
170 value={selectedShellId}
171 onChange={(event) => setSelectedShellId(event.target.value)}
172 disabled={!workspace?.available || terminalReadOnly}
173 aria-label={t("terminal.shell")}
174 title={t("terminal.shell")}
175 >
176 {shellOptions.map((shell) => (
177 <option key={shell.id} value={shell.id}>
178 {shell.id === "default" ? t("terminal.defaultShell") : shell.label}
179 </option>
180 ))}
181 </select>
182 <button type="button" className="terminal-icon-button" onClick={newSession} disabled={!workspace?.available || terminalReadOnly} aria-label={t("terminal.newSession")} title={t("terminal.newSession")}><Plus size={15} /></button>
183 <button type="button" className="terminal-icon-button" onClick={() => active && onAddOutput(active.id)} disabled={!active} aria-label={t("terminal.addOutput")} title={t("terminal.addOutput")}><MessageSquarePlus size={15} /></button>
184 <button type="button" className="terminal-icon-button" onClick={onClose} aria-label={t("rightDock.collapse")} title={t("rightDock.collapse")}><PanelBottomClose size={15} /></button>
185 </div>
186 </header>
187 {!workspace && loading ? (
188 <div className="terminal-empty"><span className="terminal-empty__spinner" />{t("terminal.loading")}</div>
189 ) : !workspace && error ? (
190 <div className="terminal-empty terminal-empty--error" role="alert">
191 <AlertTriangle size={18} />
192 <strong><ErrorMessage error={error} /></strong>
193 <button type="button" className="btn btn--secondary btn--small" onClick={() => { clearError(); void ensureReady(tabId).catch(() => {}); }}>
194 <RefreshCw size={14} />{t("terminal.retry")}
195 </button>
196 </div>
197 ) : !workspace ? (
198 <div className="terminal-empty"><AlertTriangle size={18} /><strong>{t("terminal.loading")}</strong></div>
199 ) : !workspace.available || terminalReadOnly ? (
200 <div className="terminal-empty"><AlertTriangle size={18} /><strong>{workspace.reason || t("terminal.readOnly")}</strong></div>
201 ) : (
202 <div className="terminal-panel__body">
203 {error && (
204 <div className="terminal-error" role="alert">
205 <AlertTriangle size={14} />
206 <span><ErrorMessage error={error} /></span>
207 <button type="button" className="terminal-icon-button" onClick={clearError} aria-label={t("terminal.dismissError")} title={t("terminal.dismissError")}><X size={13} /></button>
208 </div>
209 )}
210 {sessions.length > 0 && (
211 <TerminalSessionRail
212 sessions={sessions}
213 activeSessionId={active?.id ?? null}
214 onSelect={setActiveSession}
215 onClose={(id) => void closeSession(tabId, id).catch(() => {})}
216 />
217 )}
218 <div className="terminal-panel__content">
219 {active ? <TerminalView key={active.id} ref={terminalViewRef} tabId={tabId} session={active} open={open} fitEnabled={fitEnabled} onSelectionActionChange={setSelectionAction} onAddToChat={onAddToChat} /> : (
220 <div className="terminal-empty terminal-empty--action"><TerminalSquare size={22} /><p>{t("terminal.empty")}</p><button type="button" className="btn btn--secondary btn--small" onClick={newSession}><Plus size={14} />{t("terminal.newSession")}</button></div>
221 )}
222 </div>
223 </div>
224 )}
225 {open && selectionAction && typeof document !== "undefined" && createPortal(
226 <div
227 ref={selectionActionRef}
228 className="transcript-selection-action"
229 role="toolbar"
230 aria-label={t("selection.actions")}
231 style={{
232 left: actionPoint?.left ?? selectionAction.point.left,
233 top: actionPoint?.top ?? selectionAction.point.top,
234 visibility: actionPoint ? "visible" : "hidden",
235 }}
236 onMouseDown={(event) => event.preventDefault()}
237 >
238 <button type="button" onClick={addSelectionToChat}>
239 <MessageSquare size={14} aria-hidden="true" />
240 <span>{t("selection.addToChat")}</span>
241 <kbd>{addShortcut}</kbd>
242 </button>
243 </div>,
244 document.body,
245 )}
246 </section>
247 );
248 }
249
249 lines Plain Text