Files
paseo/packages/app/e2e/browser/pane-find.spec.ts
Mohamed Boudra 2c8e8a8268 fix(app): use Command+F for Find on macOS across chat, file, and terminal (#5129)
Control+F moves the caret forward on macOS, so chat Find and file pane Find
were stealing a keystroke the text editor owns. Chat Find listens on
`document` in the capture phase, so the composer received an already-cancelled
Control+F and the caret never moved.

Each Find surface carried its own copy of the shortcut predicate and only the
terminal's was platform-aware. There is now one predicate, `isFindShortcut` in
`@/pane-find`, that takes the platform as a parameter: Command without Control
on macOS, Control without Command elsewhere, no Shift or Alt. Chat Find, file
pane Find, the Find widget's own query box, terminal Find, and the terminal
emulator runtime all call it, and `terminal-find-shortcut.ts` is gone.

App surfaces source the platform from `findShortcutPlatform()`, which wraps the
app's existing `getShortcutOs()`. The predicate stays free of React Native
imports because the terminal emulator runtime is bundled on its own into the
terminal WebView; its default platform and `getShortcutOs()`'s browser branch
now share one user-agent read in `@/utils/mac-user-agent`.

Closes #5097
2026-09-21 12:34:41 +02:00

476 lines
20 KiB
TypeScript

import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
function source(page: Page) {
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
}
function query(page: Page) {
return page.getByRole("textbox", { name: "Find in pane", exact: true });
}
function status(page: Page) {
return page.getByRole("status", { name: "Find matches" });
}
async function openSource(page: Page, filename: string) {
await openFileExplorer(page);
await openFileFromExplorer(page, filename);
await expect(source(page)).toBeVisible();
}
async function findInSource(page: Page, text: string) {
await source(page).focus();
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill(text);
}
function querySelection(page: Page) {
return query(page).evaluate((input: HTMLInputElement) => [
input.selectionStart,
input.selectionEnd,
]);
}
/** The query box keeps the caret where the user left it, with nothing reselected. */
async function expectQueryCaretKept(page: Page, text: string) {
await expect.poll(() => querySelection(page)).toEqual([text.length, text.length]);
await expect(query(page)).toHaveValue(text);
}
async function expectQuerySelected(page: Page, text: string) {
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue(text);
await expect.poll(() => querySelection(page)).toEqual([0, text.length]);
}
async function closeFind(page: Page) {
await query(page).press("Escape");
await expect(source(page)).toBeFocused();
}
async function expectActiveMatchUncovered(page: Page) {
const match = page.locator(".cm-searchMatch-selected").filter({ visible: true }).first();
await expect(match).toBeVisible();
const widget = page.getByLabel("Find", { exact: true }).filter({ visible: true });
await expect
.poll(async () => {
const m = await match.boundingBox();
const w = await widget.boundingBox();
if (!m || !w) return "missing";
const covered =
m.x < w.x + w.width && m.x + m.width > w.x && m.y < w.y + w.height && m.y + m.height > w.y;
return covered ? "covered by Find" : "visible";
})
.toBe("visible");
}
async function findBoxes(page: Page) {
const pane = (await page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.boundingBox())!;
const widget = (await page
.getByLabel("Find", { exact: true })
.filter({ visible: true })
.boundingBox())!;
return { pane, widget };
}
async function expectFindInsidePane(page: Page) {
const { pane, widget } = await findBoxes(page);
expect(widget.x).toBeGreaterThanOrEqual(pane.x);
expect(widget.x + widget.width).toBeLessThanOrEqual(pane.x + pane.width);
expect(widget.y).toBeGreaterThanOrEqual(pane.y);
expect(widget.y + widget.height).toBeLessThanOrEqual(pane.y + pane.height + 1);
}
async function expectFindAtTopRight(page: Page) {
await expectFindInsidePane(page);
const { pane, widget } = await findBoxes(page);
expect(widget.y).toBeLessThan(pane.y + 70);
}
test("finds literal text at the top of editable source and returns focus at the inspected match", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-" });
const file = path.join(workspace.repoPath, "source.txt");
await writeFile(file, "first a.b\nsecond axb\nthird a.b\n");
await workspace.navigateTo();
await openSource(page, "source.txt");
await findInSource(page, "a.b");
await expect(status(page)).toHaveText("1 of 2");
await query(page).fill("A.B");
await expect(status(page)).toHaveText("1 of 2");
await query(page).fill("a.b");
await expectFindAtTopRight(page);
await query(page).press("Enter");
await expect(status(page)).toHaveText("2 of 2");
await query(page).press("Shift+Enter");
await expect(status(page)).toHaveText("1 of 2");
await page.screenshot({ path: testInfo.outputPath("editable-find.png") });
await closeFind(page);
await expect(query(page)).toBeHidden();
await expect(page.getByLabel("Line 1, column 10")).toBeVisible();
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
await test.step("reopen with the shortcut, preserve replace and Undo/save", async () => {
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("literal");
await page.getByRole("button", { name: "Replace", exact: true }).click();
await expect(source(page)).toContainText("first literal");
await expect(status(page)).toHaveText("1 of 1");
await page.screenshot({ path: testInfo.outputPath("replace.png") });
await closeFind(page);
await source(page).press("ControlOrMeta+z");
await expect(source(page)).toContainText("first a.b");
await source(page).press("ControlOrMeta+s");
await expect.poll(() => readFile(file, "utf8")).toBe("first a.b\nsecond axb\nthird a.b\n");
await findInSource(page, "a.b");
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("all");
await page.getByRole("button", { name: "Replace all", exact: true }).click();
await expect(status(page)).toHaveText("No matches");
await closeFind(page);
await source(page).press("ControlOrMeta+s");
await expect.poll(() => readFile(file, "utf8")).toBe("first all\nsecond axb\nthird all\n");
});
});
test("searches the unsaved buffer while disconnected", async ({ page, withWorkspace }) => {
const gate = await installDaemonWebSocketGate(page);
const workspace = await withWorkspace({ prefix: "pane-find-draft-" });
const file = path.join(workspace.repoPath, "draft.txt");
await writeFile(file, "saved on disk\n");
await workspace.navigateTo();
await openSource(page, "draft.txt");
await gate.drop();
await source(page).fill("unsaved needle\nsecond needle\n");
await findInSource(page, "needle");
await expect(status(page)).toHaveText("1 of 2");
expect(await readFile(file, "utf8")).toBe("saved on disk\n");
gate.restore();
});
test("searches read-only source beyond the viewport without replace controls", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-readonly-" });
await writeFile(
path.join(workspace.repoPath, "large.txt"),
"needle first\n" + "plain source line\n".repeat(70_000) + "needle last\n",
);
await workspace.navigateTo();
await openSource(page, "large.txt");
await findInSource(page, "needle");
await expect(source(page)).toHaveAttribute("contenteditable", "false");
await expect(status(page)).toHaveText("1 of 2");
await expect(page.getByRole("button", { name: "Toggle replace" })).toHaveCount(0);
await query(page).press("Enter");
await expect(status(page)).toHaveText("2 of 2");
await expect(source(page)).toContainText("needle last");
await page.screenshot({ path: testInfo.outputPath("readonly-find.png") });
await closeFind(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("plain");
await expect(status(page)).toHaveText("1 of 10000+");
});
test("targets the focused source in split panes and refocuses an open query", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-split-" });
await writeFile(path.join(workspace.repoPath, "left.txt"), "left needle\n");
await writeFile(path.join(workspace.repoPath, "right.txt"), "right needle\nright needle\n");
await workspace.navigateTo();
await openSource(page, "left.txt");
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await openFileFromExplorer(page, "right.txt");
const left = source(page).filter({ hasText: "left needle" });
const right = source(page).filter({ hasText: "right needle" });
await expect(left).toBeVisible();
await expect(right).toBeVisible();
await left.click();
await left.press("ControlOrMeta+f");
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 1");
await query(page).press("Escape");
await right.click();
await right.press("ControlOrMeta+f");
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 2");
await right.click();
await right.press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue("needle");
await page.screenshot({ path: testInfo.outputPath("split-find.png") });
});
test.describe("narrow touch browser", () => {
test.use({ hasTouch: true });
test("keeps Find controls inside a narrow source pane", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-touch-" });
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
await workspace.navigateTo();
await openSource(page, "touch.txt");
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("touch-find-closed.png") });
await source(page).focus();
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("needle");
await page.getByRole("button", { name: "Next match" }).tap();
await expect(status(page)).toHaveText("2 of 2");
await expectFindInsidePane(page);
await page.screenshot({ path: testInfo.outputPath("touch-find.png") });
await page.getByRole("button", { name: "Close Find" }).tap();
await expect(source(page)).toBeFocused();
});
});
async function selectFirstTwoLines(page: Page) {
await source(page).focus();
await source(page).press("ControlOrMeta+Home");
await source(page).press("Shift+ArrowDown");
await source(page).press("Shift+End");
}
async function selectLastWord(page: Page) {
await source(page).focus();
await source(page).press("ControlOrMeta+End");
await source(page).press("ArrowUp");
await source(page).press("Home");
await source(page).press("Shift+End");
}
test("declines multiline selection seeds and keeps replacement aligned with the visible query", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-single-line-" });
const file = path.join(workspace.repoPath, "selection.txt");
await writeFile(file, "alpha\nbeta\nalphabeta\n");
await workspace.navigateTo();
await openSource(page, "selection.txt");
await selectFirstTwoLines(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue("");
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
await query(page).press("Enter");
await expect(query(page)).toHaveValue("");
await closeFind(page);
await selectFirstTwoLines(page);
await source(page).press("ControlOrMeta+g");
await expect(query(page)).toHaveValue("");
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
await closeFind(page);
await selectLastWord(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toHaveValue("alphabeta");
await query(page).press("Enter");
await expect(status(page)).toHaveText("1 of 1");
await closeFind(page);
await expect(page.getByLabel("Line 3, column 10")).toBeVisible();
await selectFirstTwoLines(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toHaveValue("alphabeta");
await query(page).press("Enter");
await expect(status(page)).toHaveText("1 of 1");
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("replaced");
await page.getByRole("button", { name: "Replace", exact: true }).click();
await expect(source(page)).toContainText("alpha");
await expect(source(page)).toContainText("beta");
await expect(status(page)).toHaveText("No matches");
await page.screenshot({ path: testInfo.outputPath("single-line-query-replacement.png") });
await closeFind(page);
await source(page).press("ControlOrMeta+s");
await expect.poll(() => readFile(file, "utf8")).toBe("alpha\nbeta\nreplaced\n");
});
for (const startingField of ["Find in pane", "Replace with"] as const) {
test(`repeated Find from ${startingField} selects the query for new typing`, async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-repeat-input-" });
await writeFile(path.join(workspace.repoPath, "source.txt"), "needle first\nneedle second\n");
await workspace.navigateTo();
await openSource(page, "source.txt");
await findInSource(page, "needle");
await page.getByRole("button", { name: "Toggle replace" }).click();
const replacement = page.getByRole("textbox", { name: "Replace with" });
await replacement.fill("replacement");
await test.step("repeat Find from the input and type a fresh query", async () => {
const startingInput = page.getByRole("textbox", { name: startingField, exact: true });
await startingInput.press("End");
await startingInput.press("ControlOrMeta+f");
await expectQuerySelected(page, "needle");
await page.keyboard.type("first");
await expect(query(page)).toHaveValue("first");
await expect(replacement).toHaveValue("replacement");
await expect(status(page)).toHaveText("1 of 1");
});
await page.screenshot({ path: testInfo.outputPath("repeat-find-input.png") });
await closeFind(page);
await expect(page.getByLabel("Line 1, column 13")).toBeVisible();
});
}
test.describe("the active match stays visible under the floating widget", () => {
test.describe("narrow touch browser", () => {
test.use({ hasTouch: true });
test("reveals a short file's match that the widget would cover", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-cover-touch-" });
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
await workspace.navigateTo();
await openSource(page, "touch.txt");
await page.setViewportSize({ width: 390, height: 844 });
await source(page).focus();
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 2");
await expectActiveMatchUncovered(page);
await page.getByRole("button", { name: "Next match" }).tap();
await expect(status(page)).toHaveText("2 of 2");
await expectActiveMatchUncovered(page);
await page.screenshot({ path: testInfo.outputPath("touch-match-uncovered.png") });
await expect(query(page)).toHaveValue("needle");
await page.getByRole("button", { name: "Close Find" }).tap();
await expect(source(page)).toBeFocused();
});
});
test("reveals a match beneath the top-right widget in a narrow desktop pane", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-cover-split-" });
await writeFile(path.join(workspace.repoPath, "left.txt"), "left filler\n");
await writeFile(path.join(workspace.repoPath, "right.txt"), "needle one\nplain\n");
await workspace.navigateTo();
await openSource(page, "left.txt");
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await openFileFromExplorer(page, "right.txt");
const right = source(page).filter({ hasText: "needle one" });
await expect(right).toBeVisible();
await right.click();
await right.press("ControlOrMeta+f");
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 1");
await expectActiveMatchUncovered(page);
await page.screenshot({ path: testInfo.outputPath("split-match-uncovered.png") });
await test.step("stays visible when Replace expands the widget", async () => {
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("hay");
await expectActiveMatchUncovered(page);
await page.screenshot({ path: testInfo.outputPath("split-replace-uncovered.png") });
});
await test.step("navigation and query focus still work", async () => {
await query(page).press("ControlOrMeta+f");
await expectQuerySelected(page, "needle");
await query(page).press("Enter");
await expect(status(page)).toHaveText("1 of 1");
await expectActiveMatchUncovered(page);
});
await query(page).press("Escape");
await expect(right).toBeFocused();
});
});
test("Go to line keeps its dialog with Find closed and open", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-goto-" });
const lines = Array.from({ length: 40 }, (_, index) => `line ${index + 1}`).join("\n");
await writeFile(path.join(workspace.repoPath, "lines.txt"), `${lines}\n`);
await workspace.navigateTo();
await openSource(page, "lines.txt");
const gotoInput = page.getByRole("textbox", { name: "Go to line" });
await test.step("with Find closed", async () => {
await source(page).focus();
await source(page).press("ControlOrMeta+Alt+g");
await expect(gotoInput).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("goto-line-find-closed.png") });
await gotoInput.fill("12");
await gotoInput.press("Enter");
await expect(page.getByLabel("Line 12, column 1")).toBeVisible();
await expect(gotoInput).toBeHidden();
});
await test.step("with Find open", async () => {
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("line 3");
await source(page).focus();
await source(page).press("ControlOrMeta+Alt+g");
await expect(gotoInput).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("goto-line-find-open.png") });
await gotoInput.fill("30");
await gotoInput.press("Enter");
await expect(page.getByLabel("Line 30, column 1")).toBeVisible();
await expect(query(page)).toHaveValue("line 3");
await expect(status(page)).toHaveText("11 matches");
});
});
test.describe("macOS", () => {
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",
});
// Control+F moves the caret forward on macOS, so Find must only claim Command+F.
test("opens Find with Meta+f and leaves Control+f to text editing", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-mac-" });
await writeFile(path.join(workspace.repoPath, "source.txt"), "needle first\nneedle second\n");
await workspace.navigateTo();
await openSource(page, "source.txt");
await source(page).focus();
await source(page).press("Control+f");
await expect(query(page)).toHaveCount(0);
await source(page).press("Meta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 2");
await test.step("Control+f inside the query box does not reclaim the field", async () => {
await query(page).press("End");
await query(page).press("Control+f");
await expectQueryCaretKept(page, "needle");
});
await page.screenshot({ path: testInfo.outputPath("mac-find.png") });
await query(page).press("Meta+f");
await expectQuerySelected(page, "needle");
await closeFind(page);
});
});