Initial public release

This commit is contained in:
Bohdan Triapitsyn
2025-12-07 19:32:53 +02:00
commit 4b2edf7318
319 changed files with 81600 additions and 0 deletions
@@ -0,0 +1,366 @@
import React from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
import type { TerminalTheme } from '@/lib/terminalTheme';
import { getTerminalOptions } from '@/lib/terminalTheme';
import type { TerminalChunk } from '@/stores/useTerminalStore';
import { cn } from '@/lib/utils';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
type TerminalWithCore = Terminal & {
_core?: {
dimensions?: {
actualCellHeight?: number;
};
};
};
type TerminalController = {
focus: () => void;
clear: () => void;
fit: () => void;
};
interface TerminalViewportProps {
sessionKey: string;
chunks: TerminalChunk[];
onInput: (data: string) => void;
onResize: (cols: number, rows: number) => void;
theme: TerminalTheme;
fontFamily: string;
fontSize: number;
className?: string;
enableTouchScroll?: boolean;
}
const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportProps>(
(
{ sessionKey, chunks, onInput, onResize, theme, fontFamily, fontSize, className, enableTouchScroll },
ref
) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const viewportRef = React.useRef<HTMLElement | null>(null);
const terminalRef = React.useRef<Terminal | 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 isWritingRef = React.useRef(false);
const processedCountRef = React.useRef(0);
const firstChunkIdRef = React.useRef<number | null>(null);
const touchScrollCleanupRef = React.useRef<(() => void) | null>(null);
const [, forceRender] = React.useReducer((x) => x + 1, 0);
inputHandlerRef.current = onInput;
resizeHandlerRef.current = onResize;
const resetWriteState = React.useCallback(() => {
writeQueueRef.current = [];
isWritingRef.current = false;
processedCountRef.current = 0;
firstChunkIdRef.current = null;
}, []);
const fitTerminal = React.useCallback(() => {
const fitAddon = fitAddonRef.current;
const terminal = terminalRef.current;
const container = containerRef.current;
if (!fitAddon || !terminal || !container) {
return;
}
const rect = container.getBoundingClientRect();
if (rect.width < 24 || rect.height < 24) {
return;
}
try {
fitAddon.fit();
resizeHandlerRef.current(terminal.cols, terminal.rows);
} catch { /* ignored */ }
}, []);
const flushWriteQueue = React.useCallback(() => {
if (isWritingRef.current) {
return;
}
const consumeNext = () => {
const term = terminalRef.current;
if (!term) {
resetWriteState();
return;
}
const chunk = writeQueueRef.current.shift();
if (chunk === undefined) {
isWritingRef.current = false;
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();
}
}
});
};
consumeNext();
}, [resetWriteState]);
const enqueueWrite = React.useCallback(
(data: string) => {
if (!data) {
return;
}
writeQueueRef.current = [data];
isWritingRef.current = false;
flushWriteQueue();
},
[flushWriteQueue]
);
const setupTouchScroll = React.useCallback(() => {
touchScrollCleanupRef.current?.();
touchScrollCleanupRef.current = null;
if (!enableTouchScroll) {
return;
}
const container = containerRef.current;
const terminal = terminalRef.current;
if (!container || !terminal) {
return;
}
const state = {
lastY: null as number | null,
remainder: 0,
};
const internalTerminal = terminal as TerminalWithCore;
const measuredLineHeight =
internalTerminal._core?.dimensions?.actualCellHeight ?? null;
const lineHeightEstimate =
(typeof measuredLineHeight === 'number' && measuredLineHeight > 0
? measuredLineHeight
: Math.max(fontSize + 6, fontSize * 1.6));
const handleTouchStart = (event: TouchEvent) => {
if (event.touches.length !== 1) {
return;
}
state.lastY = event.touches[0].clientY;
state.remainder = 0;
};
const handleTouchMove = (event: TouchEvent) => {
if (event.touches.length !== 1) {
state.lastY = null;
state.remainder = 0;
return;
}
if (state.lastY === null) {
state.lastY = event.touches[0].clientY;
return;
}
const currentY = event.touches[0].clientY;
const delta = state.lastY - currentY;
state.lastY = currentY;
const aggregate = state.remainder + delta / lineHeightEstimate;
const lines =
aggregate > 0 ? Math.floor(aggregate) : aggregate < 0 ? Math.ceil(aggregate) : 0;
state.remainder = aggregate - lines;
if (lines !== 0) {
event.preventDefault();
event.stopPropagation();
terminal.scrollLines(lines);
}
};
const handleTouchEnd = () => {
state.lastY = null;
state.remainder = 0;
};
const listenerOptions: AddEventListenerOptions = { passive: false };
container.addEventListener('touchstart', handleTouchStart, listenerOptions);
container.addEventListener('touchmove', handleTouchMove, listenerOptions);
container.addEventListener('touchend', handleTouchEnd);
container.addEventListener('touchcancel', handleTouchEnd);
const previousTouchAction = container.style.touchAction;
container.style.touchAction = 'none';
touchScrollCleanupRef.current = () => {
container.removeEventListener('touchstart', handleTouchStart);
container.removeEventListener('touchmove', handleTouchMove);
container.removeEventListener('touchend', handleTouchEnd);
container.removeEventListener('touchcancel', handleTouchEnd);
container.style.touchAction = previousTouchAction;
};
}, [enableTouchScroll, fontSize]);
React.useEffect(() => {
const terminal = new Terminal(getTerminalOptions(fontFamily, fontSize, theme));
const fitAddon = new FitAddon();
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
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) {
return;
}
const options = getTerminalOptions(fontFamily, fontSize, theme);
Object.assign(terminal.options as Record<string, unknown>, options);
fitTerminal();
}, [fitTerminal, fontFamily, fontSize, theme]);
React.useEffect(() => {
const terminal = terminalRef.current;
if (!terminal) {
return;
}
terminal.reset();
resetWriteState();
fitTerminal();
terminal.focus();
}, [sessionKey, fitTerminal, resetWriteState]);
React.useEffect(() => {
setupTouchScroll();
return () => {
touchScrollCleanupRef.current?.();
touchScrollCleanupRef.current = null;
};
}, [setupTouchScroll, sessionKey]);
React.useEffect(() => {
const terminal = terminalRef.current;
if (!terminal) {
return;
}
if (chunks.length === 0) {
if (processedCountRef.current !== 0) {
terminal.reset();
resetWriteState();
fitTerminal();
}
return;
}
const currentFirstId = chunks[0].id;
if (firstChunkIdRef.current === null) {
firstChunkIdRef.current = currentFirstId;
}
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);
enqueueWrite(pending.map((chunk) => chunk.data).join(''));
processedCountRef.current = chunks.length;
}
}, [chunks, enqueueWrite, fitTerminal, resetWriteState]);
React.useImperativeHandle(
ref,
(): TerminalController => ({
focus: () => {
terminalRef.current?.focus();
},
clear: () => {
const terminal = terminalRef.current;
if (!terminal) {
return;
}
terminal.reset();
resetWriteState();
fitTerminal();
},
fit: () => {
fitTerminal();
},
}),
[fitTerminal, resetWriteState]
);
return (
<div ref={containerRef} className={cn('relative h-full w-full', className)}>
{viewportRef.current ? (
<OverlayScrollbar
containerRef={viewportRef}
disableHorizontal
className="overlay-scrollbar--flush overlay-scrollbar--dense overlay-scrollbar--zero"
/>
) : null}
</div>
);
}
);
TerminalViewport.displayName = 'TerminalViewport';
export type { TerminalController };
export { TerminalViewport };