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