| 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 |