diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-background-intents.ts b/apps/desktop/src/renderer/lib/terminal/terminal-background-intents.ts new file mode 100644 index 00000000000..acc7662c1ee --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-background-intents.ts @@ -0,0 +1,9 @@ +const backgroundTerminalIds = new Set(); + +export function markTerminalForBackground(terminalId: string): void { + backgroundTerminalIds.add(terminalId); +} + +export function consumeTerminalBackgroundIntent(terminalId: string): boolean { + return backgroundTerminalIds.delete(terminalId); +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index e10d2e1ab2d..25ac0aeca60 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -26,6 +26,8 @@ import { } from "./terminal-ws-transport"; interface RegistryEntry { + terminalId: string; + instanceId: string; runtime: TerminalRuntime | null; transport: TerminalTransport; linkManager: TerminalLinkManager | null; @@ -35,22 +37,92 @@ interface RegistryEntry { class TerminalRuntimeRegistryImpl { private entries = new Map(); + private entryKeysByTerminalId = new Map>(); - private getOrCreateEntry(terminalId: string): RegistryEntry { - let entry = this.entries.get(terminalId); + private getEntryKey(terminalId: string, instanceId = terminalId): string { + return `${terminalId}\u0000${instanceId}`; + } + + private getOrCreateEntry( + terminalId: string, + instanceId = terminalId, + ): RegistryEntry { + const key = this.getEntryKey(terminalId, instanceId); + let entry = this.entries.get(key); if (entry) return entry; entry = { + terminalId, + instanceId, runtime: null, transport: createTransport(), linkManager: null, pendingLinkHandlers: null, }; - this.entries.set(terminalId, entry); + this.entries.set(key, entry); + let keys = this.entryKeysByTerminalId.get(terminalId); + if (!keys) { + keys = new Set(); + this.entryKeysByTerminalId.set(terminalId, keys); + } + keys.add(key); return entry; } + private getEntry( + terminalId: string, + instanceId?: string, + ): RegistryEntry | null { + if (instanceId) { + return this.entries.get(this.getEntryKey(terminalId, instanceId)) ?? null; + } + return this.getPrimaryEntry(terminalId); + } + + private getPrimaryEntry(terminalId: string): RegistryEntry | null { + const defaultEntry = this.entries.get(this.getEntryKey(terminalId)); + if (defaultEntry) return defaultEntry; + + const keys = this.entryKeysByTerminalId.get(terminalId); + const firstKey = keys?.values().next().value; + return firstKey ? (this.entries.get(firstKey) ?? null) : null; + } + + private getEntries(terminalId: string): RegistryEntry[] { + const keys = this.entryKeysByTerminalId.get(terminalId); + if (!keys) return []; + return Array.from(keys) + .map((key) => this.entries.get(key)) + .filter((entry): entry is RegistryEntry => Boolean(entry)); + } + + private deleteEntry(entry: RegistryEntry) { + const key = this.getEntryKey(entry.terminalId, entry.instanceId); + this.entries.delete(key); + const keys = this.entryKeysByTerminalId.get(entry.terminalId); + if (!keys) return; + keys.delete(key); + if (keys.size === 0) { + this.entryKeysByTerminalId.delete(entry.terminalId); + } + } + + private serializeExistingRuntime( + terminalId: string, + excludedInstanceId: string, + ): string | undefined { + for (const entry of this.getEntries(terminalId)) { + if (entry.instanceId === excludedInstanceId || !entry.runtime) continue; + try { + return entry.runtime.serializeAddon.serialize({ scrollback: 1000 }); + } catch { + return undefined; + } + } + return undefined; + } + /** * Ensure the xterm runtime exists and attach it to `container`. * Synchronous. DOM-only — the WebSocket transport is untouched. @@ -67,11 +139,14 @@ class TerminalRuntimeRegistryImpl { terminalId: string, container: HTMLDivElement, appearance: TerminalAppearance, + instanceId = terminalId, ) { - const entry = this.getOrCreateEntry(terminalId); + const entry = this.getOrCreateEntry(terminalId, instanceId); if (!entry.runtime) { - entry.runtime = createRuntime(terminalId, appearance); + entry.runtime = createRuntime(terminalId, appearance, { + initialBuffer: this.serializeExistingRuntime(terminalId, instanceId), + }); entry.linkManager = new TerminalLinkManager(entry.runtime.terminal); if (entry.pendingLinkHandlers) { entry.linkManager.setHandlers(entry.pendingLinkHandlers); @@ -94,8 +169,8 @@ class TerminalRuntimeRegistryImpl { * * Idempotent: no-op if already connected/connecting to the same URL. */ - connect(terminalId: string, wsUrl: string) { - const entry = this.entries.get(terminalId); + connect(terminalId: string, wsUrl: string, instanceId = terminalId) { + const entry = this.getEntry(terminalId, instanceId); if (!entry?.runtime) return; connect(entry.transport, entry.runtime.terminal, wsUrl); } @@ -112,8 +187,8 @@ class TerminalRuntimeRegistryImpl { * swap), and `"closed"` (previously live and mid-auto-reconnect — swap * the URL so the reconnect targets the new endpoint). */ - reconnect(terminalId: string, wsUrl: string) { - const entry = this.entries.get(terminalId); + reconnect(terminalId: string, wsUrl: string, instanceId = terminalId) { + const entry = this.getEntry(terminalId, instanceId); if (!entry?.runtime) return; if (entry.transport.connectionState === "disconnected") return; if (entry.transport.currentUrl === wsUrl) return; @@ -124,8 +199,12 @@ class TerminalRuntimeRegistryImpl { * Set link handler callbacks for a terminal. Safe to call before or after * mount(). If the runtime already exists, link providers are re-registered. */ - setLinkHandlers(terminalId: string, handlers: TerminalLinkHandlers) { - const entry = this.getOrCreateEntry(terminalId); + setLinkHandlers( + terminalId: string, + handlers: TerminalLinkHandlers, + instanceId = terminalId, + ) { + const entry = this.getOrCreateEntry(terminalId, instanceId); if (entry.linkManager) { entry.linkManager.setHandlers(handlers); } else { @@ -138,15 +217,19 @@ class TerminalRuntimeRegistryImpl { * transport stay alive; DOM is moved off the React-controlled tree so * it survives the parent unmount without re-entering xterm.open(). */ - detach(terminalId: string) { - const entry = this.entries.get(terminalId); + detach(terminalId: string, instanceId = terminalId) { + const entry = this.getEntry(terminalId, instanceId); if (!entry?.runtime) return; detachFromContainer(entry.runtime); } - updateAppearance(terminalId: string, appearance: TerminalAppearance) { - const entry = this.entries.get(terminalId); + updateAppearance( + terminalId: string, + appearance: TerminalAppearance, + instanceId = terminalId, + ) { + const entry = this.getEntry(terminalId, instanceId); if (!entry?.runtime) return; const prevCols = entry.runtime.terminal.cols; @@ -160,89 +243,129 @@ class TerminalRuntimeRegistryImpl { } } - dispose(terminalId: string) { - const entry = this.entries.get(terminalId); - if (!entry) return; - + private disposeEntry( + entry: RegistryEntry, + options: { clearPersistedState?: boolean } = {}, + ) { entry.linkManager?.dispose(); - - sendDispose(entry.transport); disposeTransport(entry.transport); - if (entry.runtime) disposeRuntime(entry.runtime); + if (entry.runtime) { + disposeRuntime(entry.runtime, options); + } + this.deleteEntry(entry); + } - this.entries.delete(terminalId); + /** + * Release the renderer-side terminal runtime only. This detaches the xterm + * view and closes the WebSocket, but it does not tell host-service to kill + * the underlying PTY. Use this for pane/sidebar lifecycle cleanup. + */ + release(terminalId: string, instanceId?: string) { + const entries = instanceId + ? [this.getEntry(terminalId, instanceId)].filter( + (entry): entry is RegistryEntry => Boolean(entry), + ) + : this.getEntries(terminalId); + for (const entry of entries) { + this.disposeEntry(entry, { clearPersistedState: false }); + } } - getSelection(terminalId: string): string { - const entry = this.entries.get(terminalId); + /** + * Kill the host-service terminal session and remove all renderer-side state. + * This is destructive and should only be used from explicit kill actions. + */ + dispose(terminalId: string) { + for (const entry of this.getEntries(terminalId)) { + sendDispose(entry.transport); + this.disposeEntry(entry); + } + } + + getSelection(terminalId: string, instanceId?: string): string { + const entry = this.getEntry(terminalId, instanceId); return entry?.runtime?.terminal.getSelection() ?? ""; } - clear(terminalId: string): void { - const entry = this.entries.get(terminalId); + clear(terminalId: string, instanceId?: string): void { + const entry = this.getEntry(terminalId, instanceId); entry?.runtime?.terminal.clear(); } - scrollToBottom(terminalId: string): void { - const entry = this.entries.get(terminalId); + scrollToBottom(terminalId: string, instanceId?: string): void { + const entry = this.getEntry(terminalId, instanceId); entry?.runtime?.terminal.scrollToBottom(); } - paste(terminalId: string, text: string): void { - const entry = this.entries.get(terminalId); + paste(terminalId: string, text: string, instanceId?: string): void { + const entry = this.getEntry(terminalId, instanceId); entry?.runtime?.terminal.paste(text); } /** Send raw input to the terminal via the WebSocket transport (bypasses xterm). */ - writeInput(terminalId: string, data: string): void { - const entry = this.entries.get(terminalId); + writeInput(terminalId: string, data: string, instanceId?: string): void { + const entry = this.getEntry(terminalId, instanceId); if (!entry) return; sendInput(entry.transport, data); } - findNext(terminalId: string, query: string): boolean { - const entry = this.entries.get(terminalId); + findNext(terminalId: string, query: string, instanceId?: string): boolean { + const entry = this.getEntry(terminalId, instanceId); return entry?.runtime?.searchAddon?.findNext(query) ?? false; } - findPrevious(terminalId: string, query: string): boolean { - const entry = this.entries.get(terminalId); + findPrevious( + terminalId: string, + query: string, + instanceId?: string, + ): boolean { + const entry = this.getEntry(terminalId, instanceId); return entry?.runtime?.searchAddon?.findPrevious(query) ?? false; } - clearSearch(terminalId: string): void { - const entry = this.entries.get(terminalId); + clearSearch(terminalId: string, instanceId?: string): void { + const entry = this.getEntry(terminalId, instanceId); entry?.runtime?.searchAddon?.clearDecorations(); } - getTerminal(terminalId: string) { - return this.entries.get(terminalId)?.runtime?.terminal ?? null; + getTerminal(terminalId: string, instanceId?: string) { + return this.getEntry(terminalId, instanceId)?.runtime?.terminal ?? null; } - getSearchAddon(terminalId: string): SearchAddon | null { - return this.entries.get(terminalId)?.runtime?.searchAddon ?? null; + getSearchAddon(terminalId: string, instanceId?: string): SearchAddon | null { + return this.getEntry(terminalId, instanceId)?.runtime?.searchAddon ?? null; } - getProgressAddon(terminalId: string): ProgressAddon | null { - return this.entries.get(terminalId)?.runtime?.progressAddon ?? null; + getProgressAddon( + terminalId: string, + instanceId?: string, + ): ProgressAddon | null { + return ( + this.getEntry(terminalId, instanceId)?.runtime?.progressAddon ?? null + ); } getAllTerminalIds(): Set { - return new Set(this.entries.keys()); + return new Set(this.entryKeysByTerminalId.keys()); } has(terminalId: string): boolean { - return this.entries.has(terminalId); + return this.entryKeysByTerminalId.has(terminalId); } - getConnectionState(terminalId: string): ConnectionState { + getConnectionState(terminalId: string, instanceId?: string): ConnectionState { return ( - this.entries.get(terminalId)?.transport.connectionState ?? "disconnected" + this.getEntry(terminalId, instanceId)?.transport.connectionState ?? + "disconnected" ); } - onStateChange(terminalId: string, listener: () => void): () => void { - const entry = this.getOrCreateEntry(terminalId); + onStateChange( + terminalId: string, + listener: () => void, + instanceId = terminalId, + ): () => void { + const entry = this.getOrCreateEntry(terminalId, instanceId); entry.transport.stateListeners.add(listener); return () => { entry.transport.stateListeners.delete(listener); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index 108784061b2..71cb8ada0c9 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -255,6 +255,7 @@ function measureAndResize(runtime: TerminalRuntime) { export function createRuntime( terminalId: string, appearance: TerminalAppearance, + options: { initialBuffer?: string } = {}, ): TerminalRuntime { const savedDims = loadSavedDimensions(terminalId); const cols = savedDims?.cols ?? DEFAULT_COLS; @@ -276,7 +277,11 @@ export function createRuntime( // Activate Unicode 11 widths (inside loadAddons) before restoring the buffer, // else CJK/emoji/ZWJ widths get baked wrong into the replay. (#3572) const addonsResult = loadAddons(terminal); - restoreBuffer(terminalId, terminal); + if (options.initialBuffer !== undefined) { + terminal.write(options.initialBuffer); + } else { + restoreBuffer(terminalId, terminal); + } return { terminalId, @@ -360,13 +365,23 @@ export function updateRuntimeAppearance( } } -export function disposeRuntime(runtime: TerminalRuntime) { +export function disposeRuntime( + runtime: TerminalRuntime, + options: { clearPersistedState?: boolean } = {}, +) { + const clearPersistedState = options.clearPersistedState ?? true; + if (!clearPersistedState) { + persistBuffer(runtime.terminalId, runtime.serializeAddon); + persistDimensions(runtime.terminalId, runtime.lastCols, runtime.lastRows); + } runtime._disposeAddons?.(); runtime._disposeAddons = null; runtime.resizeObserver?.disconnect(); runtime.resizeObserver = null; runtime.wrapper.remove(); runtime.terminal.dispose(); - clearPersistedBuffer(runtime.terminalId); - clearPersistedDimensions(runtime.terminalId); + if (clearPersistedState) { + clearPersistedBuffer(runtime.terminalId); + clearPersistedDimensions(runtime.terminalId); + } } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index a41d06890cf..96fd1d122ca 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -56,6 +56,7 @@ export function TerminalPane({ const { hint, showHint } = useLinkClickHint(); const paneData = ctx.pane.data as TerminalPaneData; const { terminalId } = paneData; + const terminalInstanceId = ctx.pane.id; const containerRef = useRef(null); const activeTheme = useTheme(); const [isSearchOpen, setIsSearchOpen] = useState(false); @@ -82,6 +83,12 @@ export function TerminalPane({ const ensureSession = workspaceTrpc.terminal.ensureSession.useMutation(); const ensureSessionRef = useRef(ensureSession); ensureSessionRef.current = ensureSession; + const workspaceTrpcUtils = workspaceTrpc.useUtils(); + const invalidateTerminalSessionsRef = useRef( + workspaceTrpcUtils.terminal.listSessions.invalidate, + ); + invalidateTerminalSessionsRef.current = + workspaceTrpcUtils.terminal.listSessions.invalidate; // useCallback so useSyncExternalStore doesn't re-subscribe every render — // otherwise every keystroke-triggered re-render unsubscribes and @@ -89,13 +96,20 @@ export function TerminalPane({ // docs ("If you don't memoize the subscribe function…"). const subscribe = useCallback( (callback: () => void) => - terminalRuntimeRegistry.onStateChange(terminalId, callback), - [terminalId], + terminalRuntimeRegistry.onStateChange( + terminalId, + callback, + terminalInstanceId, + ), + [terminalId, terminalInstanceId], ); const getSnapshot = useCallback( (): ConnectionState => - terminalRuntimeRegistry.getConnectionState(terminalId), - [terminalId], + terminalRuntimeRegistry.getConnectionState( + terminalId, + terminalInstanceId, + ), + [terminalId, terminalInstanceId], ); const connectionState = useSyncExternalStore(subscribe, getSnapshot); @@ -114,9 +128,15 @@ export function TerminalPane({ const container = containerRef.current; if (!container) return; - terminalRuntimeRegistry.mount(terminalId, container, appearanceRef.current); + terminalRuntimeRegistry.mount( + terminalId, + container, + appearanceRef.current, + terminalInstanceId, + ); let cancelled = false; + const sessionWorkspaceId = workspaceIdRef.current; // Always connect after ensureSession settles, even on error: if the // session actually exists on the server (e.g. we raced another client), @@ -126,34 +146,53 @@ export function TerminalPane({ ensureSessionRef.current .mutateAsync({ terminalId, - workspaceId: workspaceIdRef.current, + workspaceId: sessionWorkspaceId, themeType: initialThemeTypeRef.current, }) + .then((result) => { + if (result.status === "active") { + void invalidateTerminalSessionsRef.current({ + workspaceId: sessionWorkspaceId, + }); + } + }) .catch((err) => { console.error("[TerminalPane] ensureSession failed:", err); }) .finally(() => { if (cancelled) return; - terminalRuntimeRegistry.connect(terminalId, websocketUrlRef.current); + terminalRuntimeRegistry.connect( + terminalId, + websocketUrlRef.current, + terminalInstanceId, + ); }); return () => { cancelled = true; - terminalRuntimeRegistry.detach(terminalId); + terminalRuntimeRegistry.detach(terminalId, terminalInstanceId); }; - }, [terminalId]); + }, [terminalId, terminalInstanceId]); // WS URL can change while the terminal stays mounted (token refresh, host // URL re-resolution on provider remount). Reconnect only if the transport // is already live — on initial mount the transport is "disconnected" and // we let the ensureSession path above open it. useEffect(() => { - terminalRuntimeRegistry.reconnect(terminalId, websocketUrl); - }, [terminalId, websocketUrl]); + terminalRuntimeRegistry.reconnect( + terminalId, + websocketUrl, + terminalInstanceId, + ); + }, [terminalId, terminalInstanceId, websocketUrl]); useEffect(() => { - terminalRuntimeRegistry.updateAppearance(terminalId, appearance); - }, [terminalId, appearance]); + terminalRuntimeRegistry.updateAppearance( + terminalId, + appearance, + terminalInstanceId, + ); + }, [terminalId, terminalInstanceId, appearance]); // --- Link handlers --- // All filesystem operations go through the host service. @@ -164,79 +203,84 @@ export function TerminalPane({ statPathRef.current = statPathMutation.mutateAsync; useEffect(() => { - terminalRuntimeRegistry.setLinkHandlers(terminalId, { - stat: async (path) => { - try { - const result = await statPathRef.current({ - workspaceId, - path, - }); - if (!result) return null; - return { - isDirectory: result.isDirectory, - resolvedPath: result.resolvedPath, - }; - } catch { - return null; - } - }, - onFileLinkClick: (event, link) => { - // Folders are not settings-controlled: ⌘ reveals in sidebar, - // ⌘⇧ falls through to the external editor path, plain = hint. - if (link.isDirectory) { - if (!event.metaKey && !event.ctrlKey) { + terminalRuntimeRegistry.setLinkHandlers( + terminalId, + { + stat: async (path) => { + try { + const result = await statPathRef.current({ + workspaceId, + path, + }); + if (!result) return null; + return { + isDirectory: result.isDirectory, + resolvedPath: result.resolvedPath, + }; + } catch { + return null; + } + }, + onFileLinkClick: (event, link) => { + // Folders are not settings-controlled: ⌘ reveals in sidebar, + // ⌘⇧ falls through to the external editor path, plain = hint. + if (link.isDirectory) { + if (!event.metaKey && !event.ctrlKey) { + showHint(event.clientX, event.clientY); + return; + } + event.preventDefault(); + if (event.shiftKey) { + openInExternalEditor(link.resolvedPath); + } else { + onRevealPath(link.resolvedPath, { isDirectory: true }); + } + return; + } + + const action = getFileAction(event); + if (action === null) { showHint(event.clientX, event.clientY); return; } event.preventDefault(); - if (event.shiftKey) { - openInExternalEditor(link.resolvedPath); + if (action === "external") { + openInExternalEditor(link.resolvedPath, { + line: link.row, + column: link.col, + }); } else { - onRevealPath(link.resolvedPath, { isDirectory: true }); + onOpenFile(link.resolvedPath); } - return; - } - - const action = getFileAction(event); - if (action === null) { - showHint(event.clientX, event.clientY); - return; - } - event.preventDefault(); - if (action === "external") { - openInExternalEditor(link.resolvedPath, { - line: link.row, - column: link.col, - }); - } else { - onOpenFile(link.resolvedPath); - } - }, - onUrlClick: (event, url) => { - const action = getUrlAction(event); - if (action === null) { - showHint(event.clientX, event.clientY); - return; - } - event.preventDefault(); - if (action === "external") { - electronTrpcClient.external.openUrl.mutate(url).catch((error) => { - console.error("[v2 Terminal] Failed to open URL:", url, error); - }); - } else { - ctx.store.getState().openPane({ - pane: { - kind: "browser", - data: { url } satisfies BrowserPaneData, - }, - }); - } + }, + onUrlClick: (event, url) => { + const action = getUrlAction(event); + if (action === null) { + showHint(event.clientX, event.clientY); + return; + } + event.preventDefault(); + if (action === "external") { + electronTrpcClient.external.openUrl.mutate(url).catch((error) => { + console.error("[v2 Terminal] Failed to open URL:", url, error); + }); + } else { + ctx.store.getState().openPane({ + pane: { + kind: "browser", + data: { url } satisfies BrowserPaneData, + }, + }); + } + }, + onLinkHover, + onLinkLeave, }, - onLinkHover, - onLinkLeave, - }); + terminalInstanceId, + ); }, [ terminalId, + terminalInstanceId, workspaceId, ctx.store, onOpenFile, @@ -252,7 +296,7 @@ export function TerminalPane({ useHotkey( "CLEAR_TERMINAL", () => { - terminalRuntimeRegistry.clear(terminalId); + terminalRuntimeRegistry.clear(terminalId, terminalInstanceId); }, { enabled: ctx.isActive }, ); @@ -260,7 +304,7 @@ export function TerminalPane({ useHotkey( "SCROLL_TO_BOTTOM", () => { - terminalRuntimeRegistry.scrollToBottom(terminalId); + terminalRuntimeRegistry.scrollToBottom(terminalId, terminalInstanceId); }, { enabled: ctx.isActive }, ); @@ -273,14 +317,15 @@ export function TerminalPane({ // connectionState in deps ensures terminal ref re-derives after connect/disconnect // biome-ignore lint/correctness/useExhaustiveDependencies: connectionState is intentionally included to trigger re-derive const terminal = useMemo( - () => terminalRuntimeRegistry.getTerminal(terminalId), - [terminalId, connectionState], + () => terminalRuntimeRegistry.getTerminal(terminalId, terminalInstanceId), + [terminalId, terminalInstanceId, connectionState], ); // biome-ignore lint/correctness/useExhaustiveDependencies: connectionState is intentionally included to trigger re-derive const searchAddon = useMemo( - () => terminalRuntimeRegistry.getSearchAddon(terminalId), - [terminalId, connectionState], + () => + terminalRuntimeRegistry.getSearchAddon(terminalId, terminalInstanceId), + [terminalId, terminalInstanceId, connectionState], ); const [isDropActive, setIsDropActive] = useState(false); @@ -325,8 +370,10 @@ export function TerminalPane({ if (connectionState === "closed") return; const text = resolveDroppedText(event.dataTransfer); if (!text) return; - terminalRuntimeRegistry.getTerminal(terminalId)?.focus(); - terminalRuntimeRegistry.paste(terminalId, text); + terminalRuntimeRegistry + .getTerminal(terminalId, terminalInstanceId) + ?.focus(); + terminalRuntimeRegistry.paste(terminalId, text, terminalInstanceId); }; return ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx new file mode 100644 index 00000000000..fe1da2e097a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx @@ -0,0 +1,44 @@ +import type { RendererContext } from "@superset/panes"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { Archive } from "lucide-react"; +import { markTerminalForBackground } from "renderer/lib/terminal/terminal-background-intents"; +import type { + PaneViewerData, + TerminalPaneData, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; + +interface TerminalHeaderExtrasProps { + context: RendererContext; +} + +export function TerminalHeaderExtras({ context }: TerminalHeaderExtrasProps) { + if (context.pane.kind !== "terminal") return null; + + const data = context.pane.data as TerminalPaneData; + + const handleMoveToBackground = () => { + markTerminalForBackground(data.terminalId); + void context.actions.close(); + }; + + return ( + + + + + + Move terminal to background + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/index.ts new file mode 100644 index 00000000000..7f654de91f5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/index.ts @@ -0,0 +1 @@ +export { TerminalHeaderExtras } from "./TerminalHeaderExtras"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx new file mode 100644 index 00000000000..6e0d97c0c20 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx @@ -0,0 +1,307 @@ +import type { RendererContext } from "@superset/panes"; +import { alert } from "@superset/ui/atoms/Alert"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { + Check, + ChevronDown, + LoaderCircle, + Plus, + TerminalSquare, + Trash2, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { markTerminalForBackground } from "renderer/lib/terminal/terminal-background-intents"; +import type { + PaneViewerData, + TerminalPaneData, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { getRelativeTime } from "renderer/screens/main/components/WorkspacesListView/utils"; + +interface TerminalSessionDropdownProps { + context: RendererContext; + workspaceId: string; +} + +interface VisibleTerminalSession { + terminalId: string; + workspaceId: string; + createdAt?: number; + exited: boolean; + exitCode: number; + attached: boolean; + pending?: boolean; +} + +interface TerminalPaneLocation { + tabId: string; + paneId: string; + titleOverride?: string; +} + +function formatCreatedAt(createdAt: number | undefined): string { + if (!createdAt) return "Creating"; + + return getRelativeTime(createdAt, { format: "compact" }); +} + +function getTerminalPaneLocations( + context: RendererContext, +): Map { + const locations = new Map(); + for (const tab of context.store.getState().tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.id === context.pane.id || pane.kind !== "terminal") continue; + const data = pane.data as Partial; + if (data.terminalId) { + const terminalLocations = locations.get(data.terminalId) ?? []; + terminalLocations.push({ + tabId: tab.id, + paneId: pane.id, + titleOverride: pane.titleOverride, + }); + locations.set(data.terminalId, terminalLocations); + } + } + } + return locations; +} + +export function TerminalSessionDropdown({ + context, + workspaceId, +}: TerminalSessionDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const data = context.pane.data as TerminalPaneData; + const { terminalId } = data; + const utils = workspaceTrpc.useUtils(); + const killTerminalSession = workspaceTrpc.terminal.killSession.useMutation(); + const sessionsQuery = workspaceTrpc.terminal.listSessions.useQuery( + { workspaceId }, + { + refetchInterval: isOpen ? 2_000 : false, + refetchOnWindowFocus: true, + }, + ); + + const sessions = useMemo(() => { + const liveSessions = sessionsQuery.data?.sessions ?? []; + if (liveSessions.some((session) => session.terminalId === terminalId)) { + return liveSessions; + } + return [ + { + terminalId, + workspaceId, + exited: false, + exitCode: 0, + attached: false, + pending: true, + }, + ...liveSessions, + ]; + }, [sessionsQuery.data?.sessions, terminalId, workspaceId]); + const renderTerminalPaneLocations = getTerminalPaneLocations(context); + + const handleSelectSession = (nextTerminalId: string) => { + if (nextTerminalId === terminalId) { + setIsOpen(false); + return; + } + + const state = context.store.getState(); + const terminalPaneLocations = getTerminalPaneLocations(context); + const existingLocation = terminalPaneLocations.get(nextTerminalId)?.[0]; + if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) { + markTerminalForBackground(terminalId); + } + + state.setPaneData({ + paneId: context.pane.id, + data: { terminalId: nextTerminalId } as PaneViewerData, + }); + state.setPaneTitleOverride({ + tabId: context.tab.id, + paneId: context.pane.id, + titleOverride: existingLocation?.titleOverride, + }); + setIsOpen(false); + }; + + const closePanesForTerminal = (targetTerminalId: string) => { + const terminalPaneLocations = getTerminalPaneLocations(context); + for (const location of terminalPaneLocations.get(targetTerminalId) ?? []) { + context.store.getState().closePane({ + tabId: location.tabId, + paneId: location.paneId, + }); + } + + if (targetTerminalId === terminalId) { + void context.actions.close(); + } + }; + + const removeTerminalSession = async (targetTerminalId: string) => { + await killTerminalSession.mutateAsync({ + terminalId: targetTerminalId, + workspaceId, + }); + closePanesForTerminal(targetTerminalId); + await utils.terminal.listSessions.invalidate({ workspaceId }); + }; + + const handleRemoveTerminal = (targetTerminalId: string) => { + alert({ + title: "Remove terminal session?", + description: + "This will terminate the underlying process. Use Move terminal to background to keep it running without a pane.", + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: "Remove Terminal", + variant: "destructive", + onClick: () => { + toast.promise(removeTerminalSession(targetTerminalId), { + loading: "Removing terminal...", + success: "Terminal removed", + error: "Failed to remove terminal", + }); + }, + }, + ], + }); + }; + + const handleNewTerminal = () => { + const state = context.store.getState(); + const terminalPaneLocations = getTerminalPaneLocations(context); + if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) { + markTerminalForBackground(terminalId); + } + state.setPaneData({ + paneId: context.pane.id, + data: { + terminalId: crypto.randomUUID(), + } as PaneViewerData, + }); + state.setPaneTitleOverride({ + tabId: context.tab.id, + paneId: context.pane.id, + titleOverride: undefined, + }); + void utils.terminal.listSessions.invalidate({ workspaceId }); + setIsOpen(false); + }; + + const triggerTitle = context.pane.titleOverride ?? "Terminal"; + const currentSession = sessions.find( + (session) => session.terminalId === terminalId, + ); + const currentCreatedAtLabel = formatCreatedAt(currentSession?.createdAt); + + return ( + + + + + + + Terminal Sessions + + +
+ {sessions.length > 0 ? ( + sessions.map((session) => { + const isCurrent = session.terminalId === terminalId; + const location = renderTerminalPaneLocations.get( + session.terminalId, + )?.[0]; + const createdAtLabel = formatCreatedAt(session.createdAt); + const status = isCurrent + ? "Current" + : session.pending + ? "Starting" + : session.attached + ? "Attached" + : "Detached"; + const title = isCurrent + ? triggerTitle + : (location?.titleOverride ?? "Terminal"); + + return ( + { + handleSelectSession(session.terminalId); + }} + > + + {isCurrent && } + + + {title} + + + {createdAtLabel} + + + {status} + + + + ); + }) + ) : ( +
+ No live sessions +
+ )} +
+ + + + New Terminal + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/index.ts new file mode 100644 index 00000000000..320b21eb495 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/index.ts @@ -0,0 +1 @@ +export { TerminalSessionDropdown } from "./TerminalSessionDropdown"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 4dd3195bc33..f726335309d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -4,8 +4,10 @@ import type { RendererContext, } from "@superset/panes"; import { alert } from "@superset/ui/atoms/Alert"; +import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; +import { workspaceTrpc } from "@superset/workspace-client"; import { Circle, GitCompareArrows, @@ -22,6 +24,7 @@ import { LuClipboard, LuClipboardCopy, LuEraser, + LuPower, } from "react-icons/lu"; import { TbScan } from "react-icons/tb"; import { useHotkeyDisplay } from "renderer/hotkeys"; @@ -46,6 +49,8 @@ import { DiffPane } from "./components/DiffPane"; import { FilePane } from "./components/FilePane"; import { FilePaneHeaderExtras } from "./components/FilePane/components/FilePaneHeaderExtras"; import { TerminalPane } from "./components/TerminalPane"; +import { TerminalHeaderExtras } from "./components/TerminalPane/components/TerminalHeaderExtras"; +import { TerminalSessionDropdown } from "./components/TerminalPane/components/TerminalSessionDropdown"; function getFileName(filePath: string): string { return filePath.split("/").pop() ?? filePath; @@ -145,6 +150,21 @@ export function usePaneRegistry( ): PaneRegistry { const clearShortcut = useHotkeyDisplay("CLEAR_TERMINAL").text; const scrollToBottomShortcut = useHotkeyDisplay("SCROLL_TO_BOTTOM").text; + const workspaceTrpcUtils = workspaceTrpc.useUtils(); + const { mutate: killTerminalSession, isPending: isKillingTerminalSession } = + workspaceTrpc.terminal.killSession.useMutation({ + onSuccess: () => { + toast.success("Terminal session killed"); + void workspaceTrpcUtils.terminal.listSessions.invalidate({ + workspaceId, + }); + }, + onError: (error) => { + toast.error("Failed to kill terminal session", { + description: error.message, + }); + }, + }); return useMemo>( () => ({ @@ -236,6 +256,12 @@ export function usePaneRegistry( terminal: { getIcon: () => , getTitle: () => "Terminal", + renderTitle: (ctx: RendererContext) => ( + + ), + renderHeaderExtras: (ctx: RendererContext) => ( + + ), renderPane: (ctx: RendererContext) => ( { const { terminalId } = ctx.pane.data as TerminalPaneData; - return !terminalRuntimeRegistry.getSelection(terminalId); + return !terminalRuntimeRegistry.getSelection( + terminalId, + ctx.pane.id, + ); }, onSelect: (ctx) => { const { terminalId } = ctx.pane.data as TerminalPaneData; - const text = terminalRuntimeRegistry.getSelection(terminalId); + const text = terminalRuntimeRegistry.getSelection( + terminalId, + ctx.pane.id, + ); if (text) navigator.clipboard.writeText(text); }, }, @@ -270,7 +302,13 @@ export function usePaneRegistry( const { terminalId } = ctx.pane.data as TerminalPaneData; try { const text = await navigator.clipboard.readText(); - if (text) terminalRuntimeRegistry.paste(terminalId, text); + if (text) { + terminalRuntimeRegistry.paste( + terminalId, + text, + ctx.pane.id, + ); + } } catch { // Clipboard access denied } @@ -285,7 +323,7 @@ export function usePaneRegistry( clearShortcut !== "Unassigned" ? clearShortcut : undefined, onSelect: (ctx) => { const { terminalId } = ctx.pane.data as TerminalPaneData; - terminalRuntimeRegistry.clear(terminalId); + terminalRuntimeRegistry.clear(terminalId, ctx.pane.id); }, }, { @@ -298,18 +336,51 @@ export function usePaneRegistry( : undefined, onSelect: (ctx) => { const { terminalId } = ctx.pane.data as TerminalPaneData; - terminalRuntimeRegistry.scrollToBottom(terminalId); + terminalRuntimeRegistry.scrollToBottom(terminalId, ctx.pane.id); }, }, { key: "sep-terminal-defaults", type: "separator" }, ]; - // Update close label const modifiedDefaults = defaults.map((d) => d.key === "close-pane" ? { ...d, label: "Close Terminal" } : d, ); - return [...terminalActions, ...modifiedDefaults]; + const killAction: ContextMenuActionConfig = { + key: "kill-terminal-session", + label: "Kill Terminal Session", + icon: , + variant: "destructive", + disabled: isKillingTerminalSession, + onSelect: (ctx) => { + const { terminalId } = ctx.pane.data as TerminalPaneData; + alert({ + title: "Kill terminal session?", + description: + "This will terminate the underlying process. Move the terminal to background to keep it running without a pane.", + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: "Kill Session", + variant: "destructive", + onClick: () => { + killTerminalSession({ + terminalId, + workspaceId, + }); + }, + }, + ], + }); + }, + }; + + return [ + ...terminalActions, + ...modifiedDefaults, + { key: "sep-terminal-kill", type: "separator" }, + killAction, + ]; }, }, browser: { @@ -409,6 +480,8 @@ export function usePaneRegistry( workspaceId, clearShortcut, scrollToBottomShortcut, + killTerminalSession, + isKillingTerminalSession, onOpenFile, onRevealPath, ], diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts index 92c2a17f489..2f5768fa2af 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts @@ -3,6 +3,12 @@ import { useLiveQuery } from "@tanstack/react-db"; import { useEffect, useRef } from "react"; import { browserRuntimeRegistry } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { + extractPaneLocations, + extractWorkspaceIds, + getRemovedPaneLocations, + type PaneLifecycleRow, +} from "../../../utils/paneLifecycleRows"; /** * Grace period for cross-workspace pane moves / renames before destroying. @@ -10,20 +16,21 @@ import { useCollections } from "renderer/routes/_authenticated/providers/Collect */ const DESTROY_DELAY_MS = 500; -function extractBrowserPaneIds(rows: { paneLayout: unknown }[]): Set { - const ids = new Set(); - for (const row of rows) { - const layout = row.paneLayout as WorkspaceState | undefined; - if (!layout?.tabs) continue; - for (const tab of layout.tabs) { - for (const pane of Object.values(tab.panes)) { - if (pane.kind === "browser") { - ids.add(pane.id); - } - } - } - } - return ids; +interface PendingBrowserDestruction { + workspaceId: string; + timer: ReturnType | null; +} + +function getBrowserPaneId( + pane: WorkspaceState["tabs"][number]["panes"][string], +): string | null { + return pane.kind === "browser" ? pane.id : null; +} + +function extractBrowserLocations( + rows: PaneLifecycleRow[], +): Map { + return extractPaneLocations(rows, getBrowserPaneId); } /** @@ -40,8 +47,8 @@ function extractBrowserPaneIds(rows: { paneLayout: unknown }[]): Set { */ export function useGlobalBrowserLifecycle() { const collections = useCollections(); - const prevBrowserIdsRef = useRef>(new Set()); - const pendingDestruction = useRef>>( + const prevBrowserLocationsRef = useRef>(new Map()); + const pendingDestruction = useRef>( new Map(), ); @@ -54,47 +61,79 @@ export function useGlobalBrowserLifecycle() { ); useEffect(() => { - const currentBrowserIds = extractBrowserPaneIds(allWorkspaceRows); - const prevBrowserIds = prevBrowserIdsRef.current; + const rows = allWorkspaceRows as PaneLifecycleRow[]; + const currentBrowserLocations = extractBrowserLocations(rows); + const currentWorkspaceIds = extractWorkspaceIds(rows); + const prevBrowserLocations = prevBrowserLocationsRef.current; // Cancel any pending destruction for ids that reappeared (e.g. pane // moved between workspaces, user undo, or the transient replaceState // churn we were fighting in the first place). - for (const browserId of currentBrowserIds) { - const timer = pendingDestruction.current.get(browserId); - if (timer) { - clearTimeout(timer); + for (const browserId of currentBrowserLocations.keys()) { + const pending = pendingDestruction.current.get(browserId); + if (pending?.timer) { + clearTimeout(pending.timer); + } + pendingDestruction.current.delete(browserId); + } + + // If a pane was authoritatively removed but the owner row disappeared + // before the grace timer fired, keep waiting until that row is present + // again. That avoids destroying webviews during sleep/wake while still + // cleaning up when the post-removal layout comes back. + for (const [browserId, pending] of pendingDestruction.current) { + if (pending.timer) continue; + if (currentWorkspaceIds.has(pending.workspaceId)) { pendingDestruction.current.delete(browserId); + browserRuntimeRegistry.destroy(browserId); } } - for (const browserId of prevBrowserIds) { - if (currentBrowserIds.has(browserId)) continue; + const removedLocations = getRemovedPaneLocations({ + previousLocations: prevBrowserLocations, + currentLocations: currentBrowserLocations, + currentWorkspaceIds, + }); + + for (const { id: browserId, workspaceId } of removedLocations) { if (pendingDestruction.current.has(browserId)) continue; const timer = setTimeout(() => { - pendingDestruction.current.delete(browserId); - const freshRows = Array.from( collections.v2WorkspaceLocalState.state.values(), - ); - const freshIds = extractBrowserPaneIds(freshRows); + ) as PaneLifecycleRow[]; + const freshLocations = extractBrowserLocations(freshRows); + const freshWorkspaceIds = extractWorkspaceIds(freshRows); - if (!freshIds.has(browserId)) { + if (freshLocations.has(browserId)) { + pendingDestruction.current.delete(browserId); + return; + } + + if (freshWorkspaceIds.has(workspaceId)) { + pendingDestruction.current.delete(browserId); browserRuntimeRegistry.destroy(browserId); + return; + } + + const pending = pendingDestruction.current.get(browserId); + if (pending) { + pending.timer = null; } }, DESTROY_DELAY_MS); - pendingDestruction.current.set(browserId, timer); + pendingDestruction.current.set(browserId, { workspaceId, timer }); } - prevBrowserIdsRef.current = currentBrowserIds; + prevBrowserLocationsRef.current = currentBrowserLocations; }, [allWorkspaceRows, collections]); useEffect(() => { return () => { - for (const timer of pendingDestruction.current.values()) { - clearTimeout(timer); + for (const pending of pendingDestruction.current.values()) { + if (pending.timer) { + clearTimeout(pending.timer); + } } pendingDestruction.current.clear(); }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts index 172eb65ac26..ee39bff4278 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts @@ -1,41 +1,124 @@ import type { WorkspaceState } from "@superset/panes"; +import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; +import { env } from "renderer/env.renderer"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { consumeTerminalBackgroundIntent } from "renderer/lib/terminal/terminal-background-intents"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + extractPaneLocations, + extractWorkspaceIds, + getRemovedPaneLocations, + type PaneLifecycleRow, +} from "../../../utils/paneLifecycleRows"; -/** Grace period for cross-workspace pane moves before disposing. */ -const DISPOSE_DELAY_MS = 500; +/** Grace period for cross-workspace pane moves before terminal cleanup. */ +const RELEASE_DELAY_MS = 500; interface TerminalPaneData { terminalId: string; } -function extractTerminalIds(rows: { paneLayout: unknown }[]): Set { - const ids = new Set(); - for (const row of rows) { - const layout = row.paneLayout as WorkspaceState | undefined; - if (!layout?.tabs) continue; - for (const tab of layout.tabs) { - for (const pane of Object.values(tab.panes)) { - if (pane.kind === "terminal") { - const data = pane.data as TerminalPaneData; - if (data.terminalId) { - ids.add(data.terminalId); - } - } - } - } +interface PendingTerminalCleanup { + workspaceId: string; + timer: ReturnType | null; +} + +interface PendingTerminalInstanceRelease { + terminalId: string; + workspaceId: string; + timer: ReturnType | null; +} + +function getTerminalId( + pane: WorkspaceState["tabs"][number]["panes"][string], +): string | null { + if (pane.kind !== "terminal") return null; + if (!pane.data || typeof pane.data !== "object") return null; + const data = pane.data as Partial; + return typeof data.terminalId === "string" ? data.terminalId : null; +} + +function getTerminalInstanceKey( + pane: WorkspaceState["tabs"][number]["panes"][string], +): string | null { + const terminalId = getTerminalId(pane); + return terminalId ? `${terminalId}\u0000${pane.id}` : null; +} + +function parseTerminalInstanceKey( + key: string, +): { terminalId: string; instanceId: string } | null { + const separatorIndex = key.indexOf("\u0000"); + if (separatorIndex === -1) return null; + return { + terminalId: key.slice(0, separatorIndex), + instanceId: key.slice(separatorIndex + 1), + }; +} + +function extractTerminalLocations( + rows: PaneLifecycleRow[], +): Map { + return extractPaneLocations(rows, getTerminalId); +} + +function extractTerminalInstanceLocations( + rows: PaneLifecycleRow[], +): Map { + return extractPaneLocations(rows, getTerminalInstanceKey); +} + +function cleanupRemovedTerminal({ + terminalId, + workspaceId, + hostUrlByWorkspaceId, +}: { + terminalId: string; + workspaceId: string; + hostUrlByWorkspaceId: Map; +}) { + if (consumeTerminalBackgroundIntent(terminalId)) { + terminalRuntimeRegistry.release(terminalId); + return; + } + + terminalRuntimeRegistry.dispose(terminalId); + const hostUrl = hostUrlByWorkspaceId.get(workspaceId); + if (!hostUrl) { + console.warn( + "[GlobalTerminalLifecycle] Missing host URL while killing removed terminal", + { terminalId, workspaceId }, + ); + return; } - return ids; + + getHostServiceClientByUrl(hostUrl) + .terminal.killSession.mutate({ terminalId, workspaceId }) + .catch((error) => { + console.warn( + "[GlobalTerminalLifecycle] Failed to kill removed terminal", + { terminalId, workspaceId, error }, + ); + }); } export function useGlobalTerminalLifecycle() { const collections = useCollections(); - const prevTerminalIdsRef = useRef>(new Set()); - const pendingDisposals = useRef>>( + const { machineId, activeHostUrl } = useLocalHostService(); + const prevTerminalLocationsRef = useRef>(new Map()); + const prevTerminalInstanceLocationsRef = useRef>( new Map(), ); + const pendingCleanups = useRef>( + new Map(), + ); + const pendingInstanceReleases = useRef< + Map + >(new Map()); const { data: allWorkspaceRows = [] } = useLiveQuery( (query) => @@ -45,47 +128,198 @@ export function useGlobalTerminalLifecycle() { [collections], ); + const { data: workspacesWithHosts = [] } = useLiveQuery( + (query) => + query + .from({ v2Workspaces: collections.v2Workspaces }) + .leftJoin({ hosts: collections.v2Hosts }, ({ v2Workspaces, hosts }) => + eq(v2Workspaces.hostId, hosts.id), + ) + .select(({ v2Workspaces, hosts }) => ({ + workspaceId: v2Workspaces.id, + hostId: v2Workspaces.hostId, + hostMachineId: hosts?.machineId ?? null, + })), + [collections], + ); + + const hostUrlByWorkspaceId = useMemo(() => { + const urls = new Map(); + for (const workspace of workspacesWithHosts) { + if (workspace.hostMachineId === machineId) { + if (activeHostUrl) { + urls.set(workspace.workspaceId, activeHostUrl); + } + continue; + } + + if (workspace.hostId) { + urls.set( + workspace.workspaceId, + `${env.RELAY_URL}/hosts/${workspace.hostId}`, + ); + } + } + return urls; + }, [activeHostUrl, machineId, workspacesWithHosts]); + const hostUrlByWorkspaceIdRef = useRef(hostUrlByWorkspaceId); + hostUrlByWorkspaceIdRef.current = hostUrlByWorkspaceId; + useEffect(() => { - const currentTerminalIds = extractTerminalIds(allWorkspaceRows); - const prevTerminalIds = prevTerminalIdsRef.current; - - for (const terminalId of currentTerminalIds) { - const timer = pendingDisposals.current.get(terminalId); - if (timer) { - clearTimeout(timer); - pendingDisposals.current.delete(terminalId); + const rows = allWorkspaceRows as PaneLifecycleRow[]; + const currentTerminalLocations = extractTerminalLocations(rows); + const currentTerminalInstanceLocations = + extractTerminalInstanceLocations(rows); + const currentWorkspaceIds = extractWorkspaceIds(rows); + const prevTerminalLocations = prevTerminalLocationsRef.current; + const prevTerminalInstanceLocations = + prevTerminalInstanceLocationsRef.current; + + for (const terminalId of currentTerminalLocations.keys()) { + const pending = pendingCleanups.current.get(terminalId); + if (pending?.timer) { + clearTimeout(pending.timer); } + pendingCleanups.current.delete(terminalId); } - for (const terminalId of prevTerminalIds) { - if (currentTerminalIds.has(terminalId)) continue; - if (pendingDisposals.current.has(terminalId)) continue; + for (const instanceKey of currentTerminalInstanceLocations.keys()) { + const pending = pendingInstanceReleases.current.get(instanceKey); + if (pending?.timer) { + clearTimeout(pending.timer); + } + pendingInstanceReleases.current.delete(instanceKey); + } + + // If a pane was authoritatively removed but the owner row disappeared + // before the grace timer fired, keep waiting until that row is present + // again. That avoids releasing active renderer state during sleep/wake + // while still cleaning up when the post-removal layout comes back. + for (const [terminalId, pending] of pendingCleanups.current) { + if (pending.timer) continue; + if (currentWorkspaceIds.has(pending.workspaceId)) { + pendingCleanups.current.delete(terminalId); + cleanupRemovedTerminal({ + terminalId, + workspaceId: pending.workspaceId, + hostUrlByWorkspaceId: hostUrlByWorkspaceIdRef.current, + }); + } + } + + for (const [instanceKey, pending] of pendingInstanceReleases.current) { + if (pending.timer) continue; + if (currentWorkspaceIds.has(pending.workspaceId)) { + pendingInstanceReleases.current.delete(instanceKey); + const parsed = parseTerminalInstanceKey(instanceKey); + if (parsed) { + terminalRuntimeRegistry.release(parsed.terminalId, parsed.instanceId); + } + } + } + + const removedInstanceLocations = getRemovedPaneLocations({ + previousLocations: prevTerminalInstanceLocations, + currentLocations: currentTerminalInstanceLocations, + currentWorkspaceIds, + }); + + for (const { id: instanceKey, workspaceId } of removedInstanceLocations) { + if (pendingInstanceReleases.current.has(instanceKey)) continue; + + const parsed = parseTerminalInstanceKey(instanceKey); + if (!parsed) continue; const timer = setTimeout(() => { - pendingDisposals.current.delete(terminalId); + const freshRows = Array.from( + collections.v2WorkspaceLocalState.state.values(), + ) as PaneLifecycleRow[]; + const freshInstanceLocations = + extractTerminalInstanceLocations(freshRows); + const freshWorkspaceIds = extractWorkspaceIds(freshRows); + + if (freshInstanceLocations.has(instanceKey)) { + pendingInstanceReleases.current.delete(instanceKey); + return; + } + if (freshWorkspaceIds.has(workspaceId)) { + pendingInstanceReleases.current.delete(instanceKey); + terminalRuntimeRegistry.release(parsed.terminalId, parsed.instanceId); + return; + } + + const pending = pendingInstanceReleases.current.get(instanceKey); + if (pending) { + pending.timer = null; + } + }, RELEASE_DELAY_MS); + + pendingInstanceReleases.current.set(instanceKey, { + terminalId: parsed.terminalId, + workspaceId, + timer, + }); + } + + const removedLocations = getRemovedPaneLocations({ + previousLocations: prevTerminalLocations, + currentLocations: currentTerminalLocations, + currentWorkspaceIds, + }); + + for (const { id: terminalId, workspaceId } of removedLocations) { + if (pendingCleanups.current.has(terminalId)) continue; + + const timer = setTimeout(() => { const freshRows = Array.from( collections.v2WorkspaceLocalState.state.values(), - ); - const freshIds = extractTerminalIds(freshRows); + ) as PaneLifecycleRow[]; + const freshLocations = extractTerminalLocations(freshRows); + const freshWorkspaceIds = extractWorkspaceIds(freshRows); + + if (freshLocations.has(terminalId)) { + pendingCleanups.current.delete(terminalId); + return; + } + + if (freshWorkspaceIds.has(workspaceId)) { + pendingCleanups.current.delete(terminalId); + cleanupRemovedTerminal({ + terminalId, + workspaceId, + hostUrlByWorkspaceId: hostUrlByWorkspaceIdRef.current, + }); + return; + } - if (!freshIds.has(terminalId)) { - terminalRuntimeRegistry.dispose(terminalId); + const pending = pendingCleanups.current.get(terminalId); + if (pending) { + pending.timer = null; } - }, DISPOSE_DELAY_MS); + }, RELEASE_DELAY_MS); - pendingDisposals.current.set(terminalId, timer); + pendingCleanups.current.set(terminalId, { workspaceId, timer }); } - prevTerminalIdsRef.current = currentTerminalIds; + prevTerminalLocationsRef.current = currentTerminalLocations; + prevTerminalInstanceLocationsRef.current = currentTerminalInstanceLocations; }, [allWorkspaceRows, collections]); useEffect(() => { return () => { - for (const timer of pendingDisposals.current.values()) { - clearTimeout(timer); + for (const pending of pendingCleanups.current.values()) { + if (pending.timer) { + clearTimeout(pending.timer); + } + } + pendingCleanups.current.clear(); + for (const pending of pendingInstanceReleases.current.values()) { + if (pending.timer) { + clearTimeout(pending.timer); + } } - pendingDisposals.current.clear(); + pendingInstanceReleases.current.clear(); }; }, []); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/index.ts new file mode 100644 index 00000000000..bd0002dcfd7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/index.ts @@ -0,0 +1,8 @@ +export { + extractPaneIds, + extractPaneLocations, + extractWorkspaceIds, + getRemovedPaneLocations, + type PaneLifecycleRow, + type RemovedPaneLocation, +} from "./paneLifecycleRows"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.test.ts b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.test.ts new file mode 100644 index 00000000000..2f87536559a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test"; +import { + extractPaneIds, + extractPaneLocations, + extractWorkspaceIds, + getRemovedPaneLocations, + type PaneLifecycleRow, +} from "./paneLifecycleRows"; + +function row( + workspaceId: string, + panes: Record, +): PaneLifecycleRow { + return { + workspaceId, + paneLayout: { + tabs: [ + { + id: `${workspaceId}-tab`, + title: "Tab", + panes, + layout: null, + activePaneId: null, + }, + ], + activeTabId: `${workspaceId}-tab`, + }, + }; +} + +function terminalPane(id: string) { + return { + id: `pane-${id}`, + kind: "terminal", + data: { terminalId: id }, + }; +} + +function terminalIdForPane(pane: { + kind: string; + data: unknown; +}): string | null { + if (pane.kind !== "terminal") return null; + const data = pane.data as { terminalId?: unknown }; + return typeof data.terminalId === "string" ? data.terminalId : null; +} + +describe("paneLifecycleRows", () => { + test("extracts workspace IDs and tracked pane locations", () => { + const rows = [ + row("workspace-a", { + "pane-term-1": terminalPane("term-1"), + "pane-file-1": { id: "pane-file-1", kind: "file", data: {} }, + }), + row("workspace-b", { + "pane-term-2": terminalPane("term-2"), + }), + ]; + + expect([...extractWorkspaceIds(rows)]).toEqual([ + "workspace-a", + "workspace-b", + ]); + expect([ + ...extractPaneLocations(rows, terminalIdForPane).entries(), + ]).toEqual([ + ["term-1", "workspace-a"], + ["term-2", "workspace-b"], + ]); + expect([...extractPaneIds(rows, terminalIdForPane)]).toEqual([ + "term-1", + "term-2", + ]); + }); + + test("marks a pane removed only when its owner workspace row is present", () => { + const previousLocations = new Map([ + ["term-1", "workspace-a"], + ["term-2", "workspace-b"], + ]); + const currentLocations = new Map([["term-2", "workspace-b"]]); + const currentWorkspaceIds = new Set(["workspace-a", "workspace-b"]); + + expect( + getRemovedPaneLocations({ + previousLocations, + currentLocations, + currentWorkspaceIds, + }), + ).toEqual([{ id: "term-1", workspaceId: "workspace-a" }]); + }); + + test("ignores panes whose owner workspace row disappeared", () => { + const previousLocations = new Map([ + ["term-1", "workspace-a"], + ["term-2", "workspace-b"], + ]); + const currentLocations = new Map([["term-2", "workspace-b"]]); + const currentWorkspaceIds = new Set(["workspace-b"]); + + expect( + getRemovedPaneLocations({ + previousLocations, + currentLocations, + currentWorkspaceIds, + }), + ).toEqual([]); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.ts b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.ts new file mode 100644 index 00000000000..e5873152b24 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.ts @@ -0,0 +1,93 @@ +import type { Pane, WorkspaceState } from "@superset/panes"; + +export interface PaneLifecycleRow { + workspaceId: unknown; + paneLayout: unknown; +} + +export interface RemovedPaneLocation { + id: string; + workspaceId: string; +} + +export function extractWorkspaceIds(rows: PaneLifecycleRow[]): Set { + const workspaceIds = new Set(); + for (const row of rows) { + if (typeof row.workspaceId === "string") { + workspaceIds.add(row.workspaceId); + } + } + return workspaceIds; +} + +export function extractPaneLocations( + rows: PaneLifecycleRow[], + getTrackedPaneId: (pane: Pane) => string | null, +): Map { + const locations = new Map(); + + for (const row of rows) { + if (typeof row.workspaceId !== "string") continue; + + const layout = row.paneLayout as WorkspaceState | undefined; + if (!layout?.tabs) continue; + + for (const tab of layout.tabs) { + for (const pane of Object.values(tab.panes)) { + const trackedPaneId = getTrackedPaneId(pane); + if (trackedPaneId) { + locations.set(trackedPaneId, row.workspaceId); + } + } + } + } + + return locations; +} + +export function extractPaneIds( + rows: PaneLifecycleRow[], + getTrackedPaneId: (pane: Pane) => string | null, +): Set { + const ids = new Set(); + + for (const row of rows) { + const layout = row.paneLayout as WorkspaceState | undefined; + if (!layout?.tabs) continue; + + for (const tab of layout.tabs) { + for (const pane of Object.values(tab.panes)) { + const trackedPaneId = getTrackedPaneId(pane); + if (trackedPaneId) { + ids.add(trackedPaneId); + } + } + } + } + + return ids; +} + +export function getRemovedPaneLocations({ + previousLocations, + currentLocations, + currentWorkspaceIds, +}: { + previousLocations: Map; + currentLocations: Map; + currentWorkspaceIds: Set; +}): RemovedPaneLocation[] { + const removed: RemovedPaneLocation[] = []; + + for (const [id, workspaceId] of previousLocations) { + if (currentLocations.has(id)) continue; + // A missing owner row means the collection snapshot is not authoritative + // for this pane. This happens during org/provider churn and can happen + // briefly after laptop sleep/wake. Intentional sidebar-row removals clean + // up their pane runtimes before deleting the row. + if (!currentWorkspaceIds.has(workspaceId)) continue; + removed.push({ id, workspaceId }); + } + + return removed; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts index 438d1b5be51..2cbb3417b05 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts @@ -1,5 +1,11 @@ -import type { WorkspaceState } from "@superset/panes"; +import type { Pane, WorkspaceState } from "@superset/panes"; import { useCallback } from "react"; +import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; +import { browserRuntimeRegistry } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry"; +import { + extractPaneIds, + type PaneLifecycleRow, +} from "renderer/routes/_authenticated/components/utils/paneLifecycleRows"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { AppCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/collections"; import { PROJECT_CUSTOM_COLORS } from "shared/constants/project-colors"; @@ -80,6 +86,26 @@ function ensureSidebarWorkspaceRecord( }); } +function getTerminalRuntimeId(pane: Pane): string | null { + if (pane.kind !== "terminal") return null; + if (!pane.data || typeof pane.data !== "object") return null; + const data = pane.data as { terminalId?: unknown }; + return typeof data.terminalId === "string" ? data.terminalId : null; +} + +function getBrowserRuntimeId(pane: Pane): string | null { + return pane.kind === "browser" ? pane.id : null; +} + +function cleanupWorkspacePaneRuntimes(rows: PaneLifecycleRow[]): void { + for (const terminalId of extractPaneIds(rows, getTerminalRuntimeId)) { + terminalRuntimeRegistry.release(terminalId); + } + for (const browserId of extractPaneIds(rows, getBrowserRuntimeId)) { + browserRuntimeRegistry.destroy(browserId); + } +} + export function useDashboardSidebarState() { const collections = useCollections(); @@ -403,7 +429,9 @@ export function useDashboardSidebarState() { const removeWorkspaceFromSidebar = useCallback( (workspaceId: string) => { - if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; + const workspace = collections.v2WorkspaceLocalState.get(workspaceId); + if (!workspace) return; + cleanupWorkspacePaneRuntimes([workspace]); collections.v2WorkspaceLocalState.delete(workspaceId); }, [collections], @@ -411,11 +439,10 @@ export function useDashboardSidebarState() { const removeProjectFromSidebar = useCallback( (projectId: string) => { - const workspaceIds = Array.from( + const workspaceRows = Array.from( collections.v2WorkspaceLocalState.state.values(), - ) - .filter((item) => item.sidebarState.projectId === projectId) - .map((item) => item.workspaceId); + ).filter((item) => item.sidebarState.projectId === projectId); + const workspaceIds = workspaceRows.map((item) => item.workspaceId); const sectionIds = Array.from( collections.v2SidebarSections.state.values(), ) @@ -423,6 +450,7 @@ export function useDashboardSidebarState() { .map((item) => item.sectionId); if (workspaceIds.length > 0) { + cleanupWorkspacePaneRuntimes(workspaceRows); collections.v2WorkspaceLocalState.delete(workspaceIds); } if (sectionIds.length > 0) { diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index cb100039fc1..96e54851be1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -183,9 +183,9 @@ function AuthenticatedLayout() { return ( - + void; + close: (code?: number, reason?: string) => void; + readyState: number; +}; // --------------------------------------------------------------------------- // OSC 133 shell readiness detection (FinalTerm semantic prompt standard). @@ -66,17 +75,16 @@ type ShellReadyState = "pending" | "ready" | "timed_out" | "unsupported"; interface TerminalSession { terminalId: string; + workspaceId: string; pty: IPty; - socket: { - send: (data: string) => void; - close: (code?: number, reason?: string) => void; - readyState: number; - } | null; + sockets: Set; buffer: string[]; bufferBytes: number; + createdAt: number; exited: boolean; exitCode: number; exitSignal: number; + listed: boolean; // Shell readiness (OSC 133) shellReadyState: ShellReadyState; @@ -89,14 +97,82 @@ interface TerminalSession { /** PTY lifetime is independent of socket lifetime — sockets detach/reattach freely. */ const sessions = new Map(); +function pruneAndCountOpenSockets(session: TerminalSession): number { + let openSockets = 0; + for (const socket of session.sockets) { + if (socket.readyState === SOCKET_OPEN) { + openSockets += 1; + } else if ( + socket.readyState === SOCKET_CLOSING || + socket.readyState === SOCKET_CLOSED + ) { + session.sockets.delete(socket); + } + } + return openSockets; +} + +export interface TerminalSessionSummary { + terminalId: string; + workspaceId: string; + createdAt: number; + exited: boolean; + exitCode: number; + attached: boolean; +} + +export function listTerminalSessions( + options: { workspaceId?: string; includeExited?: boolean } = {}, +): TerminalSessionSummary[] { + const includeExited = options.includeExited ?? true; + + return Array.from(sessions.values()) + .filter((session) => session.listed) + .filter( + (session) => + options.workspaceId === undefined || + session.workspaceId === options.workspaceId, + ) + .filter((session) => includeExited || !session.exited) + .map((session) => ({ + terminalId: session.terminalId, + workspaceId: session.workspaceId, + createdAt: session.createdAt, + exited: session.exited, + exitCode: session.exitCode, + attached: pruneAndCountOpenSockets(session) > 0, + })); +} + function sendMessage( socket: { send: (data: string) => void; readyState: number }, message: TerminalServerMessage, ) { - if (socket.readyState !== 1) return; + if (socket.readyState !== SOCKET_OPEN) return; socket.send(JSON.stringify(message)); } +function broadcastMessage( + session: TerminalSession, + message: TerminalServerMessage, +): number { + let sent = 0; + for (const socket of session.sockets) { + if (socket.readyState !== SOCKET_OPEN) { + if ( + socket.readyState === SOCKET_CLOSING || + socket.readyState === SOCKET_CLOSED + ) { + session.sockets.delete(socket); + } + continue; + } + sendMessage(socket, message); + sent += 1; + } + return sent; +} + function bufferOutput(session: TerminalSession, data: string) { session.buffer.push(data); session.bufferBytes += data.length; @@ -158,6 +234,10 @@ export function disposeSession(terminalId: string, db: HostDb) { clearTimeout(session.shellReadyTimeoutId); session.shellReadyTimeoutId = null; } + for (const socket of session.sockets) { + socket.close(1000, "Session disposed"); + } + session.sockets.clear(); if (!session.exited) { try { session.pty.kill(); @@ -213,6 +293,8 @@ interface CreateTerminalSessionOptions { db: HostDb; /** Command to run after the shell is ready. Queued behind shellReadyPromise. */ initialCommand?: string; + /** Hidden sessions are process-internal and should not appear in user pickers. */ + listed?: boolean; } export function createTerminalSessionInternal({ @@ -221,9 +303,11 @@ export function createTerminalSessionInternal({ themeType, db, initialCommand, + listed = true, }: CreateTerminalSessionOptions): TerminalSession | { error: string } { const existing = sessions.get(terminalId); if (existing) { + if (listed) existing.listed = true; return existing; } @@ -284,15 +368,18 @@ export function createTerminalSessionInternal({ }; } + const createdAt = Date.now(); + db.insert(terminalSessions) .values({ id: terminalId, originWorkspaceId: workspaceId, status: "active", + createdAt, }) .onConflictDoUpdate({ target: terminalSessions.id, - set: { status: "active", endedAt: null }, + set: { status: "active", createdAt, endedAt: null }, }) .run(); @@ -309,13 +396,16 @@ export function createTerminalSessionInternal({ const session: TerminalSession = { terminalId, + workspaceId, pty, - socket: null, + sockets: new Set(), buffer: [], bufferBytes: 0, + createdAt, exited: false, exitCode: 0, exitSignal: 0, + listed, shellReadyState: shellSupportsReady ? "pending" : "unsupported", shellReadyResolve, shellReadyPromise, @@ -344,9 +434,7 @@ export function createTerminalSessionInternal({ } if (data.length === 0) return; - if (session.socket?.readyState === 1) { - sendMessage(session.socket, { type: "data", data }); - } else { + if (broadcastMessage(session, { type: "data", data }) === 0) { bufferOutput(session, data); } }); @@ -361,13 +449,11 @@ export function createTerminalSessionInternal({ .where(eq(terminalSessions.id, terminalId)) .run(); - if (session.socket?.readyState === 1) { - sendMessage(session.socket, { - type: "exit", - exitCode: session.exitCode, - signal: session.exitSignal, - }); - } + broadcastMessage(session, { + type: "exit", + exitCode: session.exitCode, + signal: session.exitSignal, + }); }); if (initialCommand) { @@ -432,13 +518,10 @@ export function registerWorkspaceTerminalRoute({ // REST list — enumerate live terminal sessions app.get("/terminal/sessions", (c) => { - const result = Array.from(sessions.values()).map((s) => ({ - terminalId: s.terminalId, - exited: s.exited, - exitCode: s.exitCode, - attached: s.socket !== null, - })); - return c.json({ sessions: result }); + const workspaceId = c.req.query("workspaceId") || undefined; + return c.json({ + sessions: listTerminalSessions({ workspaceId, includeExited: true }), + }); }); app.get( @@ -482,7 +565,7 @@ export function registerWorkspaceTerminalRoute({ return; } - result.socket = ws; + result.sockets.add(ws); db.update(terminalSessions) .set({ lastAttachedAt: Date.now() }) @@ -491,10 +574,7 @@ export function registerWorkspaceTerminalRoute({ return; } - if (existing.socket && existing.socket !== ws) { - existing.socket.close(4000, "Displaced by new connection"); - } - existing.socket = ws; + existing.sockets.add(ws); db.update(terminalSessions) .set({ lastAttachedAt: Date.now() }) @@ -513,18 +593,16 @@ export function registerWorkspaceTerminalRoute({ onMessage: (event, ws) => { const session = sessions.get(terminalId ?? ""); - if (!session || session.socket !== ws) return; + if (!session || !session.sockets.has(ws)) return; let message: TerminalClientMessage; try { message = JSON.parse(String(event.data)) as TerminalClientMessage; } catch { - if (session.socket) { - sendMessage(session.socket, { - type: "error", - message: "Invalid terminal message payload", - }); - } + sendMessage(ws, { + type: "error", + message: "Invalid terminal message payload", + }); return; } @@ -549,16 +627,12 @@ export function registerWorkspaceTerminalRoute({ onClose: (_event, ws) => { const session = sessions.get(terminalId ?? ""); - if (session?.socket === ws) { - session.socket = null; - } + session?.sockets.delete(ws); }, onError: (_event, ws) => { const session = sessions.get(terminalId ?? ""); - if (session?.socket === ws) { - session.socket = null; - } + session?.sockets.delete(ws); }, }; }), diff --git a/packages/host-service/src/trpc/router/terminal/terminal.ts b/packages/host-service/src/trpc/router/terminal/terminal.ts index c90dfd7253b..823ceda946e 100644 --- a/packages/host-service/src/trpc/router/terminal/terminal.ts +++ b/packages/host-service/src/trpc/router/terminal/terminal.ts @@ -1,6 +1,11 @@ +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; import { z } from "zod"; +import { terminalSessions, workspaces } from "../../../db/schema"; import { createTerminalSessionInternal, + disposeSession, + listTerminalSessions, parseThemeType, } from "../../../terminal/terminal"; import { protectedProcedure, router } from "../../index"; @@ -34,4 +39,58 @@ export const terminalRouter = router({ return { terminalId: result.terminalId, status: "active" as const }; }), + + listSessions: protectedProcedure + .input( + z.object({ + workspaceId: z.string(), + }), + ) + .query(({ input }) => ({ + sessions: listTerminalSessions({ + workspaceId: input.workspaceId, + includeExited: false, + }), + })), + + killSession: protectedProcedure + .input( + z.object({ + terminalId: z.string(), + workspaceId: z.string(), + }), + ) + .mutation(({ ctx, input }) => { + const workspace = ctx.db.query.workspaces + .findFirst({ where: eq(workspaces.id, input.workspaceId) }) + .sync(); + + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Workspace not found", + }); + } + + const session = ctx.db.query.terminalSessions + .findFirst({ where: eq(terminalSessions.id, input.terminalId) }) + .sync(); + + if (!session) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Terminal session not found", + }); + } + + if (session.originWorkspaceId !== input.workspaceId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Terminal session does not belong to this workspace", + }); + } + + disposeSession(input.terminalId, ctx.db); + return { terminalId: input.terminalId, status: "disposed" as const }; + }), });