- 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
331 lines
8.0 KiB
TypeScript
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>
|
|
);
|
|
}
|