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