返回 DeepSeek-Reasonix
TerminalPanel.tsx
根目录 / desktop / frontend / src / components / TerminalPanel.tsx
1 import { AlertTriangle, MessageSquarePlus, PanelBottomClose, Plus, RefreshCw, TerminalSquare, X } from "lucide-react";
2 import { useCallback, useEffect, useRef, useState } from "react";
3
4 import { useT } from "../lib/i18n";
5 import { startTerminalEventBridge } from "../lib/terminalEvents";
6 import { useTerminalStore } from "../store/terminal";
7 import { TerminalSessionRail } from "./TerminalSessionRail";
8 import { TerminalView } from "./TerminalView";
9
10 export function TerminalPanel({
11 tabId,
12 cwd,
13 readOnly,
14 onClose,
15 onAddOutput,
16 }: {
17 tabId: string;
18 cwd?: string;
19 readOnly: boolean;
20 onClose: () => void;
21 onAddOutput: (sessionId: string) => void;
22 }) {
23 const t = useT();
24 const [selectedShellId, setSelectedShellId] = useState("default");
25 const workspace = useTerminalStore((state) => state.workspace);
26 const loading = useTerminalStore((state) => state.loading);
27 const error = useTerminalStore((state) => state.error);
28 const activeSessionId = useTerminalStore((state) => state.activeSessionId);
29 const syncWorkspace = useTerminalStore((state) => state.syncWorkspace);
30 const ensureReady = useTerminalStore((state) => state.ensureReady);
31 const createSession = useTerminalStore((state) => state.createSession);
32 const closeSession = useTerminalStore((state) => state.closeSession);
33 const clearError = useTerminalStore((state) => state.clearError);
34 const setActiveSession = useTerminalStore((state) => state.setActiveSession);
35 const capabilityRef = useRef({ tabId, readOnly });
36
37 useEffect(() => {
38 startTerminalEventBridge();
39 const previous = capabilityRef.current;
40 const capabilityChanged = previous.tabId === tabId && previous.readOnly !== readOnly;
41 capabilityRef.current = { tabId, readOnly };
42 void syncWorkspace(tabId, capabilityChanged).catch(() => {});
43 }, [readOnly, syncWorkspace, tabId]);
44
45 useEffect(() => {
46 if (workspace && !workspace.shells.some((shell) => shell.id === selectedShellId)) {
47 setSelectedShellId("default");
48 }
49 }, [selectedShellId, workspace]);
50
51 const newSession = useCallback(() => {
52 void createSession(tabId, ".", selectedShellId).catch(() => {});
53 }, [createSession, selectedShellId, tabId]);
54 const sessions = workspace?.sessions ?? [];
55 const shellOptions = workspace?.shells.length
56 ? workspace.shells
57 : [{ id: "default", label: t("terminal.defaultShell") }];
58 const active = sessions.find((session) => session.id === activeSessionId) ?? sessions[0];
59 const terminalReadOnly = readOnly || Boolean(workspace?.readOnly);
60
61 return (
62 <section className="terminal-panel" aria-label={t("terminal.title")}>
63 <header className="terminal-panel__header">
64 <div className="terminal-panel__identity"><TerminalSquare size={15} /><strong>{t("terminal.title")}</strong>{cwd && <span title={cwd}>{cwd}</span>}</div>
65 <div className="terminal-panel__actions">
66 <select
67 className="terminal-shell-select"
68 value={selectedShellId}
69 onChange={(event) => setSelectedShellId(event.target.value)}
70 disabled={!workspace?.available || terminalReadOnly}
71 aria-label={t("terminal.shell")}
72 title={t("terminal.shell")}
73 >
74 {shellOptions.map((shell) => (
75 <option key={shell.id} value={shell.id}>
76 {shell.id === "default" ? t("terminal.defaultShell") : shell.label}
77 </option>
78 ))}
79 </select>
80 <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>
81 <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>
82 <button type="button" className="terminal-icon-button" onClick={onClose} aria-label={t("rightDock.collapse")} title={t("rightDock.collapse")}><PanelBottomClose size={15} /></button>
83 </div>
84 </header>
85 {!workspace && loading ? (
86 <div className="terminal-empty"><span className="terminal-empty__spinner" />{t("terminal.loading")}</div>
87 ) : !workspace && error ? (
88 <div className="terminal-empty terminal-empty--error" role="alert">
89 <AlertTriangle size={18} />
90 <strong>{error}</strong>
91 <button type="button" className="btn btn--secondary btn--small" onClick={() => { clearError(); void ensureReady(tabId).catch(() => {}); }}>
92 <RefreshCw size={14} />{t("terminal.retry")}
93 </button>
94 </div>
95 ) : !workspace ? (
96 <div className="terminal-empty"><AlertTriangle size={18} /><strong>{t("terminal.loading")}</strong></div>
97 ) : !workspace.available || terminalReadOnly ? (
98 <div className="terminal-empty"><AlertTriangle size={18} /><strong>{workspace.reason || t("terminal.readOnly")}</strong></div>
99 ) : (
100 <div className="terminal-panel__body">
101 {error && (
102 <div className="terminal-error" role="alert">
103 <AlertTriangle size={14} />
104 <span>{error}</span>
105 <button type="button" className="terminal-icon-button" onClick={clearError} aria-label={t("terminal.dismissError")} title={t("terminal.dismissError")}><X size={13} /></button>
106 </div>
107 )}
108 {sessions.length > 0 && (
109 <TerminalSessionRail
110 sessions={sessions}
111 activeSessionId={active?.id ?? null}
112 onSelect={setActiveSession}
113 onClose={(id) => void closeSession(tabId, id).catch(() => {})}
114 />
115 )}
116 <div className="terminal-panel__content">
117 {active ? <TerminalView key={active.id} tabId={tabId} session={active} /> : (
118 <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>
119 )}
120 </div>
121 </div>
122 )}
123 </section>
124 );
125 }
126
126 lines Plain Text