feat(terminal) refactoring and stability improvements (#98)
* feat(terminal): replace xterm with ghostty-web Replace xterm.js terminal with ghostty-web implementation Add terminal serialization support for state restoration Apply custom patches to ghostty-web for enhancements * feat(terminal): add bun-pty backend support Switch terminal to ghostty-web with bun-pty backend for better performance Auto-detect and prefer Bun runtime when available for terminal sessions Update terminal viewport write queue handling for improved reliability * fix(terminal): prevent unnecessary resize events Only report terminal resize when dimensions actually change Simplify chunk processing state tracking Disable terminal transparency for consistent rendering * feat(terminal): increase scrollback and buffer limits Increase terminal scrollback buffer from 10k to 50k lines Increase terminal buffer limit from 256k to 1M bytes Add rate limiting and improve output handling for terminal streams
This commit is contained in:
committed by
GitHub
parent
dca8be01ca
commit
110b0e5d8f
@@ -1,14 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web';
|
||||
|
||||
import type { TerminalTheme } from '@/lib/terminalTheme';
|
||||
import { getTerminalOptions } from '@/lib/terminalTheme';
|
||||
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
|
||||
import type { TerminalChunk } from '@/stores/useTerminalStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||
|
||||
let ghosttyPromise: Promise<Ghostty> | null = null;
|
||||
|
||||
function getGhostty(): Promise<Ghostty> {
|
||||
if (!ghosttyPromise) {
|
||||
ghosttyPromise = Ghostty.load();
|
||||
}
|
||||
return ghosttyPromise;
|
||||
}
|
||||
|
||||
function findScrollableViewport(container: HTMLElement): HTMLElement | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [container, ...Array.from(container.querySelectorAll<HTMLElement>('*'))];
|
||||
let fallback: HTMLElement | null = null;
|
||||
|
||||
for (const element of candidates) {
|
||||
const style = window.getComputedStyle(element);
|
||||
const overflowY = style.overflowY;
|
||||
if (overflowY !== 'auto' && overflowY !== 'scroll') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prefer an element that is currently scrollable.
|
||||
if (element.scrollHeight - element.clientHeight > 2) {
|
||||
return element;
|
||||
}
|
||||
|
||||
// Otherwise keep the first overflow container as a fallback so we can
|
||||
// attach touch scroll before scrollback grows.
|
||||
if (!fallback) {
|
||||
fallback = element;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
type TerminalController = {
|
||||
focus: () => void;
|
||||
clear: () => void;
|
||||
@@ -34,25 +71,65 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
) => {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const viewportRef = React.useRef<HTMLElement | null>(null);
|
||||
const terminalRef = React.useRef<Terminal | null>(null);
|
||||
const terminalRef = React.useRef<GhosttyTerminal | null>(null);
|
||||
const fitAddonRef = React.useRef<FitAddon | null>(null);
|
||||
const inputHandlerRef = React.useRef<(data: string) => void>(onInput);
|
||||
const resizeHandlerRef = React.useRef<(cols: number, rows: number) => void>(onResize);
|
||||
const writeQueueRef = React.useRef<string[]>([]);
|
||||
const lastReportedSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
|
||||
const pendingWriteRef = React.useRef('');
|
||||
const writeScheduledRef = React.useRef<number | null>(null);
|
||||
const isWritingRef = React.useRef(false);
|
||||
const processedCountRef = React.useRef(0);
|
||||
const firstChunkIdRef = React.useRef<number | null>(null);
|
||||
const lastProcessedChunkIdRef = React.useRef<number | null>(null);
|
||||
const touchScrollCleanupRef = React.useRef<(() => void) | null>(null);
|
||||
const viewportDiscoveryTimeoutRef = React.useRef<number | null>(null);
|
||||
const viewportDiscoveryAttemptsRef = React.useRef(0);
|
||||
const hiddenInputRef = React.useRef<HTMLTextAreaElement | null>(null);
|
||||
const [, forceRender] = React.useReducer((x) => x + 1, 0);
|
||||
const [terminalReadyVersion, bumpTerminalReady] = React.useReducer((x) => x + 1, 0);
|
||||
|
||||
inputHandlerRef.current = onInput;
|
||||
resizeHandlerRef.current = onResize;
|
||||
|
||||
const focusHiddenInput = React.useCallback((clientX?: number, clientY?: number) => {
|
||||
const input = hiddenInputRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!input || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Position the input near the user's tap/cursor so the global keyboard
|
||||
// avoidance logic can decide whether anything is actually obscured.
|
||||
const rect = container.getBoundingClientRect();
|
||||
const fallbackX = rect.left + rect.width / 2;
|
||||
const fallbackY = rect.top + rect.height - 12;
|
||||
const x = typeof clientX === 'number' ? clientX : fallbackX;
|
||||
const y = typeof clientY === 'number' ? clientY : fallbackY;
|
||||
|
||||
const padding = 8;
|
||||
const left = Math.max(padding, Math.min(rect.width - padding, x - rect.left));
|
||||
const top = Math.max(padding, Math.min(rect.height - padding, y - rect.top));
|
||||
|
||||
input.style.left = `${left}px`;
|
||||
input.style.top = `${top}px`;
|
||||
input.style.bottom = '';
|
||||
|
||||
try {
|
||||
input.focus({ preventScroll: true });
|
||||
} catch {
|
||||
try {
|
||||
input.focus();
|
||||
} catch { /* ignored */ }
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetWriteState = React.useCallback(() => {
|
||||
writeQueueRef.current = [];
|
||||
pendingWriteRef.current = '';
|
||||
if (writeScheduledRef.current !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(writeScheduledRef.current);
|
||||
}
|
||||
writeScheduledRef.current = null;
|
||||
isWritingRef.current = false;
|
||||
processedCountRef.current = 0;
|
||||
firstChunkIdRef.current = null;
|
||||
lastProcessedChunkIdRef.current = null;
|
||||
}, []);
|
||||
|
||||
const fitTerminal = React.useCallback(() => {
|
||||
@@ -68,61 +145,85 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
}
|
||||
try {
|
||||
fitAddon.fit();
|
||||
resizeHandlerRef.current(terminal.cols, terminal.rows);
|
||||
const next = { cols: terminal.cols, rows: terminal.rows };
|
||||
const previous = lastReportedSizeRef.current;
|
||||
if (!previous || previous.cols !== next.cols || previous.rows !== next.rows) {
|
||||
lastReportedSizeRef.current = next;
|
||||
resizeHandlerRef.current(next.cols, next.rows);
|
||||
}
|
||||
} catch { /* ignored */ }
|
||||
}, []);
|
||||
|
||||
const flushWriteQueue = React.useCallback(() => {
|
||||
const flushWrites = React.useCallback(() => {
|
||||
if (isWritingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const consumeNext = () => {
|
||||
const term = terminalRef.current;
|
||||
if (!term) {
|
||||
resetWriteState();
|
||||
return;
|
||||
}
|
||||
const term = terminalRef.current;
|
||||
if (!term) {
|
||||
resetWriteState();
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = writeQueueRef.current.shift();
|
||||
if (chunk === undefined) {
|
||||
isWritingRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!pendingWriteRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isWritingRef.current = true;
|
||||
term.write(chunk, () => {
|
||||
isWritingRef.current = false;
|
||||
if (writeQueueRef.current.length > 0) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.setTimeout(consumeNext, 0);
|
||||
} else {
|
||||
consumeNext();
|
||||
}
|
||||
const chunk = pendingWriteRef.current;
|
||||
pendingWriteRef.current = '';
|
||||
|
||||
isWritingRef.current = true;
|
||||
term.write(chunk, () => {
|
||||
isWritingRef.current = false;
|
||||
if (pendingWriteRef.current) {
|
||||
if (typeof window !== 'undefined') {
|
||||
writeScheduledRef.current = window.requestAnimationFrame(() => {
|
||||
writeScheduledRef.current = null;
|
||||
flushWrites();
|
||||
});
|
||||
} else {
|
||||
flushWrites();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
consumeNext();
|
||||
}
|
||||
});
|
||||
}, [resetWriteState]);
|
||||
|
||||
const scheduleFlushWrites = React.useCallback(() => {
|
||||
if (writeScheduledRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
writeScheduledRef.current = window.requestAnimationFrame(() => {
|
||||
writeScheduledRef.current = null;
|
||||
flushWrites();
|
||||
});
|
||||
} else {
|
||||
flushWrites();
|
||||
}
|
||||
}, [flushWrites]);
|
||||
|
||||
const enqueueWrite = React.useCallback(
|
||||
(data: string) => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
writeQueueRef.current = [data];
|
||||
isWritingRef.current = false;
|
||||
flushWriteQueue();
|
||||
pendingWriteRef.current += data;
|
||||
scheduleFlushWrites();
|
||||
},
|
||||
[flushWriteQueue]
|
||||
[scheduleFlushWrites]
|
||||
);
|
||||
|
||||
const setupTouchScroll = React.useCallback(() => {
|
||||
touchScrollCleanupRef.current?.();
|
||||
touchScrollCleanupRef.current = null;
|
||||
|
||||
if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(viewportDiscoveryTimeoutRef.current);
|
||||
viewportDiscoveryTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (!enableTouchScroll) {
|
||||
viewportDiscoveryAttemptsRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -131,11 +232,15 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = container.querySelector('.xterm-viewport') as HTMLElement | null;
|
||||
if (!viewport) {
|
||||
// Ghostty scrollback is internal (canvas-based). On touch devices we need
|
||||
// to translate touch deltas into terminal scroll calls.
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewportDiscoveryAttemptsRef.current = 0;
|
||||
|
||||
const baseScrollMultiplier = 2.2;
|
||||
const maxScrollBoost = 2.8;
|
||||
const boostDenominator = 25;
|
||||
@@ -149,24 +254,35 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
lastTime: null as number | null,
|
||||
velocity: 0,
|
||||
rafId: null as number | null,
|
||||
startX: null as number | null,
|
||||
startY: null as number | null,
|
||||
didMove: false,
|
||||
};
|
||||
|
||||
const nowMs = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||
|
||||
const getMaxScrollTop = () => Math.max(0, viewport.scrollHeight - viewport.clientHeight);
|
||||
|
||||
const setScrollTop = (nextScrollTop: number) => {
|
||||
const maxScrollTop = getMaxScrollTop();
|
||||
viewport.scrollTop = Math.max(0, Math.min(maxScrollTop, nextScrollTop));
|
||||
};
|
||||
const lineHeightPx = Math.max(12, Math.round(fontSize * 1.35));
|
||||
let remainderPx = 0;
|
||||
|
||||
const scrollByPixels = (deltaPixels: number) => {
|
||||
if (!deltaPixels) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const previous = viewport.scrollTop;
|
||||
setScrollTop(previous + deltaPixels);
|
||||
return viewport.scrollTop !== previous;
|
||||
|
||||
const before = terminal.getViewportY();
|
||||
|
||||
const total = remainderPx + deltaPixels;
|
||||
const lines = Math.trunc(total / lineHeightPx);
|
||||
remainderPx = total - lines * lineHeightPx;
|
||||
|
||||
if (lines !== 0) {
|
||||
// Touch delta is in pixels, convert to lines.
|
||||
// Natural mobile scrolling: finger up scrolls down.
|
||||
terminal.scrollLines(lines);
|
||||
}
|
||||
|
||||
const after = terminal.getViewportY();
|
||||
return after !== before;
|
||||
};
|
||||
|
||||
const stopKinetic = () => {
|
||||
@@ -176,11 +292,18 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
state.rafId = null;
|
||||
};
|
||||
|
||||
const listenerOptions: AddEventListenerOptions = { passive: false, capture: true };
|
||||
const listenerOptions: AddEventListenerOptions = { passive: false, capture: false };
|
||||
const supportsPointerEvents = typeof window !== 'undefined' && 'PointerEvent' in window;
|
||||
|
||||
if (supportsPointerEvents) {
|
||||
const stateWithPointerId = Object.assign(state, { pointerId: null as number | null });
|
||||
const stateWithPointerId = Object.assign(state, {
|
||||
pointerId: null as number | null,
|
||||
startX: null as number | null,
|
||||
startY: null as number | null,
|
||||
moved: false,
|
||||
});
|
||||
|
||||
const TAP_MOVE_THRESHOLD_PX = 6;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (event.pointerType !== 'touch') {
|
||||
@@ -188,6 +311,9 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
}
|
||||
stopKinetic();
|
||||
stateWithPointerId.pointerId = event.pointerId;
|
||||
stateWithPointerId.startX = event.clientX;
|
||||
stateWithPointerId.startY = event.clientY;
|
||||
stateWithPointerId.moved = false;
|
||||
stateWithPointerId.lastY = event.clientY;
|
||||
stateWithPointerId.lastTime = nowMs();
|
||||
stateWithPointerId.velocity = 0;
|
||||
@@ -201,6 +327,14 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
return;
|
||||
}
|
||||
|
||||
if (stateWithPointerId.startX !== null && stateWithPointerId.startY !== null && !stateWithPointerId.moved) {
|
||||
const dx = event.clientX - stateWithPointerId.startX;
|
||||
const dy = event.clientY - stateWithPointerId.startY;
|
||||
if (Math.hypot(dx, dy) >= TAP_MOVE_THRESHOLD_PX) {
|
||||
stateWithPointerId.moved = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (stateWithPointerId.lastY === null) {
|
||||
stateWithPointerId.lastY = event.clientY;
|
||||
stateWithPointerId.lastTime = nowMs();
|
||||
@@ -230,10 +364,14 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
stateWithPointerId.velocity = -maxVelocity;
|
||||
}
|
||||
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
// Only prevent default once we're actually scrolling.
|
||||
if (stateWithPointerId.moved) {
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
event.stopPropagation();
|
||||
}
|
||||
event.stopPropagation();
|
||||
|
||||
scrollByPixels(deltaPixels);
|
||||
};
|
||||
|
||||
@@ -241,13 +379,24 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
if (event.pointerType !== 'touch' || stateWithPointerId.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasTap = !stateWithPointerId.moved;
|
||||
|
||||
stateWithPointerId.pointerId = null;
|
||||
stateWithPointerId.startX = null;
|
||||
stateWithPointerId.startY = null;
|
||||
stateWithPointerId.moved = false;
|
||||
stateWithPointerId.lastY = null;
|
||||
stateWithPointerId.lastTime = null;
|
||||
try {
|
||||
container.releasePointerCapture(event.pointerId);
|
||||
} catch { /* ignored */ }
|
||||
|
||||
if (wasTap) {
|
||||
focusHiddenInput(event.clientX, event.clientY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
@@ -287,10 +436,15 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
container.addEventListener('pointercancel', handlePointerUp, listenerOptions);
|
||||
|
||||
const previousTouchAction = container.style.touchAction;
|
||||
container.style.touchAction = 'none';
|
||||
container.style.touchAction = 'manipulation';
|
||||
|
||||
touchScrollCleanupRef.current = () => {
|
||||
stopKinetic();
|
||||
if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(viewportDiscoveryTimeoutRef.current);
|
||||
viewportDiscoveryTimeoutRef.current = null;
|
||||
}
|
||||
viewportDiscoveryAttemptsRef.current = 0;
|
||||
container.removeEventListener('pointerdown', handlePointerDown, listenerOptions);
|
||||
container.removeEventListener('pointermove', handlePointerMove, listenerOptions);
|
||||
container.removeEventListener('pointerup', handlePointerUp, listenerOptions);
|
||||
@@ -301,6 +455,8 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
return;
|
||||
}
|
||||
|
||||
const TAP_MOVE_THRESHOLD_PX = 6;
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
if (event.touches.length !== 1) {
|
||||
return;
|
||||
@@ -309,6 +465,9 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
state.lastY = event.touches[0].clientY;
|
||||
state.lastTime = nowMs();
|
||||
state.velocity = 0;
|
||||
state.startX = event.touches[0].clientX;
|
||||
state.startY = event.touches[0].clientY;
|
||||
state.didMove = false;
|
||||
};
|
||||
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
@@ -316,11 +475,24 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
state.lastY = null;
|
||||
state.lastTime = null;
|
||||
state.velocity = 0;
|
||||
state.startX = null;
|
||||
state.startY = null;
|
||||
state.didMove = false;
|
||||
stopKinetic();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentX = event.touches[0].clientX;
|
||||
const currentY = event.touches[0].clientY;
|
||||
|
||||
if (state.startX !== null && state.startY !== null && !state.didMove) {
|
||||
const dx = currentX - state.startX;
|
||||
const dy = currentY - state.startY;
|
||||
if (Math.hypot(dx, dy) >= TAP_MOVE_THRESHOLD_PX) {
|
||||
state.didMove = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.lastY === null) {
|
||||
state.lastY = currentY;
|
||||
state.lastTime = nowMs();
|
||||
@@ -350,20 +522,37 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
state.velocity = -maxVelocity;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (state.didMove) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
scrollByPixels(deltaPixels);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
const handleTouchEnd = (event: TouchEvent) => {
|
||||
const wasTap = !state.didMove;
|
||||
|
||||
|
||||
state.lastY = null;
|
||||
state.lastTime = null;
|
||||
|
||||
const velocity = state.velocity;
|
||||
state.startX = null;
|
||||
state.startY = null;
|
||||
state.didMove = false;
|
||||
|
||||
if (wasTap) {
|
||||
const point = event.changedTouches?.[0];
|
||||
focusHiddenInput(point?.clientX, point?.clientY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(state.velocity) < minVelocity) {
|
||||
if (Math.abs(velocity) < minVelocity) {
|
||||
state.velocity = 0;
|
||||
return;
|
||||
}
|
||||
@@ -394,77 +583,114 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
|
||||
container.addEventListener('touchstart', handleTouchStart, listenerOptions);
|
||||
container.addEventListener('touchmove', handleTouchMove, listenerOptions);
|
||||
container.addEventListener('touchend', handleTouchEnd, listenerOptions);
|
||||
container.addEventListener('touchcancel', handleTouchEnd, listenerOptions);
|
||||
container.addEventListener('touchend', handleTouchEnd as unknown as EventListener, listenerOptions);
|
||||
container.addEventListener('touchcancel', handleTouchEnd as unknown as EventListener, listenerOptions);
|
||||
|
||||
const previousTouchAction = container.style.touchAction;
|
||||
container.style.touchAction = 'none';
|
||||
container.style.touchAction = 'manipulation';
|
||||
|
||||
touchScrollCleanupRef.current = () => {
|
||||
stopKinetic();
|
||||
if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(viewportDiscoveryTimeoutRef.current);
|
||||
viewportDiscoveryTimeoutRef.current = null;
|
||||
}
|
||||
viewportDiscoveryAttemptsRef.current = 0;
|
||||
container.removeEventListener('touchstart', handleTouchStart, listenerOptions);
|
||||
container.removeEventListener('touchmove', handleTouchMove, listenerOptions);
|
||||
container.removeEventListener('touchend', handleTouchEnd, listenerOptions);
|
||||
container.removeEventListener('touchcancel', handleTouchEnd, listenerOptions);
|
||||
container.removeEventListener('touchend', handleTouchEnd as unknown as EventListener, listenerOptions);
|
||||
container.removeEventListener('touchcancel', handleTouchEnd as unknown as EventListener, listenerOptions);
|
||||
container.style.touchAction = previousTouchAction;
|
||||
};
|
||||
}, [enableTouchScroll]);
|
||||
}, [enableTouchScroll, focusHiddenInput, fontSize]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const terminal = new Terminal(getTerminalOptions(fontFamily, fontSize, theme));
|
||||
const fitAddon = new FitAddon();
|
||||
|
||||
terminalRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminal.loadAddon(fitAddon);
|
||||
let disposed = false;
|
||||
let localTerminal: GhosttyTerminal | null = null;
|
||||
let localResizeObserver: ResizeObserver | null = null;
|
||||
let localDisposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
terminal.open(container);
|
||||
const viewport = container.querySelector('.xterm-viewport') as HTMLElement | null;
|
||||
if (viewport) {
|
||||
viewport.classList.add('overlay-scrollbar-target', 'overlay-scrollbar-container');
|
||||
viewportRef.current = viewport;
|
||||
forceRender();
|
||||
}
|
||||
fitTerminal();
|
||||
terminal.focus();
|
||||
}
|
||||
|
||||
const disposables = [
|
||||
terminal.onData((data) => {
|
||||
inputHandlerRef.current(data);
|
||||
}),
|
||||
];
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitTerminal();
|
||||
});
|
||||
if (container) {
|
||||
resizeObserver.observe(container);
|
||||
}
|
||||
|
||||
return () => {
|
||||
touchScrollCleanupRef.current?.();
|
||||
touchScrollCleanupRef.current = null;
|
||||
disposables.forEach((disposable) => disposable.dispose());
|
||||
resizeObserver.disconnect();
|
||||
terminal.dispose();
|
||||
terminalRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
resetWriteState();
|
||||
};
|
||||
}, [fitTerminal, fontFamily, fontSize, theme, resetWriteState]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const options = getTerminalOptions(fontFamily, fontSize, theme);
|
||||
Object.assign(terminal.options as Record<string, unknown>, options);
|
||||
fitTerminal();
|
||||
}, [fitTerminal, fontFamily, fontSize, theme]);
|
||||
|
||||
container.tabIndex = 0;
|
||||
|
||||
const initialize = async () => {
|
||||
try {
|
||||
const ghostty = await getGhostty();
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty);
|
||||
|
||||
const terminal = new GhosttyTerminal(options);
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
|
||||
localTerminal = terminal;
|
||||
terminalRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(container);
|
||||
bumpTerminalReady();
|
||||
|
||||
const viewport = findScrollableViewport(container);
|
||||
if (viewport) {
|
||||
viewport.classList.add('overlay-scrollbar-target', 'overlay-scrollbar-container');
|
||||
viewportRef.current = viewport;
|
||||
forceRender();
|
||||
} else {
|
||||
viewportRef.current = null;
|
||||
}
|
||||
|
||||
fitTerminal();
|
||||
setupTouchScroll();
|
||||
terminal.focus();
|
||||
|
||||
localDisposables = [
|
||||
terminal.onData((data: string) => {
|
||||
inputHandlerRef.current(data);
|
||||
}),
|
||||
];
|
||||
|
||||
localResizeObserver = new ResizeObserver(() => {
|
||||
fitTerminal();
|
||||
});
|
||||
localResizeObserver.observe(container);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.setTimeout(() => {
|
||||
fitTerminal();
|
||||
}, 0);
|
||||
}
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
};
|
||||
|
||||
void initialize();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
touchScrollCleanupRef.current?.();
|
||||
touchScrollCleanupRef.current = null;
|
||||
|
||||
localDisposables.forEach((disposable) => disposable.dispose());
|
||||
localResizeObserver?.disconnect();
|
||||
|
||||
localTerminal?.dispose();
|
||||
terminalRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
viewportRef.current = null;
|
||||
lastReportedSizeRef.current = null;
|
||||
resetWriteState();
|
||||
};
|
||||
}, [fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState]);
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
const terminal = terminalRef.current;
|
||||
@@ -473,9 +699,10 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
}
|
||||
terminal.reset();
|
||||
resetWriteState();
|
||||
lastReportedSizeRef.current = null;
|
||||
fitTerminal();
|
||||
terminal.focus();
|
||||
}, [sessionKey, fitTerminal, resetWriteState]);
|
||||
}, [sessionKey, terminalReadyVersion, fitTerminal, resetWriteState]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setupTouchScroll();
|
||||
@@ -492,7 +719,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
if (processedCountRef.current !== 0) {
|
||||
if (lastProcessedChunkIdRef.current !== null) {
|
||||
terminal.reset();
|
||||
resetWriteState();
|
||||
fitTerminal();
|
||||
@@ -500,31 +727,31 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFirstId = chunks[0].id;
|
||||
if (firstChunkIdRef.current === null) {
|
||||
firstChunkIdRef.current = currentFirstId;
|
||||
const lastProcessedId = lastProcessedChunkIdRef.current;
|
||||
let pending: TerminalChunk[];
|
||||
|
||||
if (lastProcessedId === null) {
|
||||
pending = chunks;
|
||||
} else {
|
||||
const lastProcessedIndex = chunks.findIndex((chunk) => chunk.id === lastProcessedId);
|
||||
pending = lastProcessedIndex >= 0 ? chunks.slice(lastProcessedIndex + 1) : chunks;
|
||||
}
|
||||
|
||||
const shouldReset =
|
||||
firstChunkIdRef.current !== currentFirstId || processedCountRef.current > chunks.length;
|
||||
|
||||
if (shouldReset) {
|
||||
terminal.reset();
|
||||
resetWriteState();
|
||||
firstChunkIdRef.current = currentFirstId;
|
||||
}
|
||||
|
||||
if (processedCountRef.current < chunks.length) {
|
||||
const pending = chunks.slice(processedCountRef.current);
|
||||
if (pending.length > 0) {
|
||||
enqueueWrite(pending.map((chunk) => chunk.data).join(''));
|
||||
processedCountRef.current = chunks.length;
|
||||
}
|
||||
}, [chunks, enqueueWrite, fitTerminal, resetWriteState]);
|
||||
|
||||
lastProcessedChunkIdRef.current = chunks[chunks.length - 1].id;
|
||||
}, [chunks, terminalReadyVersion, enqueueWrite, fitTerminal, resetWriteState]);
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
(): TerminalController => ({
|
||||
focus: () => {
|
||||
if (enableTouchScroll) {
|
||||
focusHiddenInput();
|
||||
return;
|
||||
}
|
||||
terminalRef.current?.focus();
|
||||
},
|
||||
clear: () => {
|
||||
@@ -540,12 +767,69 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
fitTerminal();
|
||||
},
|
||||
}),
|
||||
[fitTerminal, resetWriteState]
|
||||
[enableTouchScroll, focusHiddenInput, fitTerminal, resetWriteState]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn('relative h-full w-full', className)}>
|
||||
{viewportRef.current ? (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('relative h-full w-full', className)}
|
||||
style={{ backgroundColor: theme.background }}
|
||||
onClick={(event) => {
|
||||
if (enableTouchScroll) {
|
||||
focusHiddenInput(event.clientX, event.clientY);
|
||||
} else {
|
||||
terminalRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{enableTouchScroll ? (
|
||||
<textarea
|
||||
ref={hiddenInputRef}
|
||||
inputMode="text"
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
opacity: 0.001,
|
||||
zIndex: 1,
|
||||
background: 'transparent',
|
||||
color: 'transparent',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
outline: 'none',
|
||||
}}
|
||||
onInput={(event) => {
|
||||
const raw = String(event.currentTarget.value || '');
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
// iOS often inserts `\n` for Enter; the PTY expects CR.
|
||||
const value = raw.replace(/\r\n|\r|\n/g, '\r');
|
||||
inputHandlerRef.current(value);
|
||||
event.currentTarget.value = '';
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Backspace') {
|
||||
// If there's nothing in the input buffer, emulate DEL.
|
||||
if (!event.currentTarget.value) {
|
||||
inputHandlerRef.current('\x7f');
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{viewportRef.current && !enableTouchScroll ? (
|
||||
<OverlayScrollbar
|
||||
containerRef={viewportRef}
|
||||
disableHorizontal
|
||||
|
||||
@@ -190,6 +190,11 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
switch (event.type) {
|
||||
case 'connected': {
|
||||
if (event.runtime || event.ptyBackend) {
|
||||
console.log(
|
||||
`[Terminal] connected runtime=${event.runtime ?? 'unknown'} pty=${event.ptyBackend ?? 'unknown'}`
|
||||
);
|
||||
}
|
||||
setConnecting(directory, false);
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
|
||||
Reference in New Issue
Block a user