mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 19:05:16 +00:00
* 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
392 lines
17 KiB
TypeScript
392 lines
17 KiB
TypeScript
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();
|
|
});
|