返回 DeepSeek-Reasonix
TerminalView.tsx
根目录 / desktop / frontend / src / components / TerminalView.tsx
1 import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react";
2 import { FitAddon } from "@xterm/addon-fit";
3 import { Terminal } from "@xterm/xterm";
4 import { Clipboard, Copy, MessageSquare } from "lucide-react";
5 import "@xterm/xterm/css/xterm.css";
6
7 import { useTerminalStore } from "../store/terminal";
8 import { startTerminalEventBridge } from "../lib/terminalEvents";
9 import { registerTerminalSink, type TerminalSinkSubscription } from "../lib/terminalSink";
10 import { writeClipboardText } from "../lib/clipboard";
11 import { detectShortcutPlatform, formatShortcutCombo } from "../lib/keyboardShortcuts";
12 import { useT } from "../lib/i18n";
13 import {
14 clampTerminalSelectionPointToHost,
15 createTerminalSelectionLifecycle,
16 handleTerminalCopyKey,
17 readTerminalClipboardText,
18 terminalSelectionPointFromHost,
19 type TerminalSelectionPoint,
20 } from "../lib/terminalSelection";
21 import { observeTerminalTheme, terminalThemeForElement } from "../lib/terminalTheme";
22 import { useToast } from "../lib/toast";
23 import type { TerminalSessionView } from "../lib/types";
24 import { ContextMenu, type ContextMenuPoint } from "./ContextMenu";
25
26 export type TerminalSelectionAction = {
27 text: string;
28 point: TerminalSelectionPoint;
29 };
30
31 export type TerminalViewHandle = {
32 clearSelection: () => void;
33 };
34
35 export const TerminalView = forwardRef<TerminalViewHandle, {
36 tabId: string;
37 session: TerminalSessionView;
38 open?: boolean;
39 fitEnabled?: boolean;
40 onSelectionActionChange?: (action: TerminalSelectionAction | null) => void;
41 onAddToChat?: (text: string) => void;
42 }>(function TerminalView({ tabId, session, open = true, fitEnabled = true, onSelectionActionChange, onAddToChat }, ref) {
43 const hostRef = useRef<HTMLDivElement>(null);
44 const terminalRef = useRef<Terminal | null>(null);
45 const fitRef = useRef<FitAddon | null>(null);
46 const terminalSinkRef = useRef<TerminalSinkSubscription | null>(null);
47 const fitEnabledRef = useRef(fitEnabled);
48 const openRef = useRef(open);
49 const selectionLifecycleRef = useRef(createTerminalSelectionLifecycle<Terminal>());
50 const onSelectionActionChangeRef = useRef(onSelectionActionChange);
51 const onAddToChatRef = useRef(onAddToChat);
52 const [menu, setMenu] = useState<ContextMenuPoint | null>(null);
53 const [selectionText, setSelectionText] = useState("");
54 const selectionTextRef = useRef("");
55 const write = useTerminalStore((state) => state.write);
56 const resize = useTerminalStore((state) => state.resize);
57 const { showToast } = useToast();
58 const t = useT();
59 fitEnabledRef.current = fitEnabled;
60 openRef.current = open;
61 onSelectionActionChangeRef.current = onSelectionActionChange;
62 onAddToChatRef.current = onAddToChat;
63
64 const clearSelectionState = (terminal = terminalRef.current) => {
65 selectionLifecycleRef.current.noteSelectionChange();
66 terminal?.clearSelection();
67 selectionTextRef.current = "";
68 setSelectionText("");
69 onSelectionActionChangeRef.current?.(null);
70 };
71
72 useImperativeHandle(ref, () => ({
73 clearSelection: clearSelectionState,
74 }), []);
75
76 const updateSelection = () => {
77 selectionLifecycleRef.current.noteSelectionChange();
78 const text = terminalRef.current?.getSelection() ?? "";
79 selectionTextRef.current = text;
80 setSelectionText(text);
81 };
82
83 const clearSelectionAction = () => {
84 onSelectionActionChangeRef.current?.(null);
85 };
86
87 const reportSelection = (fallbackPoint?: TerminalSelectionPoint) => {
88 if (!openRef.current) {
89 clearSelectionAction();
90 return;
91 }
92 const terminal = terminalRef.current;
93 if (!terminal?.hasSelection()) {
94 clearSelectionAction();
95 return;
96 }
97 const text = terminal.getSelection();
98 const host = hostRef.current;
99 // A queued pointer-up frame can outlive a rapid tab/session switch. The
100 // detached host must not recreate its selection action after the owner
101 // panel synchronously cleared it.
102 if (!host?.isConnected) {
103 clearSelectionAction();
104 return;
105 }
106 const hostRect = host.getBoundingClientRect();
107 const fromPaint = terminalSelectionPointFromHost(host);
108 const point = clampTerminalSelectionPointToHost(
109 fromPaint ?? fallbackPoint ?? { left: hostRect.left + 12, top: hostRect.top + 12 },
110 host,
111 );
112 onSelectionActionChangeRef.current?.({ text, point });
113 };
114
115 const copySelection = async () => {
116 const operation = selectionLifecycleRef.current.captureSelection();
117 const text = selectionTextRef.current;
118 if (!operation || !text) return;
119 const copied = await writeClipboardText(text);
120 if (!selectionLifecycleRef.current.isCurrentSelection(operation)) return;
121 if (!copied) {
122 showToast(t("diag.copyFailed"), "error");
123 return;
124 }
125 clearSelectionState(operation.terminal);
126 };
127
128 const pasteFromClipboard = async () => {
129 const operation = selectionLifecycleRef.current.capture();
130 if (!operation) return;
131 const text = await readTerminalClipboardText();
132 if (!text || !selectionLifecycleRef.current.isCurrent(operation)) return;
133 operation.terminal.paste(text);
134 };
135
136 const addSelectionToChat = () => {
137 const text = selectionTextRef.current;
138 if (!text) return;
139 onAddToChatRef.current?.(text);
140 clearSelectionState();
141 };
142
143 const shortcutPlatform = useMemo(() => detectShortcutPlatform(), []);
144
145 useEffect(startTerminalEventBridge, []);
146 useEffect(() => {
147 const host = hostRef.current;
148 if (!host) return;
149 const terminal = new Terminal({
150 convertEol: true,
151 cursorBlink: true,
152 fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
153 fontSize: 13,
154 theme: terminalThemeForElement(host),
155 });
156 terminalRef.current = terminal;
157 selectionLifecycleRef.current.activate(terminal);
158 selectionTextRef.current = "";
159 setSelectionText("");
160 const fit = new FitAddon();
161 fitRef.current = fit;
162 terminal.loadAddon(fit);
163 terminal.open(host);
164 const updateTheme = () => {
165 terminal.options.theme = terminalThemeForElement(host);
166 };
167 const stopObservingTheme = observeTerminalTheme(host, updateTheme);
168 const terminalSink = registerTerminalSink(session.id, (bytes) => terminal.write(bytes), openRef.current);
169 terminalSinkRef.current = terminalSink;
170 const input = terminal.onData((data) => { void write(tabId, session.id, data).catch(() => {}); });
171 const outputResize = terminal.onResize(({ cols, rows }) => { void resize(tabId, session.id, cols, rows).catch(() => {}); });
172 terminal.attachCustomKeyEventHandler((event) => {
173 if (event.type !== "keydown") return true;
174 const decision = handleTerminalCopyKey({
175 key: event.key,
176 ctrlKey: event.ctrlKey,
177 metaKey: event.metaKey,
178 altKey: event.altKey,
179 hasSelection: () => terminal.hasSelection(),
180 getSelection: () => terminal.getSelection(),
181 });
182 if (decision.intercepted) {
183 const selectionOperation = selectionLifecycleRef.current.captureSelection();
184 if (!selectionOperation) return false;
185 void writeClipboardText(decision.text).then((copied) => {
186 if (!selectionLifecycleRef.current.isCurrentSelection(selectionOperation)) return;
187 if (!copied) {
188 showToast(t("diag.copyFailed"), "error");
189 return;
190 }
191 clearSelectionState(terminal);
192 });
193 return false;
194 }
195 return true;
196 });
197 const selectionChange = terminal.onSelectionChange(() => {
198 updateSelection();
199 if (!terminal.hasSelection()) {
200 clearSelectionAction();
201 }
202 });
203 let frame: number | null = null;
204 const onPointerUp = (event: PointerEvent) => {
205 if (event.button !== 0) return;
206 if (frame !== null) cancelAnimationFrame(frame);
207 frame = requestAnimationFrame(() => {
208 frame = null;
209 reportSelection({ left: event.clientX, top: event.clientY + 8 });
210 });
211 };
212 const onContextMenu = (event: MouseEvent) => {
213 event.preventDefault();
214 updateSelection();
215 setMenu({ left: event.clientX, top: event.clientY });
216 };
217 host.addEventListener("pointerup", onPointerUp);
218 host.addEventListener("contextmenu", onContextMenu);
219 const fitTerminal = () => {
220 if (!fitEnabledRef.current) return;
221 if (host.clientHeight < 32 || host.clientWidth < 32) return;
222 fit.fit();
223 const { cols, rows } = terminal;
224 if (cols > 0 && rows > 0) void resize(tabId, session.id, cols, rows).catch(() => {});
225 };
226 const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(fitTerminal);
227 observer?.observe(host);
228 fitTerminal();
229 return () => {
230 if (frame !== null) cancelAnimationFrame(frame);
231 host.removeEventListener("pointerup", onPointerUp);
232 host.removeEventListener("contextmenu", onContextMenu);
233 observer?.disconnect();
234 stopObservingTheme();
235 selectionChange.dispose();
236 input.dispose();
237 outputResize.dispose();
238 terminalSink.dispose();
239 if (terminalSinkRef.current === terminalSink) terminalSinkRef.current = null;
240 selectionLifecycleRef.current.deactivate(terminal);
241 if (fitRef.current === fit) fitRef.current = null;
242 if (terminalRef.current === terminal) terminalRef.current = null;
243 terminal.dispose();
244 // A session switch disposes this terminal while TerminalPanel stays
245 // mounted; drop any floating selection action so its stale text can
246 // never be added to the chat of the newly active session.
247 onSelectionActionChangeRef.current?.(null);
248 };
249 }, [resize, session.id, tabId, write]);
250
251 useLayoutEffect(() => {
252 terminalSinkRef.current?.setActive(open);
253 }, [open]);
254
255 useEffect(() => {
256 if (!fitEnabled) return;
257 const host = hostRef.current;
258 const terminal = terminalRef.current;
259 const fit = fitRef.current;
260 if (!host || !terminal || !fit) return;
261 if (host.clientHeight < 32 || host.clientWidth < 32) return;
262 fit.fit();
263 const { cols, rows } = terminal;
264 if (cols > 0 && rows > 0) void resize(tabId, session.id, cols, rows).catch(() => {});
265 }, [fitEnabled, resize, session.id, tabId]);
266
267 useEffect(() => {
268 if (!open) setMenu(null);
269 }, [open]);
270
271 const copyShortcut = formatShortcutCombo(
272 shortcutPlatform === "darwin" ? { key: "c", meta: true } : { key: "c", ctrl: true },
273 shortcutPlatform,
274 );
275 const pasteShortcut = formatShortcutCombo(
276 shortcutPlatform === "darwin" ? { key: "v", meta: true } : { key: "v", ctrl: true },
277 shortcutPlatform,
278 );
279
280 return <>
281 <ContextMenu
282 open={open && menu != null}
283 point={menu}
284 minWidth={180}
285 ariaLabel={t("terminal.title")}
286 items={[
287 {
288 key: "copy",
289 label: t("common.copy"),
290 icon: <Copy size={14} />,
291 shortcut: copyShortcut,
292 disabled: !selectionText,
293 onSelect: () => {
294 setMenu(null);
295 void copySelection();
296 },
297 },
298 {
299 key: "paste",
300 label: t("common.paste"),
301 icon: <Clipboard size={14} />,
302 shortcut: pasteShortcut,
303 onSelect: () => {
304 setMenu(null);
305 void pasteFromClipboard();
306 },
307 },
308 { type: "separator", key: "terminal-menu-separator" },
309 {
310 key: "add-to-chat",
311 label: t("selection.addToChat"),
312 icon: <MessageSquare size={14} />,
313 disabled: !selectionText,
314 onSelect: () => {
315 setMenu(null);
316 addSelectionToChat();
317 },
318 },
319 ]}
320 onClose={() => setMenu(null)}
321 />
322 <div ref={hostRef} className="terminal-view" aria-label={session.title} />
323 </>;
324 });
325
325 lines Plain Text