Files
paseo/packages/app/src/components/terminal-emulator.tsx
T
Mohamed Boudra 223c8d7d0b feat: sortable workspace tabs, tab context menus, and terminal auto-create removal
- Add drag-and-drop tab reordering with persistent tab order
- Add right-click context menu with copy agent ID, copy resume command, close to right
- Switch workspace route encoding from percent-encoding to base64url
- Remove terminal auto-creation on directory listing (explicit create only)
- Add swipe gestures on terminal emulator for mobile sidebar navigation
- Highlight active workspace row in sidebar
2026-03-02 15:59:13 +07:00

331 lines
8.0 KiB
TypeScript

"use dom";
import { useEffect, useRef } from "react";
import type { DOMProps } from "expo/dom";
import "@xterm/xterm/css/xterm.css";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
interface TerminalEmulatorProps {
dom?: DOMProps;
streamKey: string;
initialOutputText: string;
outputChunkText: string;
outputChunkSequence: number;
testId?: string;
backgroundColor?: string;
foregroundColor?: string;
cursorColor?: string;
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
onInput?: (data: string) => Promise<void> | void;
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
onTerminalKey?: (input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onOutputChunkConsumed?: (sequence: number) => Promise<void> | void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
resizeRequestToken?: number;
}
declare global {
interface Window {}
}
export default function TerminalEmulator({
streamKey,
initialOutputText,
outputChunkText,
outputChunkSequence,
testId = "terminal-surface",
backgroundColor = "#0b0b0b",
foregroundColor = "#e6e6e6",
cursorColor = "#e6e6e6",
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onOutputChunkConsumed,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
resizeRequestToken = 0,
}: TerminalEmulatorProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const hostRef = useRef<HTMLDivElement | null>(null);
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
const appliedInitialOutputRef = useRef<string | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root || !swipeGesturesEnabled) {
return;
}
const SWIPE_MIN_PX = 22;
const VERTICAL_CANCEL_PX = 12;
const HORIZONTAL_DOMINANCE_RATIO = 1.2;
let tracking = false;
let activePointerId: number | null = null;
let startX = 0;
let startY = 0;
let fired = false;
const reset = () => {
tracking = false;
activePointerId = null;
startX = 0;
startY = 0;
fired = false;
};
const shouldTreatAsVertical = (dx: number, dy: number) => {
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDy < VERTICAL_CANCEL_PX) {
return false;
}
return absDy > absDx;
};
const shouldTreatAsHorizontal = (dx: number, dy: number) => {
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDx < SWIPE_MIN_PX) {
return false;
}
if (absDy === 0) {
return true;
}
return absDx / absDy >= HORIZONTAL_DOMINANCE_RATIO;
};
const onPointerDown = (event: PointerEvent) => {
if (!event.isPrimary) {
return;
}
tracking = true;
fired = false;
activePointerId = event.pointerId;
startX = event.clientX;
startY = event.clientY;
};
const onPointerMove = (event: PointerEvent) => {
if (!tracking || fired) {
return;
}
if (activePointerId !== null && event.pointerId !== activePointerId) {
return;
}
const dx = event.clientX - startX;
const dy = event.clientY - startY;
if (shouldTreatAsVertical(dx, dy)) {
reset();
return;
}
if (!shouldTreatAsHorizontal(dx, dy)) {
return;
}
fired = true;
if (dx > 0) {
onSwipeRight?.();
} else {
onSwipeLeft?.();
}
if (event.cancelable) {
event.preventDefault();
}
};
const onPointerUp = (event: PointerEvent) => {
if (activePointerId !== null && event.pointerId !== activePointerId) {
return;
}
reset();
};
const onPointerCancel = (event: PointerEvent) => {
if (activePointerId !== null && event.pointerId !== activePointerId) {
return;
}
reset();
};
root.addEventListener("pointerdown", onPointerDown, { passive: true });
root.addEventListener("pointermove", onPointerMove, { passive: false });
root.addEventListener("pointerup", onPointerUp, { passive: true });
root.addEventListener("pointercancel", onPointerCancel, { passive: true });
return () => {
root.removeEventListener("pointerdown", onPointerDown);
root.removeEventListener("pointermove", onPointerMove);
root.removeEventListener("pointerup", onPointerUp);
root.removeEventListener("pointercancel", onPointerCancel);
};
}, [onSwipeLeft, onSwipeRight, swipeGesturesEnabled]);
useEffect(() => {
const host = hostRef.current;
const root = rootRef.current;
if (!host || !root) {
return;
}
const runtime = new TerminalEmulatorRuntime();
runtimeRef.current = runtime;
runtime.setCallbacks({
callbacks: {
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
},
});
runtime.setPendingModifiers({ pendingModifiers });
runtime.mount({
root,
host,
initialOutputText,
theme: {
backgroundColor,
foregroundColor,
cursorColor,
},
});
appliedInitialOutputRef.current = initialOutputText;
return () => {
runtime.unmount();
if (runtimeRef.current === runtime) {
runtimeRef.current = null;
}
appliedInitialOutputRef.current = null;
};
}, [backgroundColor, cursorColor, foregroundColor, streamKey]);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime) {
return;
}
if (appliedInitialOutputRef.current === initialOutputText) {
return;
}
appliedInitialOutputRef.current = initialOutputText;
runtime.clear();
if (initialOutputText.length > 0) {
runtime.write({ text: initialOutputText });
}
}, [initialOutputText]);
useEffect(() => {
runtimeRef.current?.setCallbacks({
callbacks: {
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
},
});
}, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]);
useEffect(() => {
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
}, [pendingModifiers]);
useEffect(() => {
const runtime = runtimeRef.current;
if (outputChunkSequence <= 0) {
return;
}
if (!runtime) {
onOutputChunkConsumed?.(outputChunkSequence);
return;
}
if (outputChunkText.length === 0) {
runtime.clear({
onCommitted: () => {
onOutputChunkConsumed?.(outputChunkSequence);
},
});
return;
}
runtime.write({
text: outputChunkText,
onCommitted: () => {
onOutputChunkConsumed?.(outputChunkSequence);
},
});
}, [onOutputChunkConsumed, outputChunkSequence, outputChunkText]);
useEffect(() => {
if (focusRequestToken <= 0) {
return;
}
runtimeRef.current?.focus();
}, [focusRequestToken]);
useEffect(() => {
if (resizeRequestToken <= 0) {
return;
}
runtimeRef.current?.resize({ force: true });
}, [resizeRequestToken]);
return (
<div
ref={rootRef}
data-testid={testId}
style={{
position: "relative",
display: "flex",
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
backgroundColor,
overflow: "hidden",
overscrollBehavior: "none",
touchAction: "pan-y",
}}
onPointerDown={() => {
runtimeRef.current?.focus();
}}
>
<div
ref={hostRef}
style={{
flex: 1,
minHeight: 0,
minWidth: 0,
width: "100%",
height: "100%",
overflow: "hidden",
overscrollBehavior: "none",
}}
/>
</div>
);
}