From d014cfdb2ff7f41500232db9f42d357f83302278 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 9 Sep 2026 19:40:57 +0200 Subject: [PATCH] feat(app): add pane Find to file source --- packages/app/e2e/browser/pane-find.spec.ts | 293 ++++++++++++++++++ .../src/file-pane/editor/extensions.web.ts | 9 +- .../app/src/file-pane/editor/view.web.tsx | 31 +- packages/app/src/file-pane/find/index.web.tsx | 122 ++++++++ packages/app/src/file-pane/find/model.web.ts | 158 ++++++++++ .../app/src/file-pane/source/view.web.tsx | 25 +- packages/app/src/i18n/resources/ar.ts | 15 + packages/app/src/i18n/resources/en.ts | 15 + packages/app/src/i18n/resources/es.ts | 15 + packages/app/src/i18n/resources/fr.ts | 15 + packages/app/src/i18n/resources/ja.ts | 15 + packages/app/src/i18n/resources/ko.ts | 15 + packages/app/src/i18n/resources/pt-BR.ts | 15 + packages/app/src/i18n/resources/ru.ts | 15 + packages/app/src/i18n/resources/zh-CN.ts | 15 + packages/app/src/pane-find/index.tsx | 260 ++++++++++++++++ 16 files changed, 1019 insertions(+), 14 deletions(-) create mode 100644 packages/app/e2e/browser/pane-find.spec.ts create mode 100644 packages/app/src/file-pane/find/index.web.tsx create mode 100644 packages/app/src/file-pane/find/model.web.ts create mode 100644 packages/app/src/pane-find/index.tsx diff --git a/packages/app/e2e/browser/pane-find.spec.ts b/packages/app/e2e/browser/pane-find.spec.ts new file mode 100644 index 0000000000..48d0d58a5c --- /dev/null +++ b/packages/app/e2e/browser/pane-find.spec.ts @@ -0,0 +1,293 @@ +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); +} +async function expectQuerySelected(page: Page, text: string) { + await expect(query(page)).toBeFocused(); + await expect(query(page)).toHaveValue(text); + await expect + .poll(() => + query(page).evaluate((input: HTMLInputElement) => [input.selectionStart, input.selectionEnd]), + ) + .toEqual([0, text.length]); +} +async function closeFind(page: Page) { + await query(page).press("Escape"); + await expect(source(page)).toBeFocused(); +} +async function expectFindAtTopRight(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(); + expect(widget!.x + widget!.width).toBeLessThanOrEqual(pane!.x + pane!.width); + expect(widget!.x).toBeGreaterThanOrEqual(pane!.x); + 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 test.step("reopen from touch action, preserve replace and Undo/save", async () => { + await page.getByRole("button", { name: "Find", exact: true }).click(); + 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("opens Find by touch and keeps controls inside the 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 page.getByRole("button", { name: "Find", exact: true }).tap(); + 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 expectFindAtTopRight(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 page.getByRole("button", { name: "Find", exact: true }).click(); + 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(); + }); +} diff --git a/packages/app/src/file-pane/editor/extensions.web.ts b/packages/app/src/file-pane/editor/extensions.web.ts index 47fc0ab3c5..e9310bd554 100644 --- a/packages/app/src/file-pane/editor/extensions.web.ts +++ b/packages/app/src/file-pane/editor/extensions.web.ts @@ -5,7 +5,6 @@ import { indentOnInput, syntaxHighlighting, } from "@codemirror/language"; -import { searchKeymap } from "@codemirror/search"; import { EditorView, drawSelection, @@ -42,7 +41,6 @@ export function editorBaseExtensions(onSave: () => void) { indentWithTab, ...defaultKeymap, ...historyKeymap, - ...searchKeymap, ]), ]; } @@ -63,6 +61,13 @@ export function editorTheme(theme: EditorVisualTheme) { fontFamily: theme.monoFont, lineHeight: "1.45", }, + ".cm-panels": { backgroundColor: theme.background, color: theme.foreground }, + ".cm-panels-top": { borderBottom: "none" }, + ".cm-searchMatch": { backgroundColor: theme.selection }, + ".cm-searchMatch-selected": { + backgroundColor: theme.selection, + outline: `1px solid ${theme.cursor}`, + }, ".cm-content": { caretColor: theme.foreground, padding: "16px 0" }, ".cm-cursor, .cm-dropCursor": { borderLeftColor: theme.cursor }, ".cm-gutters": { diff --git a/packages/app/src/file-pane/editor/view.web.tsx b/packages/app/src/file-pane/editor/view.web.tsx index 03ab27a778..d396697c41 100644 --- a/packages/app/src/file-pane/editor/view.web.tsx +++ b/packages/app/src/file-pane/editor/view.web.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef, useSyncExternalStore } from "react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { FileFind, FileFindModel } from "../find/index.web"; import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { getLanguageForFile } from "@getpaseo/highlight"; @@ -38,6 +39,7 @@ export function FileEditorView({ onCursorChange, onVimModeChange, }: FileEditorViewProps) { + const [find] = useState(() => new FileFindModel()); const hostRef = useRef(null); const viewRef = useRef(null); const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); @@ -54,6 +56,7 @@ export function FileEditorView({ doc: values.content, extensions: [ vimCompartment.of(values.vimEnabled ? vim() : []), + find.extension, ...editorBaseExtensions(() => void values.model.save()), languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []), wrappingCompartment.of(wrappingForFile(values.filename)), @@ -81,7 +84,7 @@ export function FileEditorView({ view.destroy(); viewRef.current = null; }; - }, []); + }, [find]); useEffect(() => { const view = viewRef.current; @@ -141,15 +144,25 @@ export function FileEditorView({ }, [onVimModeChange, vimEnabled]); return ( -
+
+
+ +
); } const remoteUpdate = Annotation.define(); +const FRAME_STYLE = { + display: "flex", + position: "relative", + flex: 1, + minHeight: 0, + minWidth: 0, +} as const; const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const; diff --git a/packages/app/src/file-pane/find/index.web.tsx b/packages/app/src/file-pane/find/index.web.tsx new file mode 100644 index 0000000000..70801aa0ca --- /dev/null +++ b/packages/app/src/file-pane/find/index.web.tsx @@ -0,0 +1,122 @@ +import { + useCallback, + useMemo, + useEffect, + useRef, + useSyncExternalStore, + type RefObject, +} from "react"; +import { createPortal } from "react-dom"; +import { EditorView } from "@codemirror/view"; +import { Search } from "lucide-react-native"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { PaneFind, type PaneFindHandle } from "@/pane-find"; +import { usePaneFocus } from "@/panels/pane-context"; +import { hasActiveWebOverlay } from "@/lib/overlay-root"; +import { useRetainedPanelActive } from "@/components/retained-panel"; +import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; +import { FileFindModel } from "./model.web"; + +export { FileFindModel } from "./model.web"; + +export function FileFind({ + model, + editor, +}: { + model: FileFindModel; + editor: RefObject; +}) { + const { t } = useTranslation(); + const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); + const widget = useRef(null); + const { isInteractive, focusPane } = usePaneFocus(); + const active = useRetainedPanelActive(); + const open = useCallback(() => { + focusPane(); + model.open(editor.current); + widget.current?.focus(); + }, [editor, focusPane, model]); + + useEffect(() => { + if (!isInteractive || !active) return; + function onKeyDown(event: KeyboardEvent) { + if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event)) + return; + if ( + (event.metaKey || event.ctrlKey) && + !event.altKey && + !event.shiftKey && + event.key.toLowerCase() === "f" + ) { + event.preventDefault(); + model.open(editor.current); + widget.current?.focus(); + } + } + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [active, editor, isInteractive, model]); + + const replace = useMemo( + () => + state.readOnly + ? undefined + : { + value: state.replacement, + onChange: model.setReplacement, + onReplace: model.replace, + onReplaceAll: model.replaceAll, + }, + [model, state.readOnly, state.replacement], + ); + if (!state.panel) + return ( + + + + + + ) : null} + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + widget: { + width: 480, + maxWidth: "100%", + padding: theme.spacing[2], + gap: theme.spacing[1], + backgroundColor: theme.colors.surface1, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.md, + }, + findRow: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: theme.spacing[1] }, + queryRow: { + flex: 1, + minWidth: 140, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + row: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1] }, + replacement: { gap: theme.spacing[1] }, + input: { + flex: 1, + minWidth: 0, + minHeight: CONTROL_HEIGHTS.compact, + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + fontSize: theme.fontSize.base, + color: theme.colors.foreground, + backgroundColor: theme.colors.surface0, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.sm, + }, + status: { + flex: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + paddingHorizontal: theme.spacing[2], + }, +}));