Replace the legacy terminal flow with a shared authenticated WebSocket runtime used across web, desktop, relay, and mobile surfaces. - introduce the v3 terminal protocol with scoped attachments, snapshots, ordered output, bounded replay history, reconnects, and explicit lifecycle - harden PTY creation, restart, resize, close, force-kill, idle cleanup, shell selection, login mode, environment sanitization, and appearance sync - add runtime-aware terminal APIs with relay authentication and Electron parity - add a fullscreen mobile terminal workspace with touch scrolling, long-press selection, safe-area controls, quick keys, and Ctrl/Alt input - add terminal selection attachments, preview detection, project actions, shell settings, and localized UI - harden Ghostty rendering, resize recovery, Unicode handling, block characters, line height, and stale-row behavior - remove the obsolete terminal SSE path and update reverse-proxy guidance - expand terminal runtime, transport, input, selection, and store coverage - avoid duplicate web builds when preparing mobile assets in root CI builds
382 lines
14 KiB
TypeScript
382 lines
14 KiB
TypeScript
import React from 'react';
|
|
import { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web';
|
|
|
|
import { cn } from '@/lib/utils';
|
|
import type { TerminalTheme } from '@/lib/terminalTheme';
|
|
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
|
|
import {
|
|
getGhosttySafeResetSequence,
|
|
rewriteGhosttyDefaultBackgroundResets,
|
|
} from '@/lib/terminalOutput';
|
|
import {
|
|
getTerminalCellFromPoint,
|
|
getTerminalWordRange,
|
|
type TerminalCellPosition,
|
|
} from '@/lib/terminalTouchSelection';
|
|
import type { TerminalChunk } from '@/stores/useTerminalStore';
|
|
|
|
let ghosttyPromise: Promise<Ghostty> | null = null;
|
|
const loadGhostty = (): Promise<Ghostty> => ghosttyPromise ??= Ghostty.load();
|
|
|
|
export type TerminalController = {
|
|
focus: () => void;
|
|
fit: () => void;
|
|
getSelection: () => { text: string; startLine: number; endLine: number } | null;
|
|
};
|
|
|
|
type Props = {
|
|
sessionKey: string;
|
|
chunks: TerminalChunk[];
|
|
onInput: (data: string) => void;
|
|
onResize: (cols: number, rows: number) => void;
|
|
theme: TerminalTheme;
|
|
fontFamily: string;
|
|
fontSize: number;
|
|
className?: string;
|
|
enableTouchScroll?: boolean;
|
|
autoFocus?: boolean;
|
|
isVisible?: boolean;
|
|
};
|
|
|
|
const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
|
sessionKey, chunks, onInput, onResize, theme, fontFamily, fontSize, className,
|
|
enableTouchScroll = false, autoFocus = true, isVisible = true,
|
|
}, ref) => {
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
const terminalRef = React.useRef<GhosttyTerminal | null>(null);
|
|
const fitRef = React.useRef<FitAddon | null>(null);
|
|
const inputRef = React.useRef(onInput);
|
|
const resizeRef = React.useRef(onResize);
|
|
const lastSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
|
|
const lastChunkRef = React.useRef<number | null>(null);
|
|
const writeQueueRef = React.useRef('');
|
|
const outputRewriteCarryRef = React.useRef('');
|
|
const safeResetRef = React.useRef(getGhosttySafeResetSequence(theme.background));
|
|
const writingRef = React.useRef(false);
|
|
const visibleRef = React.useRef(isVisible);
|
|
const rendererReadyRef = React.useRef(false);
|
|
const [ready, setReady] = React.useState(0);
|
|
const [rendererGeneration, setRendererGeneration] = React.useState(0);
|
|
inputRef.current = onInput;
|
|
resizeRef.current = onResize;
|
|
visibleRef.current = isVisible;
|
|
safeResetRef.current = getGhosttySafeResetSequence(theme.background);
|
|
|
|
const fit = React.useCallback(() => {
|
|
const container = containerRef.current;
|
|
const terminal = terminalRef.current;
|
|
if (!container || !terminal || !fitRef.current || !visibleRef.current) return;
|
|
const bounds = container.getBoundingClientRect();
|
|
if (bounds.width < 24 || bounds.height < 24) return;
|
|
try {
|
|
fitRef.current.fit();
|
|
const next = { cols: terminal.cols, rows: terminal.rows };
|
|
if (!lastSizeRef.current || lastSizeRef.current.cols !== next.cols || lastSizeRef.current.rows !== next.rows) {
|
|
lastSizeRef.current = next;
|
|
resizeRef.current(next.cols, next.rows);
|
|
}
|
|
if (!rendererReadyRef.current) {
|
|
rendererReadyRef.current = true;
|
|
setReady((value) => value + 1);
|
|
}
|
|
} catch { /* hidden or detached */ }
|
|
}, []);
|
|
|
|
const flush = React.useCallback(() => {
|
|
if (writingRef.current || !writeQueueRef.current || !terminalRef.current) return;
|
|
const terminal = terminalRef.current;
|
|
const pending = writeQueueRef.current;
|
|
writeQueueRef.current = '';
|
|
const rewritten = rewriteGhosttyDefaultBackgroundResets(
|
|
pending,
|
|
outputRewriteCarryRef.current,
|
|
safeResetRef.current,
|
|
);
|
|
outputRewriteCarryRef.current = rewritten.carry;
|
|
if (!rewritten.data) {
|
|
if (writeQueueRef.current) flush();
|
|
return;
|
|
}
|
|
writingRef.current = true;
|
|
terminal.write(rewritten.data, () => {
|
|
if (terminalRef.current !== terminal) return;
|
|
writingRef.current = false;
|
|
if (writeQueueRef.current) flush();
|
|
});
|
|
}, []);
|
|
|
|
const recreateRenderer = React.useCallback(() => {
|
|
lastChunkRef.current = null;
|
|
writeQueueRef.current = '';
|
|
outputRewriteCarryRef.current = '';
|
|
writingRef.current = false;
|
|
setRendererGeneration((value) => value + 1);
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
let disposed = false;
|
|
let terminal: GhosttyTerminal | null = null;
|
|
let observer: ResizeObserver | null = null;
|
|
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let fitFrame: number | null = null;
|
|
let subscriptions: Array<{ dispose: () => void }> = [];
|
|
const handleFocusIn = () => {
|
|
if (terminal && visibleRef.current) terminal.options.cursorBlink = true;
|
|
};
|
|
const handleFocusOut = (event: FocusEvent) => {
|
|
if (event.relatedTarget instanceof Node && container.contains(event.relatedTarget)) return;
|
|
if (terminal) terminal.options.cursorBlink = false;
|
|
};
|
|
const handleWindowFocus = () => {
|
|
if (terminal && visibleRef.current && container.contains(document.activeElement)) {
|
|
terminal.options.cursorBlink = true;
|
|
}
|
|
};
|
|
const handleWindowBlur = () => {
|
|
if (terminal) terminal.options.cursorBlink = false;
|
|
};
|
|
|
|
container.addEventListener('focusin', handleFocusIn);
|
|
container.addEventListener('focusout', handleFocusOut);
|
|
window.addEventListener('focus', handleWindowFocus);
|
|
window.addEventListener('blur', handleWindowBlur);
|
|
|
|
loadGhostty().then((ghostty) => {
|
|
if (disposed) return;
|
|
terminal = new GhosttyTerminal(getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false));
|
|
const fitAddon = new FitAddon();
|
|
terminal.loadAddon(fitAddon);
|
|
terminal.open(container);
|
|
terminalRef.current = terminal;
|
|
fitRef.current = fitAddon;
|
|
subscriptions = [terminal.onData((data) => inputRef.current(data))];
|
|
observer = new ResizeObserver(() => {
|
|
if (resizeTimeout) clearTimeout(resizeTimeout);
|
|
resizeTimeout = setTimeout(fit, 80);
|
|
});
|
|
observer.observe(container);
|
|
fit();
|
|
const safeReset = safeResetRef.current;
|
|
if (safeReset) terminal.write(`${safeReset}\u001b[2J\u001b[H`);
|
|
fitFrame = requestAnimationFrame(fit);
|
|
});
|
|
|
|
return () => {
|
|
disposed = true;
|
|
observer?.disconnect();
|
|
if (resizeTimeout) clearTimeout(resizeTimeout);
|
|
if (fitFrame !== null) cancelAnimationFrame(fitFrame);
|
|
container.removeEventListener('focusin', handleFocusIn);
|
|
container.removeEventListener('focusout', handleFocusOut);
|
|
window.removeEventListener('focus', handleWindowFocus);
|
|
window.removeEventListener('blur', handleWindowBlur);
|
|
subscriptions.forEach((subscription) => subscription.dispose());
|
|
terminal?.dispose();
|
|
terminalRef.current = null;
|
|
fitRef.current = null;
|
|
lastSizeRef.current = null;
|
|
lastChunkRef.current = null;
|
|
writeQueueRef.current = '';
|
|
outputRewriteCarryRef.current = '';
|
|
writingRef.current = false;
|
|
rendererReadyRef.current = false;
|
|
};
|
|
}, [fit, fontFamily, fontSize, rendererGeneration, theme]);
|
|
|
|
React.useEffect(() => {
|
|
const terminal = terminalRef.current;
|
|
const container = containerRef.current;
|
|
if (!terminal || !container) return;
|
|
terminal.options.cursorBlink = isVisible && document.hasFocus() && container.contains(document.activeElement);
|
|
}, [isVisible, ready]);
|
|
|
|
React.useEffect(() => {
|
|
const terminal = terminalRef.current;
|
|
if (!terminal) return;
|
|
if (chunks.length === 0) {
|
|
if (lastChunkRef.current !== null) recreateRenderer();
|
|
return;
|
|
}
|
|
const previous = lastChunkRef.current;
|
|
const previousIndex = previous === null ? -1 : chunks.findIndex((chunk) => chunk.id === previous);
|
|
if (previous !== null && previousIndex < 0) {
|
|
recreateRenderer();
|
|
return;
|
|
}
|
|
const isReplay = previousIndex < 0;
|
|
const pending = previousIndex >= 0 ? chunks.slice(previousIndex + 1) : chunks;
|
|
writeQueueRef.current += pending.map((chunk) => isReplay ? (chunk.replayData ?? chunk.data) : chunk.data).join('');
|
|
lastChunkRef.current = chunks.at(-1)?.id ?? null;
|
|
flush();
|
|
}, [chunks, flush, ready, recreateRenderer]);
|
|
|
|
React.useEffect(() => {
|
|
if (!autoFocus || !isVisible) return;
|
|
const frame = requestAnimationFrame(() => terminalRef.current?.focus());
|
|
return () => cancelAnimationFrame(frame);
|
|
}, [autoFocus, isVisible, ready, sessionKey]);
|
|
|
|
React.useEffect(() => {
|
|
const container = containerRef.current;
|
|
const terminal = terminalRef.current;
|
|
if (!enableTouchScroll || !container || !terminal) return;
|
|
let pointerId: number | null = null;
|
|
let longPressTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let gesture: 'idle' | 'pending' | 'scrolling' | 'selecting' = 'idle';
|
|
let startX = 0;
|
|
let startY = 0;
|
|
let lastY = 0;
|
|
let remainder = 0;
|
|
let selectionFocus: TerminalCellPosition | null = null;
|
|
const lineHeight = Math.max(12, fontSize + 2);
|
|
const clearLongPress = () => {
|
|
if (!longPressTimeout) return;
|
|
clearTimeout(longPressTimeout);
|
|
longPressTimeout = null;
|
|
};
|
|
const cellFromPoint = (clientX: number, clientY: number) => {
|
|
const canvas = container.querySelector('canvas');
|
|
if (!canvas) return null;
|
|
return getTerminalCellFromPoint(clientX, clientY, canvas.getBoundingClientRect(), terminal.cols, terminal.rows);
|
|
};
|
|
const dispatchSelectionMouseEvent = (
|
|
type: 'mousedown' | 'mousemove',
|
|
cell: TerminalCellPosition,
|
|
) => {
|
|
const canvas = container.querySelector('canvas');
|
|
if (!canvas) return;
|
|
const bounds = canvas.getBoundingClientRect();
|
|
const clientX = bounds.left + ((cell.column + 0.5) / terminal.cols) * bounds.width;
|
|
const clientY = bounds.top + ((cell.row + 0.5) / terminal.rows) * bounds.height;
|
|
canvas.dispatchEvent(new MouseEvent(type, {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
button: 0,
|
|
buttons: 1,
|
|
clientX,
|
|
clientY,
|
|
}));
|
|
};
|
|
const finishSelection = () => {
|
|
document.dispatchEvent(new MouseEvent('mouseup', {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
button: 0,
|
|
buttons: 0,
|
|
}));
|
|
};
|
|
const down = (event: PointerEvent) => {
|
|
if (event.pointerType !== 'touch' || pointerId !== null) return;
|
|
pointerId = event.pointerId;
|
|
gesture = 'pending';
|
|
startX = event.clientX;
|
|
startY = event.clientY;
|
|
lastY = event.clientY;
|
|
remainder = 0;
|
|
selectionFocus = null;
|
|
container.setPointerCapture(event.pointerId);
|
|
longPressTimeout = setTimeout(() => {
|
|
longPressTimeout = null;
|
|
if (pointerId !== event.pointerId || gesture !== 'pending') return;
|
|
const cell = cellFromPoint(startX, startY);
|
|
if (!cell) return;
|
|
|
|
const buffer = terminal.buffer.active;
|
|
const lineIndex = Math.max(0, buffer.length - terminal.rows - buffer.viewportY + cell.row);
|
|
const line = buffer.getLine(lineIndex);
|
|
const cells = Array.from({ length: terminal.cols }, (_, column) => line?.getCell(column)?.getChars() ?? '');
|
|
const word = getTerminalWordRange(cells, cell.column);
|
|
const selectionAnchor = { column: word.startColumn, row: cell.row };
|
|
selectionFocus = { column: word.endColumn, row: cell.row };
|
|
gesture = 'selecting';
|
|
dispatchSelectionMouseEvent('mousedown', selectionAnchor);
|
|
dispatchSelectionMouseEvent('mousemove', selectionFocus);
|
|
}, 350);
|
|
};
|
|
const move = (event: PointerEvent) => {
|
|
if (pointerId !== event.pointerId) return;
|
|
|
|
if (gesture === 'selecting') {
|
|
const focus = cellFromPoint(event.clientX, event.clientY);
|
|
if (focus && (!selectionFocus || focus.column !== selectionFocus.column || focus.row !== selectionFocus.row)) {
|
|
selectionFocus = focus;
|
|
dispatchSelectionMouseEvent('mousemove', focus);
|
|
}
|
|
if (event.cancelable) event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
if (gesture === 'pending') {
|
|
const distance = Math.hypot(event.clientX - startX, event.clientY - startY);
|
|
if (distance < 8) return;
|
|
clearLongPress();
|
|
gesture = 'scrolling';
|
|
}
|
|
|
|
if (gesture !== 'scrolling') return;
|
|
const delta = lastY - event.clientY;
|
|
lastY = event.clientY;
|
|
remainder += delta;
|
|
const lines = Math.trunc(remainder / lineHeight);
|
|
if (lines) { terminal.scrollLines(lines); remainder -= lines * lineHeight; }
|
|
if (event.cancelable) event.preventDefault();
|
|
};
|
|
const up = (event: PointerEvent) => {
|
|
if (pointerId !== event.pointerId) return;
|
|
const shouldFocus = gesture === 'pending';
|
|
const shouldFinishSelection = gesture === 'selecting';
|
|
clearLongPress();
|
|
if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId);
|
|
pointerId = null;
|
|
gesture = 'idle';
|
|
if (shouldFinishSelection) finishSelection();
|
|
if (shouldFocus) terminal.focus();
|
|
};
|
|
const cancel = (event: PointerEvent) => {
|
|
if (pointerId !== event.pointerId) return;
|
|
const shouldFinishSelection = gesture === 'selecting';
|
|
clearLongPress();
|
|
if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId);
|
|
pointerId = null;
|
|
gesture = 'idle';
|
|
if (shouldFinishSelection) finishSelection();
|
|
};
|
|
container.addEventListener('pointerdown', down);
|
|
container.addEventListener('pointermove', move, { passive: false });
|
|
container.addEventListener('pointerup', up);
|
|
container.addEventListener('pointercancel', cancel);
|
|
return () => {
|
|
clearLongPress();
|
|
container.removeEventListener('pointerdown', down);
|
|
container.removeEventListener('pointermove', move);
|
|
container.removeEventListener('pointerup', up);
|
|
container.removeEventListener('pointercancel', cancel);
|
|
};
|
|
}, [enableTouchScroll, fontSize, ready]);
|
|
|
|
React.useImperativeHandle(ref, () => ({
|
|
focus: () => terminalRef.current?.focus(),
|
|
fit,
|
|
getSelection: () => {
|
|
const terminal = terminalRef.current;
|
|
const range = terminal?.getSelectionPosition();
|
|
const text = terminal?.getSelection() ?? '';
|
|
if (!range || !text.trim()) return null;
|
|
return { text, startLine: range.start.y + 1, endLine: range.end.y + 1 };
|
|
},
|
|
}), [fit]);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
data-terminal-owner="main"
|
|
className={cn('terminal-viewport-container h-full w-full overflow-hidden touch-none', className)}
|
|
/>
|
|
);
|
|
});
|
|
|
|
TerminalViewport.displayName = 'TerminalViewport';
|
|
export { TerminalViewport };
|