mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
feat(app): find text in terminal scrollback
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
|
||||
import { getTerminalBufferText, buildTerminalWorkspaceUrl } from "../support/helpers/terminal-perf";
|
||||
|
||||
function query(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Find in pane", exact: true }).filter({ visible: true });
|
||||
}
|
||||
function status(page: Page) {
|
||||
return page.getByRole("status", { name: "Find matches" }).filter({ visible: true });
|
||||
}
|
||||
function input(page: Page) {
|
||||
return page
|
||||
.getByTestId("terminal-surface")
|
||||
.filter({ visible: true })
|
||||
.locator(".xterm-helper-textarea");
|
||||
}
|
||||
async function openFind(page: Page, text?: string) {
|
||||
await input(page).press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
if (text !== undefined) await query(page).fill(text);
|
||||
}
|
||||
async function expectSelectedQuery(page: Page, text: string) {
|
||||
await expect(query(page)).toBeFocused();
|
||||
await expect(query(page)).toHaveValue(text);
|
||||
await expect
|
||||
.poll(() =>
|
||||
query(page).evaluate((el: HTMLInputElement) => [el.selectionStart, el.selectionEnd]),
|
||||
)
|
||||
.toEqual([0, text.length]);
|
||||
}
|
||||
async function expectUncoveredMatch(page: Page) {
|
||||
const widget = page.getByLabel("Find", { exact: true });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const selected = await page.evaluate(() => {
|
||||
const term = (
|
||||
window as Window & {
|
||||
__paseoTerminal?: {
|
||||
rows: number;
|
||||
cols: number;
|
||||
buffer: { active: { viewportY: number } };
|
||||
getSelectionPosition():
|
||||
| { start: { x: number; y: number }; end: { x: number; y: number } }
|
||||
| undefined;
|
||||
};
|
||||
}
|
||||
).__paseoTerminal;
|
||||
const selection = term?.getSelectionPosition();
|
||||
return term && selection
|
||||
? { selection, rows: term.rows, cols: term.cols, firstRow: term.buffer.active.viewportY }
|
||||
: null;
|
||||
});
|
||||
const screen = await page.locator(".xterm-screen").filter({ visible: true }).boundingBox();
|
||||
const w = await widget.boundingBox();
|
||||
if (!selected || !screen || !w) return false;
|
||||
const x = screen.x + (selected.selection.start.x * screen.width) / selected.cols;
|
||||
const y =
|
||||
screen.y +
|
||||
((selected.selection.start.y - selected.firstRow) * screen.height) / selected.rows;
|
||||
const height = screen.height / selected.rows;
|
||||
return (
|
||||
y >= screen.y &&
|
||||
y + height <= screen.y + screen.height + 1 &&
|
||||
!(x >= w.x && x < w.x + w.width && y < w.y + w.height && y + height > w.y)
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
async function viewport(page: Page) {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
(window as Window & { __paseoTerminal?: { buffer: { active: { viewportY: number } } } })
|
||||
.__paseoTerminal?.buffer.active.viewportY,
|
||||
);
|
||||
}
|
||||
|
||||
let harness: TerminalE2EHarness;
|
||||
test.beforeEach(async () => {
|
||||
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-find-" });
|
||||
});
|
||||
test.afterEach(async () => {
|
||||
await harness.cleanup();
|
||||
});
|
||||
|
||||
test("searches retained output without sending Find input to the shell", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const received = path.join(harness.tempRepo.path, "received.txt");
|
||||
const trigger = path.join(harness.tempRepo.path, "output.trigger");
|
||||
const bottomTrigger = path.join(harness.tempRepo.path, "bottom.trigger");
|
||||
const script = `stty -echo; printf 'first a.b\\n'; for i in $(seq 1 90); do printf 'line %s\\n' "$i"; done; printf 'last A.B\\nREADY\\n'; (while [ ! -f output.trigger ]; do sleep 0.05; done; printf 'new output\\n'; while [ ! -f bottom.trigger ]; do sleep 0.05; done; for i in $(seq 1 30); do printf 'bottom output %s\\n' "$i"; done) & while IFS= read -r line; do printf '%s\\n' "$line" >> received.txt; done`;
|
||||
await writeFile(received, "");
|
||||
const terminal = await harness.createTerminal({
|
||||
name: "Find fixture",
|
||||
command: "bash",
|
||||
args: ["--noprofile", "--norc", "-c", script],
|
||||
});
|
||||
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain("READY");
|
||||
await openFind(page, "a.b");
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await query(page).press("Shift+Enter");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await expectUncoveredMatch(page);
|
||||
const findBox = await page.getByLabel("Find", { exact: true }).boundingBox();
|
||||
const surfaceBox = await page.getByTestId("terminal-surface").boundingBox();
|
||||
expect(findBox!.y).toBeLessThan(surfaceBox!.y + 70);
|
||||
const inspected = await viewport(page);
|
||||
await writeFile(trigger, "go");
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain("new output");
|
||||
await expect.poll(() => viewport(page)).toBe(inspected);
|
||||
await page.screenshot({ path: testInfo.outputPath("scrollback-find.png") });
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
const inspectedBottom = await viewport(page);
|
||||
await writeFile(bottomTrigger, "go");
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain("bottom output 30");
|
||||
await expect.poll(() => viewport(page)).toBe(inspectedBottom);
|
||||
await expectUncoveredMatch(page);
|
||||
await query(page).press("Enter");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await query(page).press("ControlOrMeta+f");
|
||||
await expectSelectedQuery(page, "a.b");
|
||||
await query(page).fill("A.B");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await query(page).fill("absent[.*]");
|
||||
await expect(status(page)).toHaveText("No matches");
|
||||
await query(page).fill("a.b");
|
||||
await query(page).press("Escape");
|
||||
await expect(query(page)).toBeHidden();
|
||||
await expect(input(page)).toBeFocused();
|
||||
await expect(page.locator(".xterm-find-result-decoration")).toHaveCount(0);
|
||||
await input(page).pressSequentially("only-shell-input");
|
||||
await input(page).press("Enter");
|
||||
await expect.poll(() => readFile(received, "utf8")).toBe("only-shell-input\n");
|
||||
await input(page).pressSequentially("discard");
|
||||
await input(page).press("Control+u");
|
||||
await input(page).pressSequentially("ordinary-input");
|
||||
await input(page).press("Enter");
|
||||
await expect.poll(() => readFile(received, "utf8")).toBe("only-shell-input\nordinary-input\n");
|
||||
await openFind(page);
|
||||
await expectSelectedQuery(page, "a.b");
|
||||
await expect(status(page)).toHaveText(/of 2/);
|
||||
await expectUncoveredMatch(page);
|
||||
await expect(page.getByRole("button", { name: "Toggle replace" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("keeps terminal Find owned by retained tabs and the focused split pane", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const first = await harness.createTerminal({
|
||||
name: "First search",
|
||||
command: "bash",
|
||||
args: ["-c", "printf 'first needle\\n'; cat"],
|
||||
});
|
||||
const second = await harness.createTerminal({
|
||||
name: "Second search",
|
||||
command: "bash",
|
||||
args: ["-c", "printf 'second needle\\nsecond needle\\n'; cat"],
|
||||
});
|
||||
await harness.openTerminal(page, { terminalId: first.id });
|
||||
await openFind(page, "needle");
|
||||
await expect(status(page)).toHaveText("1 of 1");
|
||||
await page.getByTestId(`workspace-tab-terminal_${second.id}`).first().click();
|
||||
await openFind(page, "needle");
|
||||
await expect(status(page).filter({ visible: true })).toHaveText("2 of 2");
|
||||
await query(page).filter({ visible: true }).press("Escape");
|
||||
await page.getByTestId(`workspace-tab-terminal_${first.id}`).first().click();
|
||||
await input(page).press("ControlOrMeta+f");
|
||||
await expectSelectedQuery(page, "needle");
|
||||
await expect(status(page).filter({ visible: true })).toHaveText("1 of 1");
|
||||
await query(page).filter({ visible: true }).press("Escape");
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await page.getByTestId(`workspace-tab-terminal_${second.id}`).first().click();
|
||||
await runWorkspaceActionFromCommandCenter(page, "Move tab right");
|
||||
const left = page
|
||||
.getByTestId("split-group-child")
|
||||
.filter({ has: page.getByTestId(`workspace-tab-terminal_${first.id}`) });
|
||||
const right = page
|
||||
.getByTestId("split-group-child")
|
||||
.filter({ has: page.getByTestId(`workspace-tab-terminal_${second.id}`) });
|
||||
await expect(left.locator(".xterm-helper-textarea")).toHaveCount(1);
|
||||
await expect(right.locator(".xterm-helper-textarea")).toHaveCount(1);
|
||||
await right.getByTestId("terminal-surface").click();
|
||||
await right.locator(".xterm-helper-textarea").press("ControlOrMeta+f");
|
||||
await expect(right.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused();
|
||||
await right.getByRole("textbox", { name: "Find in pane", exact: true }).fill("needle");
|
||||
await expect(right.getByRole("status", { name: "Find matches" })).toHaveText(/of 2/);
|
||||
await expect(left.getByRole("textbox", { name: "Find in pane", exact: true })).toHaveCount(0);
|
||||
await right.getByRole("textbox", { name: "Find in pane", exact: true }).press("Escape");
|
||||
await left.getByTestId("terminal-surface").click();
|
||||
await left.locator(".xterm-helper-textarea").press("ControlOrMeta+f");
|
||||
await expect(left.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused();
|
||||
await left.getByRole("textbox", { name: "Find in pane", exact: true }).fill("needle");
|
||||
await expect(left.getByRole("status", { name: "Find matches" })).toHaveText("1 of 1");
|
||||
await page.screenshot({ path: testInfo.outputPath("split-find.png") });
|
||||
});
|
||||
|
||||
test("keeps the shared Find controls usable in a compact dark browser", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 390, height: 720 });
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
const terminal = await harness.createTerminal({
|
||||
name: "Compact search",
|
||||
command: "bash",
|
||||
args: ["-c", "printf 'needle\\nneedle\\n'; cat"],
|
||||
});
|
||||
await page.goto(buildTerminalWorkspaceUrl(harness.workspaceId, terminal.id));
|
||||
await expect(page.getByTestId("terminal-surface")).toBeVisible();
|
||||
await page.getByTestId("terminal-surface").click();
|
||||
await openFind(page, "needle");
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
await page.getByRole("button", { name: "Previous match" }).click();
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
await page.getByRole("button", { name: "Next match" }).click();
|
||||
await expect(status(page)).toHaveText("2 of 2");
|
||||
const box = await page.getByLabel("Find", { exact: true }).boundingBox();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
|
||||
await page.screenshot({ path: testInfo.outputPath("compact-find.png") });
|
||||
await page.getByRole("button", { name: "Close Find" }).click();
|
||||
await expect(input(page)).toBeFocused();
|
||||
});
|
||||
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
TerminalFindHandle,
|
||||
TerminalFindResult,
|
||||
} from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import type { Ref } from "react";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
@@ -12,6 +16,7 @@ import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
||||
|
||||
export interface TerminalEmulatorHandle {
|
||||
find?: TerminalFindHandle;
|
||||
writeOutput: (data: TerminalOutputData) => void;
|
||||
restoreOutput: (data: TerminalOutputData) => void;
|
||||
renderSnapshot: (state: TerminalState | null) => void;
|
||||
@@ -39,6 +44,8 @@ export interface TerminalEmulatorProps {
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
initialSnapshot?: TerminalState | null;
|
||||
onFindRequest?: () => void;
|
||||
onFindResult?: (result: TerminalFindResult) => void;
|
||||
onInput?: (data: string) => Promise<void> | void;
|
||||
onFocus?: () => Promise<void> | void;
|
||||
onResize?: (input: {
|
||||
|
||||
@@ -19,7 +19,10 @@ import { WebView, type WebViewMessageEvent } from "react-native-webview";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
|
||||
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import type {
|
||||
TerminalOutputData,
|
||||
TerminalFindResult,
|
||||
} from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import type {
|
||||
TerminalLocalFileLinkSource,
|
||||
TerminalLocalFileLinkTarget,
|
||||
@@ -49,6 +52,9 @@ type BridgeInboundMessage =
|
||||
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
|
||||
| { type: "paste"; streamKey: string; text: string }
|
||||
| { type: "clear"; streamKey: string }
|
||||
| { type: "find"; streamKey: string; query: string; direction?: "next" | "previous" }
|
||||
| { type: "clearFind"; streamKey: string }
|
||||
| { type: "findWidgetSize"; streamKey: string; size: { width: number; height: number } }
|
||||
| { type: "focus"; streamKey: string; forceRefocus?: boolean }
|
||||
| { type: "resize"; streamKey: string; forceClaim: boolean; shouldClaim?: boolean }
|
||||
| { type: "setTheme"; streamKey: string; theme: ITheme }
|
||||
@@ -67,6 +73,8 @@ type BridgeOutboundMessage =
|
||||
| { type: "bridgeReady" }
|
||||
| { type: "rendererReady"; streamKey: string; isReady: boolean }
|
||||
| { type: "input"; streamKey: string; data: string }
|
||||
| { type: "findRequest"; streamKey: string }
|
||||
| { type: "findResult"; streamKey: string; result: TerminalFindResult }
|
||||
| {
|
||||
type: "resize";
|
||||
streamKey: string;
|
||||
@@ -162,6 +170,8 @@ export default function WebViewTerminalEmulator({
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
initialSnapshot = null,
|
||||
onFindRequest,
|
||||
onFindResult,
|
||||
onInput,
|
||||
onFocus,
|
||||
onResize,
|
||||
@@ -209,6 +219,8 @@ export default function WebViewTerminalEmulator({
|
||||
swipeGesturesEnabled,
|
||||
};
|
||||
const callbacksRef = useRef({
|
||||
onFindRequest,
|
||||
onFindResult,
|
||||
onInput,
|
||||
onFocus,
|
||||
onResize,
|
||||
@@ -222,6 +234,8 @@ export default function WebViewTerminalEmulator({
|
||||
onSwipeRight,
|
||||
});
|
||||
callbacksRef.current = {
|
||||
onFindRequest,
|
||||
onFindResult,
|
||||
onInput,
|
||||
onFocus,
|
||||
onResize,
|
||||
@@ -315,6 +329,11 @@ export default function WebViewTerminalEmulator({
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
(): TerminalEmulatorHandle => ({
|
||||
find: {
|
||||
setWidgetSize: (size) => sendToWebView({ type: "findWidgetSize", streamKey, size }),
|
||||
search: (query, direction) => sendToWebView({ type: "find", streamKey, query, direction }),
|
||||
clear: () => sendToWebView({ type: "clearFind", streamKey }),
|
||||
},
|
||||
writeOutput: (data: TerminalOutputData) => {
|
||||
const output = outputDecoderRef.current.decode(data, { stream: true });
|
||||
if (output.length === 0) {
|
||||
@@ -563,6 +582,14 @@ export default function WebViewTerminalEmulator({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (message.type === "findRequest") {
|
||||
callbacksRef.current.onFindRequest?.();
|
||||
return;
|
||||
}
|
||||
if (message.type === "findResult") {
|
||||
callbacksRef.current.onFindResult?.(message.result);
|
||||
return;
|
||||
}
|
||||
handleTerminalMessage(message);
|
||||
},
|
||||
[handleLifecycleMessage, handleTerminalMessage, streamKey],
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"use dom";
|
||||
|
||||
import type {
|
||||
TerminalFindHandle,
|
||||
TerminalFindResult,
|
||||
} from "../terminal/runtime/terminal-emulator-runtime";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -41,6 +46,7 @@ import {
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
|
||||
export interface TerminalEmulatorHandle {
|
||||
find?: TerminalFindHandle;
|
||||
writeOutput: (data: TerminalOutputData) => void;
|
||||
restoreOutput: (data: TerminalOutputData) => void;
|
||||
renderSnapshot: (state: TerminalState | null) => void;
|
||||
@@ -111,6 +117,8 @@ interface TerminalEmulatorProps {
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
initialSnapshot?: TerminalState | null;
|
||||
onFindRequest?: () => void;
|
||||
onFindResult?: (result: TerminalFindResult) => void;
|
||||
onInput?: (data: string) => Promise<void> | void;
|
||||
onFocus?: () => Promise<void> | void;
|
||||
onResize?: (input: {
|
||||
@@ -172,6 +180,8 @@ export default function TerminalEmulator({
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
initialSnapshot = null,
|
||||
onFindRequest,
|
||||
onFindResult,
|
||||
onInput,
|
||||
onFocus,
|
||||
onResize,
|
||||
@@ -201,6 +211,8 @@ export default function TerminalEmulator({
|
||||
const onRendererReadyChangeRef = useRef(onRendererReadyChange);
|
||||
onRendererReadyChangeRef.current = onRendererReadyChange;
|
||||
const mountCallbacksRef = useRef({
|
||||
onFindRequest,
|
||||
onFindResult,
|
||||
onInput,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
@@ -210,6 +222,8 @@ export default function TerminalEmulator({
|
||||
onOpenLocalFileLink,
|
||||
});
|
||||
mountCallbacksRef.current = {
|
||||
onFindRequest,
|
||||
onFindResult,
|
||||
onInput,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
@@ -283,6 +297,11 @@ export default function TerminalEmulator({
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
(): TerminalEmulatorHandle => ({
|
||||
find: {
|
||||
setWidgetSize: (size) => runtimeRef.current?.find.setWidgetSize(size),
|
||||
search: (query, direction) => runtimeRef.current?.find.search(query, direction),
|
||||
clear: () => runtimeRef.current?.find.clear(),
|
||||
},
|
||||
writeOutput: (data: TerminalOutputData) => {
|
||||
runtimeRef.current?.write({ data });
|
||||
},
|
||||
@@ -478,6 +497,8 @@ export default function TerminalEmulator({
|
||||
useEffect(() => {
|
||||
runtimeRef.current?.setCallbacks({
|
||||
callbacks: {
|
||||
onFindRequest,
|
||||
onFindResult,
|
||||
onInput,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
@@ -489,6 +510,8 @@ export default function TerminalEmulator({
|
||||
},
|
||||
});
|
||||
}, [
|
||||
onFindRequest,
|
||||
onFindResult,
|
||||
onInput,
|
||||
onInputModeChange,
|
||||
onOpenLocalFileLink,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { TerminalFind, type TerminalPaneFindHandle } from "@/terminal/find";
|
||||
import type { TerminalFindResult } from "@/terminal/runtime/terminal-emulator-runtime";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -267,6 +269,12 @@ export function TerminalPane({
|
||||
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
||||
useBlockMobilePanelOpenGestures(isMobile && isWorkspaceFocused && isPaneFocused && hasSelection);
|
||||
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
|
||||
const findRef = useRef<TerminalPaneFindHandle>(null);
|
||||
const handleFindRequest = useCallback(() => findRef.current?.open(), []);
|
||||
const handleFindResult = useCallback(
|
||||
(result: TerminalFindResult) => findRef.current?.update(result),
|
||||
[],
|
||||
);
|
||||
const terminalIdRef = useRef<string>(terminalId);
|
||||
const terminalPresentedRef = useRef(isTerminalPresented);
|
||||
terminalPresentedRef.current = isTerminalPresented;
|
||||
@@ -326,6 +334,7 @@ export function TerminalPane({
|
||||
}, [isMobile, isWorkspaceFocused, mobileView]);
|
||||
const handleRendererReadyChange = useCallback(
|
||||
(change: TerminalRendererReadyChange) => {
|
||||
if (!change.isReady && change.streamKey === terminalStreamKey) findRef.current?.reset();
|
||||
setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change));
|
||||
if (!shouldReplayTerminalSnapshotForRenderer({ change, terminalStreamKey })) {
|
||||
return;
|
||||
@@ -1060,6 +1069,8 @@ export function TerminalPane({
|
||||
onRendererReadyChange={handleRendererReadyChange}
|
||||
onSwipeRight={handleSwipeRight}
|
||||
onSwipeLeft={handleSwipeLeft}
|
||||
onFindRequest={handleFindRequest}
|
||||
onFindResult={handleFindResult}
|
||||
onInput={handleTerminalData}
|
||||
onFocus={handleTerminalFocus}
|
||||
onResize={handleTerminalResize}
|
||||
@@ -1075,6 +1086,16 @@ export function TerminalPane({
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TerminalFind
|
||||
key={terminalStreamKey}
|
||||
ref={findRef}
|
||||
terminal={emulatorRef}
|
||||
active={
|
||||
isTerminalPresented && isPaneFocused && rendererReadyStreamKey === terminalStreamKey
|
||||
}
|
||||
focusTerminal={requestTerminalFocus}
|
||||
/>
|
||||
|
||||
{showLoadingOverlay ? (
|
||||
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { View, type LayoutChangeEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PaneFind, type PaneFindHandle } from "@/pane-find";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { hasActiveWebOverlay } from "@/lib/overlay-root";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import type { TerminalEmulatorHandle } from "@/components/terminal-emulator-contract";
|
||||
import type { TerminalFindResult } from "../runtime/terminal-emulator-runtime";
|
||||
|
||||
export interface TerminalPaneFindHandle {
|
||||
open(): void;
|
||||
update(result: TerminalFindResult): void;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export const TerminalFind = forwardRef<
|
||||
TerminalPaneFindHandle,
|
||||
{
|
||||
terminal: RefObject<TerminalEmulatorHandle | null>;
|
||||
active: boolean;
|
||||
focusTerminal(): void;
|
||||
}
|
||||
>(function TerminalFind({ terminal, active, focusTerminal }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const widget = useRef<PaneFindHandle>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [result, setResult] = useState<TerminalFindResult>({
|
||||
resultIndex: -1,
|
||||
resultCount: 0,
|
||||
limited: false,
|
||||
placement: "top",
|
||||
});
|
||||
const show = useCallback(() => {
|
||||
if (!active || !terminal.current?.find) return;
|
||||
if (!open && query) terminal.current.find.search(query);
|
||||
setOpen(true);
|
||||
widget.current?.focus();
|
||||
}, [active, open, query, terminal]);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
open: show,
|
||||
update: setResult,
|
||||
reset: () => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
},
|
||||
}),
|
||||
[show],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isWeb || !active) return;
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
|
||||
return;
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
event.key.toLowerCase() === "f" &&
|
||||
terminal.current?.find
|
||||
) {
|
||||
event.preventDefault();
|
||||
show();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [active, show, terminal]);
|
||||
const search = useCallback(
|
||||
(text: string) => {
|
||||
setQuery(text);
|
||||
terminal.current?.find?.search(text);
|
||||
},
|
||||
[terminal],
|
||||
);
|
||||
const next = useCallback(() => terminal.current?.find?.search(query, "next"), [query, terminal]);
|
||||
const previous = useCallback(
|
||||
() => terminal.current?.find?.search(query, "previous"),
|
||||
[query, terminal],
|
||||
);
|
||||
const measureWidget = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
terminal.current?.find?.setWidgetSize({ width, height });
|
||||
},
|
||||
[terminal],
|
||||
);
|
||||
const close = useCallback(() => {
|
||||
terminal.current?.find?.clear();
|
||||
setOpen(false);
|
||||
focusTerminal();
|
||||
}, [focusTerminal, terminal]);
|
||||
if (!open || !terminal.current?.find) return null;
|
||||
const total = `${result.resultCount}${result.limited ? "+" : ""}`;
|
||||
let status = "";
|
||||
if (query) {
|
||||
if (result.resultCount === 0) status = t("paneFind.noMatches");
|
||||
else if (result.resultIndex < 0) status = t("paneFind.total", { total });
|
||||
else status = t("paneFind.position", { current: result.resultIndex + 1, total });
|
||||
}
|
||||
return (
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
style={[styles.overlay, result.placement === "top" ? styles.top : styles.bottom]}
|
||||
>
|
||||
<View onLayout={measureWidget} style={styles.frame}>
|
||||
<PaneFind
|
||||
ref={widget}
|
||||
query={query}
|
||||
status={status}
|
||||
canNavigate={result.resultCount > 0}
|
||||
onQueryChange={search}
|
||||
onNext={next}
|
||||
onPrevious={previous}
|
||||
onClose={close}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "flex-end",
|
||||
zIndex: 2,
|
||||
},
|
||||
frame: { maxWidth: "100%", padding: theme.spacing[2] },
|
||||
top: { top: 0 },
|
||||
bottom: { bottom: 0 },
|
||||
}));
|
||||
@@ -6,7 +6,7 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
|
||||
import { Terminal, type ITheme } from "@xterm/xterm";
|
||||
import { Terminal, type ITheme, type IMarker } from "@xterm/xterm";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
type TerminalInputModeState,
|
||||
@@ -32,6 +32,21 @@ import { resolveTerminalFontFamily, resolveTerminalFontSize } from "./terminal-f
|
||||
|
||||
export type TerminalOutputData = Uint8Array;
|
||||
|
||||
export interface TerminalFindHandle {
|
||||
setWidgetSize(size: { width: number; height: number }): void;
|
||||
search(query: string, direction?: "next" | "previous"): void;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export interface TerminalFindResult {
|
||||
resultIndex: number;
|
||||
resultCount: number;
|
||||
limited: boolean;
|
||||
placement: "top" | "bottom";
|
||||
}
|
||||
|
||||
const FIND_HIGHLIGHT_LIMIT = 20_000;
|
||||
|
||||
export interface TerminalEmulatorRuntimeMountInput {
|
||||
root: HTMLDivElement;
|
||||
host: HTMLDivElement;
|
||||
@@ -43,6 +58,8 @@ export interface TerminalEmulatorRuntimeMountInput {
|
||||
}
|
||||
|
||||
export interface TerminalEmulatorRuntimeCallbacks {
|
||||
onFindRequest?: () => void;
|
||||
onFindResult?: (result: TerminalFindResult) => void;
|
||||
onInput?: (data: string) => Promise<void> | void;
|
||||
onResize?: (input: TerminalResizeEvent) => Promise<void> | void;
|
||||
onTerminalKey?: (input: {
|
||||
@@ -173,6 +190,48 @@ export class TerminalEmulatorRuntime {
|
||||
};
|
||||
private terminal: Terminal | null = null;
|
||||
private fitAddon: FitAddon | null = null;
|
||||
private searchAddon: SearchAddon | null = null;
|
||||
private findResult = { resultIndex: -1, resultCount: 0 };
|
||||
private findQuery = "";
|
||||
private findOutputAnchor: IMarker | undefined;
|
||||
private findWidgetSize = { width: 0, height: 0 };
|
||||
|
||||
readonly find: TerminalFindHandle = {
|
||||
setWidgetSize: (size) => {
|
||||
this.findWidgetSize = size;
|
||||
this.emitFindResult();
|
||||
},
|
||||
search: (query, direction) => {
|
||||
this.releaseFindOutputAnchor();
|
||||
this.findQuery = query;
|
||||
if (this.terminal && !this.searchAddon) this.loadSearchAddon(this.terminal);
|
||||
const theme = this.terminal?.options.theme;
|
||||
const options = {
|
||||
regex: false,
|
||||
caseSensitive: false,
|
||||
incremental: direction === undefined,
|
||||
decorations: {
|
||||
matchBorder: theme?.yellow ?? "#b58900",
|
||||
matchOverviewRuler: theme?.yellow ?? "#b58900",
|
||||
activeMatchBorder: theme?.blue ?? "#268bd2",
|
||||
activeMatchColorOverviewRuler: theme?.blue ?? "#268bd2",
|
||||
},
|
||||
};
|
||||
if (direction === "next") this.searchAddon?.findNext(query, options);
|
||||
else this.searchAddon?.findPrevious(query, options);
|
||||
},
|
||||
clear: () => {
|
||||
this.releaseFindOutputAnchor();
|
||||
this.findQuery = "";
|
||||
// clearDecorations leaves the addon's pending refresh alive. Disposal
|
||||
// cancels its timers and listeners without touching terminal selection.
|
||||
this.searchAddon?.dispose();
|
||||
this.searchAddon = null;
|
||||
this.findResult = { resultIndex: -1, resultCount: 0 };
|
||||
this.emitFindResult();
|
||||
},
|
||||
};
|
||||
|
||||
private fitAndEmitResize: ((input?: TerminalResizeRequest) => void) | null = null;
|
||||
private lastSize: { rows: number; cols: number } | null = null;
|
||||
private cleanup: (() => void) | null = null;
|
||||
@@ -215,6 +274,77 @@ export class TerminalEmulatorRuntime {
|
||||
this.pendingModifiers = input.pendingModifiers;
|
||||
}
|
||||
|
||||
private loadSearchAddon(terminal: Terminal): void {
|
||||
const searchAddon = new SearchAddon({ highlightLimit: FIND_HIGHLIGHT_LIMIT });
|
||||
terminal.loadAddon(searchAddon);
|
||||
this.searchAddon = searchAddon;
|
||||
searchAddon.onDidChangeResults((result) => {
|
||||
this.findResult = result;
|
||||
this.emitFindResult();
|
||||
});
|
||||
}
|
||||
|
||||
private releaseFindOutputAnchor(): void {
|
||||
this.findOutputAnchor?.dispose();
|
||||
this.findOutputAnchor = undefined;
|
||||
}
|
||||
|
||||
private preserveFindViewport(terminal: Terminal): void {
|
||||
if (!this.findQuery || this.findResult.resultCount === 0 || this.findOutputAnchor) return;
|
||||
const buffer = terminal.buffer.active;
|
||||
this.findOutputAnchor = terminal.registerMarker(
|
||||
buffer.viewportY - buffer.baseY - buffer.cursorY,
|
||||
);
|
||||
}
|
||||
|
||||
private handleFindShortcut(event: KeyboardEvent): boolean {
|
||||
if (
|
||||
!(event.metaKey || event.ctrlKey) ||
|
||||
event.shiftKey ||
|
||||
event.altKey ||
|
||||
event.key.toLowerCase() !== "f" ||
|
||||
!this.callbacks.onFindRequest
|
||||
)
|
||||
return false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.callbacks.onFindRequest();
|
||||
return true;
|
||||
}
|
||||
|
||||
private findWidgetCoversSelection(): boolean {
|
||||
if (!this.findQuery) return false;
|
||||
const terminal = this.terminal;
|
||||
const element = terminal?.element;
|
||||
const screen = element?.querySelector(".xterm-screen");
|
||||
const selection = terminal?.getSelectionPosition();
|
||||
if (!terminal || !element || !screen || !selection) return false;
|
||||
const root = element.getBoundingClientRect();
|
||||
const grid = screen.getBoundingClientRect();
|
||||
const cellWidth = grid.width / terminal.cols;
|
||||
const cellHeight = grid.height / terminal.rows;
|
||||
const firstRow = terminal.buffer.active.viewportY;
|
||||
const top = grid.top + (selection.start.y - firstRow) * cellHeight;
|
||||
const bottom = grid.top + (selection.end.y - firstRow + 1) * cellHeight;
|
||||
// A wrapped selection can occupy the right edge on its intermediate rows.
|
||||
const rightCol = selection.start.y === selection.end.y ? selection.end.x : terminal.cols;
|
||||
const right = grid.left + rightCol * cellWidth;
|
||||
return (
|
||||
top < root.top + this.findWidgetSize.height &&
|
||||
bottom > root.top &&
|
||||
right > root.right - this.findWidgetSize.width
|
||||
);
|
||||
}
|
||||
|
||||
private emitFindResult(): void {
|
||||
const placement = this.findWidgetCoversSelection() ? "bottom" : "top";
|
||||
this.callbacks.onFindResult?.({
|
||||
...this.findResult,
|
||||
limited: this.findResult.resultCount >= FIND_HIGHLIGHT_LIMIT,
|
||||
placement,
|
||||
});
|
||||
}
|
||||
|
||||
getInputModeState(): TerminalInputModeState {
|
||||
return this.inputModeTracker.getState();
|
||||
}
|
||||
@@ -267,7 +397,19 @@ export class TerminalEmulatorRuntime {
|
||||
},
|
||||
}),
|
||||
);
|
||||
terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 }));
|
||||
this.loadSearchAddon(terminal);
|
||||
terminal.onWriteParsed(() => {
|
||||
// Even a bottom viewport is an inspected location while searching. A
|
||||
// marker follows buffer trimming; output writes remain batched normally.
|
||||
const anchor = this.findOutputAnchor;
|
||||
if (anchor && !anchor.isDisposed) terminal.scrollToLine(anchor.line);
|
||||
// A parser turn may yield with submitted writes still queued. Commit
|
||||
// callbacks identify completion; plain writes still submit back-to-back.
|
||||
if (this.pendingWriteCommits.size === 0) this.releaseFindOutputAnchor();
|
||||
});
|
||||
terminal.onScroll(() => {
|
||||
if (this.findQuery) this.emitFindResult();
|
||||
});
|
||||
terminal.loadAddon(new ClipboardAddon());
|
||||
try {
|
||||
terminal.loadAddon(new LigaturesAddon());
|
||||
@@ -418,6 +560,8 @@ export class TerminalEmulatorRuntime {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.handleFindShortcut(event)) return false;
|
||||
|
||||
if (!isMac && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) {
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
@@ -775,6 +919,7 @@ export class TerminalEmulatorRuntime {
|
||||
}
|
||||
|
||||
unmount(): void {
|
||||
this.releaseFindOutputAnchor();
|
||||
this.clearInFlightOutputTimeout();
|
||||
const inFlightOperation = this.inFlightOutputOperation;
|
||||
this.inFlightOutputOperation = null;
|
||||
@@ -796,6 +941,9 @@ export class TerminalEmulatorRuntime {
|
||||
window.__paseoTerminal = undefined;
|
||||
}
|
||||
this.terminal = null;
|
||||
this.searchAddon = null;
|
||||
this.findQuery = "";
|
||||
this.findResult = { resultIndex: -1, resultCount: 0 };
|
||||
this.fitAddon = null;
|
||||
this.fitAndEmitResize = null;
|
||||
this.lastSize = null;
|
||||
@@ -870,6 +1018,7 @@ export class TerminalEmulatorRuntime {
|
||||
}
|
||||
|
||||
private submitWrite(terminal: Terminal, operation: TerminalOutputOperation): void {
|
||||
this.preserveFindViewport(terminal);
|
||||
// Synchronous per-write tracking must run in frame order; doing it here in the drain
|
||||
// loop preserves that ordering even though the writes are submitted without waiting.
|
||||
const text = this.inputModeDecoder.decode(operation.data, { stream: true });
|
||||
@@ -879,19 +1028,11 @@ export class TerminalEmulatorRuntime {
|
||||
}
|
||||
this.hasUngatedWrites = true;
|
||||
const onCommitted = operation.onCommitted;
|
||||
if (!onCommitted) {
|
||||
try {
|
||||
terminal.write(operation.data);
|
||||
} catch {
|
||||
// Match existing behavior: a failed write still proceeds with no commit callback.
|
||||
}
|
||||
return;
|
||||
}
|
||||
const commit = () => {
|
||||
if (!this.pendingWriteCommits.delete(commit)) {
|
||||
return;
|
||||
}
|
||||
onCommitted();
|
||||
onCommitted?.();
|
||||
};
|
||||
this.pendingWriteCommits.add(commit);
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import {
|
||||
TerminalEmulatorRuntime,
|
||||
encodeTerminalOutput,
|
||||
type TerminalFindResult,
|
||||
} from "./terminal-emulator-runtime";
|
||||
|
||||
let runtime: TerminalEmulatorRuntime;
|
||||
let root: HTMLDivElement;
|
||||
afterEach(() => {
|
||||
runtime?.unmount();
|
||||
root?.remove();
|
||||
});
|
||||
function mount() {
|
||||
root = document.createElement("div");
|
||||
Object.assign(root.style, { width: "480px", height: "200px" });
|
||||
const host = document.createElement("div");
|
||||
Object.assign(host.style, { width: "100%", height: "100%" });
|
||||
root.append(host);
|
||||
document.body.append(root);
|
||||
runtime = new TerminalEmulatorRuntime();
|
||||
const results: TerminalFindResult[] = [];
|
||||
runtime.setCallbacks({
|
||||
callbacks: {
|
||||
onFindResult: (result) => {
|
||||
results.push(result);
|
||||
},
|
||||
},
|
||||
});
|
||||
runtime.mount({
|
||||
root,
|
||||
host,
|
||||
initialSnapshot: null,
|
||||
scrollback: 1000,
|
||||
theme: { background: "#ffffff", foreground: "#111111" },
|
||||
});
|
||||
return results;
|
||||
}
|
||||
function write(text: string) {
|
||||
return new Promise<void>((resolve) =>
|
||||
runtime.write({ data: encodeTerminalOutput(text), onCommitted: resolve }),
|
||||
);
|
||||
}
|
||||
|
||||
test("uses xterm's literal matching across wrapped cells and keeps selection when Find closes", async () => {
|
||||
const results = mount();
|
||||
// Initial font measurement schedules refits through 2 seconds. Settle them
|
||||
// before choosing an exact column boundary for the wrapped text fixture.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2600));
|
||||
await write("first [a.b] 界\r\n" + "padding\r\n".repeat(30) + "last [A.B] 界");
|
||||
runtime.find.search("[a.b] 界");
|
||||
expect(results.at(-1)).toMatchObject({ resultCount: 2, resultIndex: 1, limited: false });
|
||||
runtime.find.search("[a.b] 界", "previous");
|
||||
expect(results.at(-1)).toMatchObject({ resultCount: 2, resultIndex: 0, placement: "top" });
|
||||
runtime.find.search("[a.b] 界", "previous");
|
||||
expect(results.at(-1)).toMatchObject({ resultCount: 2, resultIndex: 1 });
|
||||
const selection = window.__paseoTerminal!.getSelection();
|
||||
runtime.find.clear();
|
||||
expect(window.__paseoTerminal!.getSelection()).toBe(selection);
|
||||
expect(results.at(-1)).toMatchObject({ resultCount: 0, resultIndex: -1 });
|
||||
runtime.find.search("[aXb]");
|
||||
expect(results.at(-1)).toMatchObject({ resultCount: 0 });
|
||||
const cols = window.__paseoTerminal!.cols;
|
||||
await write("\r\n" + "x".repeat(cols - 2) + "wrapped-needle");
|
||||
runtime.find.search("wrapped-needle");
|
||||
await expect.poll(() => results.at(-1)).toMatchObject({ resultCount: 1, resultIndex: 0 });
|
||||
});
|
||||
|
||||
test("reports the addon's highlight limit without disabling navigation beyond the counted matches", async () => {
|
||||
const results = mount();
|
||||
await write(("a ".repeat(25) + "\r\n").repeat(801));
|
||||
runtime.find.search("a");
|
||||
expect(results.at(-1)).toMatchObject({ resultCount: 20000, limited: true, resultIndex: -1 });
|
||||
expect(window.__paseoTerminal!.getSelection()).toBe("a");
|
||||
runtime.find.search("a", "next");
|
||||
expect(results.at(-1)).toMatchObject({ resultCount: 20000, limited: true, resultIndex: 0 });
|
||||
});
|
||||
|
||||
test("keeps the inspected bottom viewport fixed when output arrives", async () => {
|
||||
mount();
|
||||
await write("padding\r\n".repeat(30) + "inspected needle");
|
||||
runtime.find.search("needle");
|
||||
const term = window.__paseoTerminal!;
|
||||
const inspected = term.buffer.active.viewportY;
|
||||
await write("\r\nnew output".repeat(30));
|
||||
await expect.poll(() => term.buffer.active.viewportY).toBe(inspected);
|
||||
expect(term.getSelection()).toBe("needle");
|
||||
});
|
||||
|
||||
test("searches the active alternate buffer without moving the restored normal viewport", async () => {
|
||||
const results = mount();
|
||||
await write("normal needle\r\n" + "padding\r\n".repeat(30));
|
||||
const normalViewport = window.__paseoTerminal!.buffer.active.viewportY;
|
||||
await write("\x1b[?1049hAlt needle\r\nAlt NEEDLE");
|
||||
runtime.find.search("needle");
|
||||
expect(results.at(-1)).toMatchObject({ resultCount: 2, resultIndex: 1, limited: false });
|
||||
expect(window.__paseoTerminal!.getSelection()).toBe("NEEDLE");
|
||||
runtime.find.search("needle", "previous");
|
||||
expect(window.__paseoTerminal!.getSelection()).toBe("needle");
|
||||
await write("\x1b[?1049l");
|
||||
await expect.poll(() => window.__paseoTerminal!.buffer.active.viewportY).toBe(normalViewport);
|
||||
runtime.find.search("needle");
|
||||
await expect.poll(() => results.at(-1)).toMatchObject({ resultCount: 1, resultIndex: 0 });
|
||||
});
|
||||
|
||||
test("keeps Find at the top unless its measured frame covers the active match", async () => {
|
||||
const results = mount();
|
||||
await new Promise((resolve) => setTimeout(resolve, 2600));
|
||||
runtime.find.setWidgetSize({ width: 356, height: 58 });
|
||||
const cols = window.__paseoTerminal!.cols;
|
||||
await write("left-needle\r\n" + " ".repeat(cols - 12) + "right-needle");
|
||||
runtime.find.search("left-needle");
|
||||
expect(results.at(-1)).toMatchObject({ resultCount: 1, placement: "top" });
|
||||
runtime.find.search("right-needle");
|
||||
expect(results.at(-1)).toMatchObject({ resultCount: 1, placement: "bottom" });
|
||||
});
|
||||
|
||||
test("closing Find cancels pending refreshes without clearing old or new selections", async () => {
|
||||
mount();
|
||||
await write("first needle\r\n" + "padding\r\n".repeat(30) + "last needle");
|
||||
const term = window.__paseoTerminal!;
|
||||
for (const freshSelection of [false, true]) {
|
||||
runtime.find.search("needle");
|
||||
await write("\r\nnew output");
|
||||
// onWriteParsed schedules the addon's refresh after the write callback.
|
||||
await new Promise<void>((resolve) => {
|
||||
const subscription = term.onWriteParsed(() => {
|
||||
subscription.dispose();
|
||||
resolve();
|
||||
});
|
||||
runtime.write({ data: encodeTerminalOutput("\rprogress") });
|
||||
});
|
||||
runtime.find.clear();
|
||||
if (freshSelection) term.select(0, 0, 5);
|
||||
const selection = term.getSelection();
|
||||
expect(selection).toBe(freshSelection ? "first" : "needle");
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
expect(term.getSelection()).toBe(selection);
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves inspection through multiple real parser turns of queued output", async () => {
|
||||
mount();
|
||||
await new Promise((resolve) => setTimeout(resolve, 2600));
|
||||
await write("padding\r\n".repeat(30) + "needle\r\nprogress");
|
||||
runtime.find.search("needle");
|
||||
const term = window.__paseoTerminal!;
|
||||
const inspected = term.buffer.active.viewportY;
|
||||
const samples: { viewport: number; base: number }[] = [];
|
||||
const subscription = term.onWriteParsed(() => {
|
||||
samples.push({ viewport: term.buffer.active.viewportY, base: term.buffer.active.baseY });
|
||||
});
|
||||
for (let i = 0; i < 150; i++) {
|
||||
runtime.write({ data: encodeTerminalOutput("\rprogress".repeat(1000)) });
|
||||
}
|
||||
await write("\r\nnew output".repeat(100));
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
subscription.dispose();
|
||||
expect(samples.length).toBeGreaterThan(1);
|
||||
expect(samples[0].base).toBe(inspected);
|
||||
expect(samples.at(-1)!.base).toBeGreaterThan(inspected);
|
||||
expect(term.buffer.active.viewportY).toBe(inspected);
|
||||
expect(term.getSelection()).toBe("needle");
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TerminalFindResult } from "../runtime/terminal-emulator-runtime";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
import xtermCss from "@xterm/xterm/css/xterm.css";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
@@ -32,6 +33,9 @@ type InboundMessage =
|
||||
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
|
||||
| { type: "paste"; streamKey: string; text: string }
|
||||
| { type: "clear"; streamKey: string }
|
||||
| { type: "find"; streamKey: string; query: string; direction?: "next" | "previous" }
|
||||
| { type: "clearFind"; streamKey: string }
|
||||
| { type: "findWidgetSize"; streamKey: string; size: { width: number; height: number } }
|
||||
| { type: "focus"; streamKey: string; forceRefocus?: boolean }
|
||||
| { type: "resize"; streamKey: string; forceClaim: boolean; shouldClaim?: boolean }
|
||||
| { type: "setTheme"; streamKey: string; theme: ITheme }
|
||||
@@ -50,6 +54,8 @@ type OutboundMessage =
|
||||
| { type: "bridgeReady" }
|
||||
| { type: "rendererReady"; streamKey: string; isReady: boolean }
|
||||
| { type: "input"; streamKey: string; data: string }
|
||||
| { type: "findRequest"; streamKey: string }
|
||||
| { type: "findResult"; streamKey: string; result: TerminalFindResult }
|
||||
| {
|
||||
type: "resize";
|
||||
streamKey: string;
|
||||
@@ -229,6 +235,18 @@ class TerminalWebViewBridge {
|
||||
this.resolveLocalFileLinkRequest(message.requestId, message.target);
|
||||
return;
|
||||
}
|
||||
if (message.type === "findWidgetSize") {
|
||||
this.runtime?.find.setWidgetSize(message.size);
|
||||
return;
|
||||
}
|
||||
if (message.type === "find") {
|
||||
this.runtime?.find.search(message.query, message.direction);
|
||||
return;
|
||||
}
|
||||
if (message.type === "clearFind") {
|
||||
this.runtime?.find.clear();
|
||||
return;
|
||||
}
|
||||
this.receiveMounted(message);
|
||||
}
|
||||
|
||||
@@ -306,6 +324,9 @@ class TerminalWebViewBridge {
|
||||
this.runtime = runtime;
|
||||
runtime.setCallbacks({
|
||||
callbacks: {
|
||||
onFindRequest: () => sendToNative({ type: "findRequest", streamKey: message.streamKey }),
|
||||
onFindResult: (result) =>
|
||||
sendToNative({ type: "findResult", streamKey: message.streamKey, result }),
|
||||
onInput: (data) => sendToNative({ type: "input", streamKey: message.streamKey, data }),
|
||||
onResize: ({ rows, cols, shouldClaim, forceClaim }) =>
|
||||
sendToNative({
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,73 @@
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { terminalEmulatorWebViewHtml } from "./terminal-emulator-webview-html";
|
||||
|
||||
interface Message {
|
||||
type: string;
|
||||
streamKey?: string;
|
||||
result?: { resultCount: number; resultIndex: number };
|
||||
data?: string;
|
||||
}
|
||||
interface TerminalFrameWindow extends Window {
|
||||
__PASEO_TERMINAL_WEBVIEW_RECEIVE__(message: unknown): void;
|
||||
__paseoTerminal?: Terminal;
|
||||
}
|
||||
let frame: HTMLIFrameElement;
|
||||
afterEach(() => frame?.remove());
|
||||
|
||||
test("the generated WebView searches its mounted stream and keeps Find commands off the input bridge", async () => {
|
||||
const messages: Message[] = [];
|
||||
frame = document.createElement("iframe");
|
||||
frame.style.width = "500px";
|
||||
frame.style.height = "300px";
|
||||
document.body.append(frame);
|
||||
const win = frame.contentWindow as TerminalFrameWindow;
|
||||
Object.assign(win, {
|
||||
ReactNativeWebView: { postMessage: (raw: string) => messages.push(JSON.parse(raw)) },
|
||||
});
|
||||
win.document.open();
|
||||
win.document.write(terminalEmulatorWebViewHtml);
|
||||
win.document.close();
|
||||
await expect.poll(() => messages.some((m) => m.type === "bridgeReady")).toBe(true);
|
||||
const send = (message: object) =>
|
||||
win.__PASEO_TERMINAL_WEBVIEW_RECEIVE__({ streamKey: "owned", ...message });
|
||||
send({
|
||||
type: "mount",
|
||||
initialSnapshot: null,
|
||||
scrollbackLines: 1000,
|
||||
theme: { background: "#ffffff", foreground: "#111111" },
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
swipeGesturesEnabled: false,
|
||||
});
|
||||
await expect
|
||||
.poll(() => messages.some((m) => m.type === "rendererReady" && m.streamKey === "owned"))
|
||||
.toBe(true);
|
||||
send({ type: "writeOutput", text: "first a.b\r\n" + "padding\r\n".repeat(30) + "last A.B" });
|
||||
await expect
|
||||
.poll(() => {
|
||||
const b = win.__paseoTerminal?.buffer.active;
|
||||
return b
|
||||
? Array.from({ length: b.length }, (_, i) => b.getLine(i)?.translateToString(true)).join(
|
||||
"\n",
|
||||
)
|
||||
: "";
|
||||
})
|
||||
.toContain("last A.B");
|
||||
send({ type: "findWidgetSize", size: { width: 356, height: 58 } });
|
||||
send({ type: "find", query: "a.b" });
|
||||
await expect
|
||||
.poll(() => messages.findLast((m) => m.type === "findResult")?.result)
|
||||
.toMatchObject({ resultCount: 2, resultIndex: 1 });
|
||||
send({ type: "find", query: "a.b", direction: "previous" });
|
||||
await expect
|
||||
.poll(() => messages.findLast((m) => m.type === "findResult")?.result)
|
||||
.toMatchObject({ resultCount: 2, resultIndex: 0 });
|
||||
send({ type: "find", streamKey: "stale", query: "absent" });
|
||||
expect(win.__paseoTerminal!.getSelection()).toBe("a.b");
|
||||
send({ type: "clearFind" });
|
||||
await expect
|
||||
.poll(() => messages.findLast((m) => m.type === "findResult")?.result)
|
||||
.toMatchObject({ resultCount: 0 });
|
||||
expect(messages.filter((m) => m.type === "input")).toEqual([]);
|
||||
send({ type: "unmount" });
|
||||
});
|
||||
Reference in New Issue
Block a user