mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Find text in terminal scrollback (#4650)
* feat(app): add pane Find to file source * fix(app): float the pane Find widget and tighten its chrome Find pushed the first lines of the file down, ran 480px wide with 32px ghost buttons, and let the replacement input start left of the query input. Float the CodeMirror top panel out of flow so the widget overlays the top right of the pane, move the disclosure toggle into a gutter that spans both rows so the two fields share a leading rail, put the match count inside the query field, and drop the icon controls to the shared pane-toolbar chrome the file bar above already uses. * fix(app): keep the active match visible and drop the Find button The floating widget covered the active match whenever the match sat under it and the document was too short to scroll clear. CodeMirror's own panel scroll margin cannot reveal a first line, so the file integration now owns placement: the widget renders as an overlay beside the editor, the model measures it against the active match, and flips it to the opposite corner only when the current corner collides. Hysteresis keeps navigation from bouncing the widget between corners. Also removes the standalone Find button shown while Find is closed, per user direction. Cmd/Ctrl+F is the entry point. * fix(app): stop the Find placeholder from hiding Go to line Hiding `.cm-panels` suppressed every CodeMirror panel in the file editor, including the Go to line dialog that Mod-Alt-g opens through the same search keymap. Hide only the inert Find placeholder panel instead, by its own class, and leave the shared panel container alone. * feat(app): find text in terminal scrollback * test(app): account for terminal write completion before barriers * fix(terminal): only Cmd+F opens Find on macOS; Ctrl+F reaches the shell * test(app): exercise terminal find shortcuts against real ptys * test(app): wait for visible vim lines before terminal page down * test(app): account for vim page overlap in terminal shortcut journey
This commit is contained in:
@@ -26,6 +26,7 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
|
||||
- **Snapshot catch-up is backpressure-gated.** A stream falls back to a full snapshot only when `outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES` (256KB) **and** the client transport reports `bufferedAmount > MAX_CLIENT_BUFFERED_BYTES` (4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches.
|
||||
- **Plugin daemon sessions report IPC queue bytes.** Their virtual socket increments `bufferedAmount` before `process.send` and decrements it only in the send callback. Text and binary frames share that ordered queue, so the normal snapshot catch-up and physical high-water gates remain valid for server-side plugin SDK traffic.
|
||||
- **Client output writes are not serialized per frame.** The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (`clear`, `snapshot`, `suppressInput` writes) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output.
|
||||
- **Find preserves the inspected xterm viewport, including at the live bottom.** Keep its output anchor inside the existing batched write path. Serializing output to preserve a search result would add terminal latency.
|
||||
- **Retained terminal tabs in the focused workspace keep their streams.** Hidden mounted terminals continue applying output, so switching tabs does not resubscribe or request a fresh snapshot. The retained-panel LRU bounds the number of live streams; terminals in an unfocused workspace detach.
|
||||
- **Terminal size has one daemon-owned claimant.** Focus and direct interaction send a `claim`; later geometry changes from that connection send `update`. A claim transfers ownership even when the dimensions are unchanged, while an update from any other connection is ignored. This lets an owning pane follow splits and keyboard insets without allowing an idle phone or browser to steal the PTY size.
|
||||
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { spawnSync } from "node:child_process";
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
async function openControlCharacterTerminal(page: Page) {
|
||||
await test.step("Run cat -v in a real bash terminal", async () => {
|
||||
const terminal = await harness.createTerminal({
|
||||
name: "Control characters",
|
||||
command: "bash",
|
||||
// Disable the tty's echo so ^F proves cat received the byte. Noncanonical
|
||||
// input lets cat print it immediately, without waiting for Enter.
|
||||
args: ["--noprofile", "--norc", "-c", "stty -echo -icanon; printf 'CAT_READY\\n'; cat -v"],
|
||||
});
|
||||
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain("CAT_READY");
|
||||
});
|
||||
}
|
||||
|
||||
async function pressTerminalShortcut(page: Page, shortcut: string) {
|
||||
await test.step(`Press ${shortcut} in the terminal`, async () => {
|
||||
await input(page).press(shortcut);
|
||||
});
|
||||
}
|
||||
|
||||
async function expectFindClosed(page: Page) {
|
||||
await expect(query(page)).toBeHidden();
|
||||
await expect(input(page)).toBeFocused();
|
||||
}
|
||||
|
||||
async function recordTerminalEvidence(name: string, body: string) {
|
||||
const evidencePath = test.info().outputPath(`${name}.txt`);
|
||||
await writeFile(evidencePath, body);
|
||||
await test.info().attach(name, { path: evidencePath, contentType: "text/plain" });
|
||||
}
|
||||
|
||||
async function expectCatControlF(page: Page) {
|
||||
await test.step("Cat displays ^F while Find stays closed", async () => {
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain("^F");
|
||||
await expectFindClosed(page);
|
||||
await recordTerminalEvidence("cat-control-f", await getTerminalBufferText(page));
|
||||
});
|
||||
}
|
||||
|
||||
async function expectVimPageDown(page: Page, before: { topLine: number; lastLine: number }) {
|
||||
await test.step("Vim pages down while Find stays closed", async () => {
|
||||
try {
|
||||
// Vim's Ctrl+F keeps two lines of overlap, so the new page starts before the old page ends.
|
||||
await expect.poll(() => visibleTopLineNumber(page)).toBeGreaterThan(before.topLine);
|
||||
await expect.poll(() => visibleTopLineNumber(page)).toBeGreaterThanOrEqual(40);
|
||||
await expectFindClosed(page);
|
||||
} finally {
|
||||
await recordTerminalEvidence("vim-after-control-f", await getTerminalBufferText(page));
|
||||
await recordTerminalEvidence(
|
||||
"vim-page-down",
|
||||
JSON.stringify({ before, after: { topLine: await visibleTopLineNumber(page) } }),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function expectCatRoundTrip(page: Page) {
|
||||
await test.step("Wait for cat to echo subsequent input from the pty", async () => {
|
||||
await input(page).pressSequentially("PTY_ROUND_TRIP");
|
||||
await input(page).press("Enter");
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain("PTY_ROUND_TRIP");
|
||||
});
|
||||
}
|
||||
|
||||
async function openNumberedFileInVim(page: Page) {
|
||||
return test.step("Open 300 numbered lines in vim without user configuration", async () => {
|
||||
const terminal = await harness.createTerminal({
|
||||
name: "Vim page down",
|
||||
command: "bash",
|
||||
args: [
|
||||
"--noprofile",
|
||||
"--norc",
|
||||
"-c",
|
||||
"seq 1 300 > numbered-lines.txt && vim -u NONE -i NONE numbered-lines.txt; printf 'VIM_EXITED\\n'; cat",
|
||||
],
|
||||
});
|
||||
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||
await expectVimFirstPage(page);
|
||||
const screen = await readVimScreen(page);
|
||||
await recordTerminalEvidence("vim-before-control-f", screen.text);
|
||||
return { topLine: screen.numberedLines[0], lastLine: screen.numberedLines.at(-1)! };
|
||||
});
|
||||
}
|
||||
|
||||
async function readVimScreen(page: Page) {
|
||||
const rows = await page.evaluate(
|
||||
() => (window as Window & { __paseoTerminal: { rows: number } }).__paseoTerminal.rows,
|
||||
);
|
||||
const text = await getTerminalBufferText(page);
|
||||
const numberedLines = text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => /^\d+$/.test(line))
|
||||
.map(Number);
|
||||
return { rows, text, numberedLines };
|
||||
}
|
||||
|
||||
async function expectVimFirstPage(page: Page) {
|
||||
await expect(async () => {
|
||||
const screen = await readVimScreen(page);
|
||||
// The captured screen has one numbered file line per row, except Vim's
|
||||
// bottom command row. Derive the last visible line from the fitted terminal.
|
||||
expect(screen.numberedLines).toEqual(Array.from({ length: screen.rows - 1 }, (_, i) => i + 1));
|
||||
expect(screen.text).not.toContain("300L");
|
||||
}).toPass({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function visibleTopLineNumber(page: Page) {
|
||||
// Vim uses the alternate screen, so its active buffer starts at the visible top row.
|
||||
return Number((await getTerminalBufferText(page)).split("\n")[0].trim());
|
||||
}
|
||||
|
||||
async function quitVim(page: Page) {
|
||||
await test.step("Leave vim with Escape and :q!", async () => {
|
||||
await input(page).press("Escape");
|
||||
await input(page).pressSequentially(":q!");
|
||||
await input(page).press("Enter");
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain("VIM_EXITED");
|
||||
});
|
||||
}
|
||||
|
||||
let harness: TerminalE2EHarness;
|
||||
test.beforeEach(async () => {
|
||||
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-find-" });
|
||||
});
|
||||
test.afterEach(async () => {
|
||||
await harness.cleanup();
|
||||
});
|
||||
|
||||
test.describe("macOS terminal shortcuts", () => {
|
||||
test.use({
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
});
|
||||
|
||||
test("sends Control+f to cat and opens Find with Meta+f", async ({ page }) => {
|
||||
await openControlCharacterTerminal(page);
|
||||
await pressTerminalShortcut(page, "Control+f");
|
||||
await expectCatControlF(page);
|
||||
await pressTerminalShortcut(page, "Meta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
});
|
||||
|
||||
test("sends Control+f to vim to page down", async ({ page }) => {
|
||||
const vim = spawnSync("which", ["vim"], { encoding: "utf8" });
|
||||
await recordTerminalEvidence("which-vim", `status: ${vim.status}\n${vim.stdout}${vim.stderr}`);
|
||||
test.skip(vim.status !== 0, "vim is not installed on this runner (which vim failed)");
|
||||
const before = await openNumberedFileInVim(page);
|
||||
await pressTerminalShortcut(page, "Control+f");
|
||||
await expectVimPageDown(page, before);
|
||||
await quitVim(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("opens Find with Control+f on Linux without sending ^F to cat", async ({ page }) => {
|
||||
await openControlCharacterTerminal(page);
|
||||
await pressTerminalShortcut(page, "Control+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await test.step("Close Find and return focus to the terminal", async () => {
|
||||
await query(page).press("Escape");
|
||||
await expectFindClosed(page);
|
||||
});
|
||||
await expectCatRoundTrip(page);
|
||||
await test.step("Cat received subsequent input without any ^F", async () => {
|
||||
const output = await getTerminalBufferText(page);
|
||||
expect(output).not.toContain("^F");
|
||||
await recordTerminalEvidence("cat-without-control-f", output);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -1042,6 +1051,8 @@ export function TerminalPane({
|
||||
onRendererReadyChange={handleRendererReadyChange}
|
||||
onSwipeRight={handleSwipeRight}
|
||||
onSwipeLeft={handleSwipeLeft}
|
||||
onFindRequest={handleFindRequest}
|
||||
onFindResult={handleFindResult}
|
||||
onInput={handleTerminalData}
|
||||
onFocus={handleTerminalFocus}
|
||||
onResize={handleTerminalResize}
|
||||
@@ -1057,6 +1068,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,140 @@
|
||||
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 { isFindShortcut } from "../runtime/terminal-find-shortcut";
|
||||
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 (isFindShortcut(event) && 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 },
|
||||
}));
|
||||
@@ -192,6 +192,63 @@ describe("terminal-emulator-runtime", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ isMac: true, ctrlKey: true, metaKey: false, opensFind: false },
|
||||
{ isMac: true, ctrlKey: false, metaKey: true, opensFind: true },
|
||||
{ isMac: false, ctrlKey: true, metaKey: false, opensFind: true },
|
||||
{ isMac: false, ctrlKey: false, metaKey: true, opensFind: false },
|
||||
{ isMac: true, ctrlKey: false, metaKey: true, shiftKey: true, opensFind: false },
|
||||
{ isMac: true, ctrlKey: false, metaKey: true, altKey: true, opensFind: false },
|
||||
{ isMac: false, ctrlKey: true, metaKey: false, shiftKey: true, opensFind: false },
|
||||
{ isMac: false, ctrlKey: true, metaKey: false, altKey: true, opensFind: false },
|
||||
{ isMac: true, ctrlKey: true, metaKey: true, opensFind: false },
|
||||
{ isMac: false, ctrlKey: true, metaKey: true, opensFind: false },
|
||||
])(
|
||||
"routes Find with isMac=$isMac ctrl=$ctrlKey meta=$metaKey shift=$shiftKey alt=$altKey",
|
||||
({ isMac, ctrlKey, metaKey, shiftKey = false, altKey = false, opensFind }) => {
|
||||
const runtime = new TerminalEmulatorRuntime({ isMac });
|
||||
let findRequests = 0;
|
||||
let prevented = false;
|
||||
let stopped = false;
|
||||
runtime.setCallbacks({
|
||||
callbacks: {
|
||||
onFindRequest: () => {
|
||||
findRequests += 1;
|
||||
},
|
||||
},
|
||||
});
|
||||
const event = {
|
||||
type: "keydown",
|
||||
key: "f",
|
||||
ctrlKey,
|
||||
metaKey,
|
||||
shiftKey,
|
||||
altKey,
|
||||
isComposing: false,
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
stopPropagation: () => {
|
||||
stopped = true;
|
||||
},
|
||||
} as KeyboardEvent;
|
||||
|
||||
let passedToXterm: boolean | undefined;
|
||||
runtime.attachKeyEventHandler({
|
||||
attachCustomKeyEventHandler: (handler) => {
|
||||
passedToXterm = handler(event);
|
||||
},
|
||||
hasSelection: () => false,
|
||||
getSelection: () => "",
|
||||
paste: () => {},
|
||||
});
|
||||
expect(passedToXterm).toBe(!opensFind);
|
||||
expect(findRequests).toBe(opensFind ? 1 : 0);
|
||||
expect(prevented).toBe(opensFind);
|
||||
expect(stopped).toBe(opensFind);
|
||||
},
|
||||
);
|
||||
|
||||
it("drains contiguous plain writes without waiting for each commit, gating a clear behind them", () => {
|
||||
const { runtime, terminal, writeCallbacks, writeTexts } = createRuntimeWithTerminal();
|
||||
const committed: string[] = [];
|
||||
@@ -302,9 +359,10 @@ describe("terminal-emulator-runtime", () => {
|
||||
{ kittyKeyboardFlags: 7, win32InputMode: false, bracketedPaste: false },
|
||||
]);
|
||||
|
||||
// The plain write carries no onCommitted, so it registers no callback; writeCallbacks[0]
|
||||
// is the barrier gate sentinel.
|
||||
writeCallbacks[0]?.();
|
||||
// Commit the plain write, then the sentinel that gates the snapshot.
|
||||
writeCallbacks[0]();
|
||||
expect(inputModeChanges).toHaveLength(1);
|
||||
writeCallbacks[1]();
|
||||
|
||||
expect(inputModeChanges).toEqual([
|
||||
{ kittyKeyboardFlags: 7, win32InputMode: false, bracketedPaste: false },
|
||||
@@ -413,14 +471,15 @@ describe("terminal-emulator-runtime", () => {
|
||||
runtime.write({ data: terminalOutput("output") });
|
||||
runtime.restoreOutput({ data: terminalOutput("snapshot") });
|
||||
|
||||
// The plain write carries no onCommitted so it registers no callback; writeCallbacks[0]
|
||||
// is the sentinel gate. suppressInput only flips once the gate resolves the barrier.
|
||||
// The plain write commits first. Only the following sentinel opens the barrier.
|
||||
expect(readSuppressInput()).toBe(false);
|
||||
writeCallbacks[0]?.();
|
||||
writeCallbacks[0]();
|
||||
expect(readSuppressInput()).toBe(false);
|
||||
writeCallbacks[1]();
|
||||
expect(readSuppressInput()).toBe(true);
|
||||
|
||||
// writeCallbacks[1] is the barrier's own snapshot write; committing it restores input.
|
||||
writeCallbacks[1]?.();
|
||||
// Committing the barrier's snapshot write restores input.
|
||||
writeCallbacks[2]();
|
||||
expect(readSuppressInput()).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -28,10 +28,26 @@ import {
|
||||
type TerminalLocalFileLinkSource,
|
||||
type TerminalLocalFileLinkTarget,
|
||||
} from "../local-links/terminal-local-link-provider";
|
||||
import { isMac, isFindShortcut } from "./terminal-find-shortcut";
|
||||
import { resolveTerminalFontFamily, resolveTerminalFontSize } from "./terminal-font";
|
||||
|
||||
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 +59,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: {
|
||||
@@ -123,11 +141,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const isMac =
|
||||
typeof navigator !== "undefined" &&
|
||||
(/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") ||
|
||||
/Mac/i.test((navigator as Navigator & { platform?: string }).platform ?? ""));
|
||||
|
||||
const isAppleHandheld =
|
||||
typeof navigator !== "undefined" &&
|
||||
isAppleHandheldPlatform({
|
||||
@@ -165,6 +178,8 @@ function withOverviewRulerBorderHidden(theme: ITheme): ITheme {
|
||||
}
|
||||
|
||||
export class TerminalEmulatorRuntime {
|
||||
constructor(private readonly options: { isMac: boolean } = { isMac }) {}
|
||||
|
||||
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
|
||||
private pendingModifiers: PendingTerminalModifiers = {
|
||||
ctrl: false,
|
||||
@@ -173,6 +188,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 +272,155 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
attachKeyEventHandler(
|
||||
terminal: Pick<
|
||||
Terminal,
|
||||
"attachCustomKeyEventHandler" | "hasSelection" | "getSelection" | "paste"
|
||||
>,
|
||||
): void {
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
if (event.type !== "keydown" || event.isComposing) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.handleFindShortcut(event)) return false;
|
||||
|
||||
if (
|
||||
!this.options.isMac &&
|
||||
event.ctrlKey &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!event.metaKey
|
||||
) {
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
|
||||
if (key === "c" && terminal.hasSelection()) {
|
||||
void navigator.clipboard.writeText(terminal.getSelection());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+V: paste from clipboard into terminal
|
||||
if (key === "v") {
|
||||
event.preventDefault();
|
||||
void navigator.clipboard.readText().then((text) => {
|
||||
if (text) {
|
||||
terminal.paste(text);
|
||||
}
|
||||
return;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedKey = normalizeDomTerminalKey(event.key);
|
||||
if (!normalizedKey || isTerminalModifierDomKey(event.key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
!shouldInterceptDomTerminalKey({
|
||||
key: normalizedKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey,
|
||||
metaKey: event.metaKey,
|
||||
pendingModifiers: this.pendingModifiers,
|
||||
enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(),
|
||||
isAppleHandheld,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const modifiers = mergeTerminalModifiers({
|
||||
pendingModifiers: this.pendingModifiers,
|
||||
ctrlKey: event.ctrlKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey,
|
||||
metaKey: event.metaKey,
|
||||
});
|
||||
this.callbacks.onTerminalKey?.({
|
||||
key: normalizeTerminalTransportKey(normalizedKey),
|
||||
...modifiers,
|
||||
});
|
||||
|
||||
if (this.pendingModifiers.ctrl || this.pendingModifiers.shift || this.pendingModifiers.alt) {
|
||||
this.callbacks.onPendingModifiersConsumed?.();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private handleFindShortcut(event: KeyboardEvent): boolean {
|
||||
if (!isFindShortcut(event, this.options) || !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 +473,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());
|
||||
@@ -413,75 +631,7 @@ export class TerminalEmulatorRuntime {
|
||||
this.callbacks.onInput?.(data);
|
||||
});
|
||||
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
if (event.type !== "keydown" || event.isComposing) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isMac && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) {
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
|
||||
if (key === "c" && terminal.hasSelection()) {
|
||||
void navigator.clipboard.writeText(terminal.getSelection());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+V: paste from clipboard into terminal
|
||||
if (key === "v") {
|
||||
event.preventDefault();
|
||||
void navigator.clipboard.readText().then((text) => {
|
||||
if (text) {
|
||||
terminal.paste(text);
|
||||
}
|
||||
return;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedKey = normalizeDomTerminalKey(event.key);
|
||||
if (!normalizedKey || isTerminalModifierDomKey(event.key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
!shouldInterceptDomTerminalKey({
|
||||
key: normalizedKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey,
|
||||
metaKey: event.metaKey,
|
||||
pendingModifiers: this.pendingModifiers,
|
||||
enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(),
|
||||
isAppleHandheld,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const modifiers = mergeTerminalModifiers({
|
||||
pendingModifiers: this.pendingModifiers,
|
||||
ctrlKey: event.ctrlKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey,
|
||||
metaKey: event.metaKey,
|
||||
});
|
||||
this.callbacks.onTerminalKey?.({
|
||||
key: normalizeTerminalTransportKey(normalizedKey),
|
||||
...modifiers,
|
||||
});
|
||||
|
||||
if (this.pendingModifiers.ctrl || this.pendingModifiers.shift || this.pendingModifiers.alt) {
|
||||
this.callbacks.onPendingModifiersConsumed?.();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
});
|
||||
this.attachKeyEventHandler(terminal);
|
||||
|
||||
const removeTouchListeners = this.setupTouchScrollHandlers({
|
||||
root: input.root,
|
||||
@@ -775,6 +925,7 @@ export class TerminalEmulatorRuntime {
|
||||
}
|
||||
|
||||
unmount(): void {
|
||||
this.releaseFindOutputAnchor();
|
||||
this.clearInFlightOutputTimeout();
|
||||
const inFlightOperation = this.inFlightOutputOperation;
|
||||
this.inFlightOutputOperation = null;
|
||||
@@ -796,6 +947,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 +1024,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 +1034,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,14 @@
|
||||
export const isMac =
|
||||
typeof navigator !== "undefined" &&
|
||||
(/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") ||
|
||||
/Mac/i.test((navigator as Navigator & { platform?: string }).platform ?? ""));
|
||||
|
||||
export function isFindShortcut(
|
||||
event: Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "shiftKey" | "altKey">,
|
||||
platform = { isMac },
|
||||
): boolean {
|
||||
const primaryModifier = platform.isMac
|
||||
? event.metaKey && !event.ctrlKey
|
||||
: event.ctrlKey && !event.metaKey;
|
||||
return primaryModifier && !event.shiftKey && !event.altKey && event.key.toLowerCase() === "f";
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
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(options?: { isMac: boolean }) {
|
||||
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(options);
|
||||
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");
|
||||
});
|
||||
|
||||
test.each([true, false])("routes Find and shell input through xterm with isMac=%s", (isMac) => {
|
||||
mount({ isMac });
|
||||
const input: string[] = [];
|
||||
let findRequests = 0;
|
||||
runtime.setCallbacks({
|
||||
callbacks: {
|
||||
onInput: (data) => {
|
||||
input.push(data);
|
||||
},
|
||||
onFindRequest: () => {
|
||||
findRequests += 1;
|
||||
},
|
||||
},
|
||||
});
|
||||
const textarea = root.querySelector("textarea")!;
|
||||
textarea.focus();
|
||||
const ctrlF = new KeyboardEvent("keydown", {
|
||||
key: "f",
|
||||
code: "KeyF",
|
||||
keyCode: 70,
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
textarea.dispatchEvent(ctrlF);
|
||||
expect(findRequests).toBe(isMac ? 0 : 1);
|
||||
expect(input).toEqual(isMac ? ["\x06"] : []);
|
||||
const metaF = new KeyboardEvent("keydown", {
|
||||
key: "f",
|
||||
code: "KeyF",
|
||||
keyCode: 70,
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
textarea.dispatchEvent(metaF);
|
||||
expect(findRequests).toBe(1);
|
||||
expect(metaF.defaultPrevented).toBe(isMac);
|
||||
});
|
||||
@@ -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