-
Notifications
You must be signed in to change notification settings - Fork 898
feat(desktop): terminal in v2 pane #3108
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
96f19b6
height
Kitenite 9830d15
lifecycle
Kitenite cffda4c
Merge conflict
Kitenite 99a7061
Deslop
Kitenite 5de8541
Refactor
Kitenite 5712647
Merge origin/main, resolve TerminalPane conflict (keep attach/detach …
Kitenite d7d983f
Separate terminal runtime from WebSocket transport in renderer
Kitenite 5a2c4dc
Fix stale WebSocket handlers and eager runtime creation
Kitenite be505e4
Co-locate useGlobalTerminalLifecycle under its component
Kitenite 4acf790
Keep WebSocket alive across terminal attach/detach cycles
Kitenite 7836a30
Lint
Kitenite 3259e18
Move connect idempotency into transport layer
Kitenite File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
101 changes: 101 additions & 0 deletions
101
apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| import { | ||
| attachToContainer, | ||
| createRuntime, | ||
| detachFromContainer, | ||
| disposeRuntime, | ||
| type TerminalRuntime, | ||
| } from "./terminal-runtime"; | ||
| import { | ||
| type ConnectionState, | ||
| connect, | ||
| createTransport, | ||
| disposeTransport, | ||
| sendDispose, | ||
| sendResize, | ||
| type TerminalTransport, | ||
| } from "./terminal-ws-transport"; | ||
|
|
||
| interface RegistryEntry { | ||
| runtime: TerminalRuntime; | ||
| transport: TerminalTransport; | ||
| } | ||
|
|
||
| class TerminalRuntimeRegistryImpl { | ||
| private entries = new Map<string, RegistryEntry>(); | ||
|
|
||
| private getOrCreate(paneId: string): RegistryEntry { | ||
| let entry = this.entries.get(paneId); | ||
| if (entry) return entry; | ||
|
|
||
| entry = { | ||
| runtime: createRuntime(paneId), | ||
| transport: createTransport(), | ||
| }; | ||
|
|
||
| this.entries.set(paneId, entry); | ||
| return entry; | ||
| } | ||
|
|
||
| attach(paneId: string, container: HTMLDivElement, wsUrl: string) { | ||
| const { runtime, transport } = this.getOrCreate(paneId); | ||
|
|
||
| attachToContainer(runtime, container, () => { | ||
| sendResize(transport, runtime.terminal.cols, runtime.terminal.rows); | ||
| }); | ||
|
|
||
| connect(transport, runtime.terminal, wsUrl); | ||
| } | ||
|
|
||
| /** | ||
| * Detach the terminal from its DOM container. | ||
| * | ||
| * This only removes the DOM attachment (wrapper, resize observer, focus). | ||
| * The WebSocket and xterm data flow are intentionally kept alive so output | ||
| * written while the pane is hidden is not lost. Disposal of the transport | ||
| * happens exclusively through {@link dispose} when the paneId is removed | ||
| * from persisted pane state. | ||
| */ | ||
| detach(paneId: string) { | ||
| const entry = this.entries.get(paneId); | ||
| if (!entry) return; | ||
|
|
||
| detachFromContainer(entry.runtime); | ||
| } | ||
|
|
||
| dispose(paneId: string) { | ||
| const entry = this.entries.get(paneId); | ||
| if (!entry) return; | ||
|
|
||
| sendDispose(entry.transport); | ||
| disposeTransport(entry.transport); | ||
| disposeRuntime(entry.runtime); | ||
|
|
||
| this.entries.delete(paneId); | ||
| } | ||
|
|
||
| getAllPaneIds(): Set<string> { | ||
| return new Set(this.entries.keys()); | ||
| } | ||
|
|
||
| has(paneId: string): boolean { | ||
| return this.entries.has(paneId); | ||
| } | ||
|
|
||
| getConnectionState(paneId: string): ConnectionState { | ||
| return ( | ||
| this.entries.get(paneId)?.transport.connectionState ?? "disconnected" | ||
| ); | ||
| } | ||
|
|
||
| onStateChange(paneId: string, listener: () => void): () => void { | ||
| const { transport } = this.getOrCreate(paneId); | ||
| transport.stateListeners.add(listener); | ||
| return () => { | ||
| transport.stateListeners.delete(listener); | ||
| }; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| export const terminalRuntimeRegistry = new TerminalRuntimeRegistryImpl(); | ||
|
|
||
| export type { ConnectionState }; | ||
181 changes: 181 additions & 0 deletions
181
apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| import { FitAddon } from "@xterm/addon-fit"; | ||
| import { SerializeAddon } from "@xterm/addon-serialize"; | ||
| import { Terminal as XTerm } from "@xterm/xterm"; | ||
|
|
||
| const SERIALIZE_SCROLLBACK = 1000; | ||
| const STORAGE_KEY_PREFIX = "terminal-buffer:"; | ||
| const DIMS_KEY_PREFIX = "terminal-dims:"; | ||
| const DEFAULT_COLS = 120; | ||
| const DEFAULT_ROWS = 32; | ||
|
|
||
| export interface TerminalRuntime { | ||
| paneId: string; | ||
| terminal: XTerm; | ||
| fitAddon: FitAddon; | ||
| serializeAddon: SerializeAddon; | ||
| /** Reparented between containers across attach/detach cycles — not recreated. */ | ||
| wrapper: HTMLDivElement; | ||
| container: HTMLDivElement | null; | ||
| resizeObserver: ResizeObserver | null; | ||
| /** Fallback grid size used when the host is not visible. */ | ||
| lastCols: number; | ||
| lastRows: number; | ||
| } | ||
|
|
||
| function createTerminal( | ||
| cols: number, | ||
| rows: number, | ||
| ): { | ||
| terminal: XTerm; | ||
| fitAddon: FitAddon; | ||
| serializeAddon: SerializeAddon; | ||
| } { | ||
| const fitAddon = new FitAddon(); | ||
| const serializeAddon = new SerializeAddon(); | ||
| const terminal = new XTerm({ | ||
| cols, | ||
| rows, | ||
| cursorBlink: true, | ||
| fontFamily: | ||
| 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', | ||
| fontSize: 12, | ||
| theme: { | ||
| background: "#14100f", | ||
| foreground: "#f5efe9", | ||
| }, | ||
| }); | ||
| terminal.loadAddon(fitAddon); | ||
| terminal.loadAddon(serializeAddon); | ||
| return { terminal, fitAddon, serializeAddon }; | ||
| } | ||
|
|
||
| function persistBuffer(paneId: string, serializeAddon: SerializeAddon) { | ||
| try { | ||
| const data = serializeAddon.serialize({ scrollback: SERIALIZE_SCROLLBACK }); | ||
| localStorage.setItem(`${STORAGE_KEY_PREFIX}${paneId}`, data); | ||
| } catch {} | ||
|
Kitenite marked this conversation as resolved.
|
||
| } | ||
|
|
||
| function restoreBuffer(paneId: string, terminal: XTerm) { | ||
| try { | ||
| const data = localStorage.getItem(`${STORAGE_KEY_PREFIX}${paneId}`); | ||
| if (data) terminal.write(data); | ||
| } catch {} | ||
| } | ||
|
|
||
| function clearPersistedBuffer(paneId: string) { | ||
| try { | ||
| localStorage.removeItem(`${STORAGE_KEY_PREFIX}${paneId}`); | ||
| } catch {} | ||
| } | ||
|
|
||
| function persistDimensions(paneId: string, cols: number, rows: number) { | ||
| try { | ||
| localStorage.setItem( | ||
| `${DIMS_KEY_PREFIX}${paneId}`, | ||
| JSON.stringify({ cols, rows }), | ||
| ); | ||
| } catch {} | ||
| } | ||
|
|
||
| function loadSavedDimensions( | ||
| paneId: string, | ||
| ): { cols: number; rows: number } | null { | ||
| try { | ||
| const raw = localStorage.getItem(`${DIMS_KEY_PREFIX}${paneId}`); | ||
| if (!raw) return null; | ||
| const parsed = JSON.parse(raw); | ||
| if (typeof parsed.cols === "number" && typeof parsed.rows === "number") { | ||
| return parsed; | ||
| } | ||
| return null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| function clearPersistedDimensions(paneId: string) { | ||
| try { | ||
| localStorage.removeItem(`${DIMS_KEY_PREFIX}${paneId}`); | ||
| } catch {} | ||
| } | ||
|
|
||
| function hostIsVisible(container: HTMLDivElement | null): boolean { | ||
| if (!container) return false; | ||
| return container.clientWidth > 0 && container.clientHeight > 0; | ||
| } | ||
|
|
||
| function measureAndResize(runtime: TerminalRuntime) { | ||
| if (!hostIsVisible(runtime.container)) return; | ||
| runtime.fitAddon.fit(); | ||
| runtime.lastCols = runtime.terminal.cols; | ||
| runtime.lastRows = runtime.terminal.rows; | ||
| } | ||
|
|
||
| export function createRuntime(paneId: string): TerminalRuntime { | ||
| const savedDims = loadSavedDimensions(paneId); | ||
| const cols = savedDims?.cols ?? DEFAULT_COLS; | ||
| const rows = savedDims?.rows ?? DEFAULT_ROWS; | ||
|
|
||
| const { terminal, fitAddon, serializeAddon } = createTerminal(cols, rows); | ||
|
|
||
| const wrapper = document.createElement("div"); | ||
| wrapper.style.width = "100%"; | ||
| wrapper.style.height = "100%"; | ||
| terminal.open(wrapper); | ||
| restoreBuffer(paneId, terminal); | ||
|
|
||
| return { | ||
| paneId, | ||
| terminal, | ||
| fitAddon, | ||
| serializeAddon, | ||
| wrapper, | ||
| container: null, | ||
| resizeObserver: null, | ||
| lastCols: cols, | ||
| lastRows: rows, | ||
| }; | ||
| } | ||
|
|
||
| export function attachToContainer( | ||
| runtime: TerminalRuntime, | ||
| container: HTMLDivElement, | ||
| onResize?: () => void, | ||
| ) { | ||
| runtime.container = container; | ||
| container.appendChild(runtime.wrapper); | ||
| measureAndResize(runtime); | ||
|
|
||
| // Force a full repaint — the renderer may have skipped paint frames while | ||
| // the wrapper was detached from the DOM and receiving background data. | ||
| runtime.terminal.refresh(0, runtime.terminal.rows - 1); | ||
|
|
||
| runtime.resizeObserver?.disconnect(); | ||
| const observer = new ResizeObserver(() => { | ||
| measureAndResize(runtime); | ||
| onResize?.(); | ||
| }); | ||
| observer.observe(container); | ||
| runtime.resizeObserver = observer; | ||
|
|
||
| runtime.terminal.focus(); | ||
| } | ||
|
|
||
| export function detachFromContainer(runtime: TerminalRuntime) { | ||
| persistBuffer(runtime.paneId, runtime.serializeAddon); | ||
| persistDimensions(runtime.paneId, runtime.lastCols, runtime.lastRows); | ||
|
Kitenite marked this conversation as resolved.
|
||
| runtime.resizeObserver?.disconnect(); | ||
| runtime.resizeObserver = null; | ||
| runtime.wrapper.remove(); | ||
| runtime.container = null; | ||
| } | ||
|
|
||
| export function disposeRuntime(runtime: TerminalRuntime) { | ||
| runtime.resizeObserver?.disconnect(); | ||
| runtime.resizeObserver = null; | ||
| runtime.wrapper.remove(); | ||
| runtime.terminal.dispose(); | ||
| clearPersistedBuffer(runtime.paneId); | ||
| clearPersistedDimensions(runtime.paneId); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.