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..45ba51cc2d --- /dev/null +++ b/packages/app/e2e/browser/pane-find.spec.ts @@ -0,0 +1,430 @@ +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 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"); + }); +}); 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..7136542415 --- /dev/null +++ b/packages/app/src/file-pane/find/index.web.tsx @@ -0,0 +1,115 @@ +import { + useCallback, + useMemo, + useEffect, + useRef, + useSyncExternalStore, + type RefObject, +} from "react"; +import { EditorView } from "@codemirror/view"; +import { View, type View as ViewInstance } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +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 } = usePaneFocus(); + const active = useRetainedPanelActive(); + // RN Web hands the underlying DOM element to a View ref; the model measures it. + const setWidgetNode = useCallback( + (node: ViewInstance | null) => model.setWidgetNode(node as unknown as HTMLElement | null), + [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.open) return null; + const total = `${state.total}${state.limited ? "+" : ""}`; + let status = ""; + if (state.query) { + if (state.total === 0) status = t("paneFind.noMatches"); + else if (state.current) status = t("paneFind.position", { current: state.current, total }); + else status = t("paneFind.total", { total }); + } + return ( + + + 0} + onQueryChange={model.setSearch} + onNext={model.next} + onPrevious={model.previous} + onClose={model.close} + replace={replace} + /> + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + // The widget floats over the content; the model flips the corner when it would + // otherwise sit on top of the active match. + overlay: { + position: "absolute", + left: theme.spacing[2], + right: theme.spacing[2], + alignItems: "flex-end", + zIndex: 1, + }, + // Bounds the widget to the pane; PaneFind's own maxWidth resolves against this. + widget: { maxWidth: "100%" }, + overlayTop: { top: theme.spacing[2] }, + overlayBottom: { bottom: theme.spacing[2] }, +})); diff --git a/packages/app/src/file-pane/find/model.web.ts b/packages/app/src/file-pane/find/model.web.ts new file mode 100644 index 0000000000..0ce22500a9 --- /dev/null +++ b/packages/app/src/file-pane/find/model.web.ts @@ -0,0 +1,255 @@ +import { EditorState, type Extension } from "@codemirror/state"; +import { EditorView, keymap, type Panel, type ViewUpdate } from "@codemirror/view"; +import { + closeSearchPanel, + findNext, + findPrevious, + getSearchQuery, + openSearchPanel, + replaceAll, + replaceNext, + search, + searchKeymap, + SearchQuery, + setSearchQuery, +} from "@codemirror/search"; + +const MATCH_STATUS_LIMIT = 10_000; + +/** Which corner of the editor the floating Find widget occupies. */ +export type FindPlacement = "top" | "bottom"; + +/** CodeMirror owns query, matching, selection, replacement, and panel lifetime. */ +export class FileFindModel { + private view: EditorView | null = null; + private listeners = new Set<() => void>(); + private matches: Array<{ from: number; to: number }> = []; + private widget: HTMLElement | null = null; + private widgetResize: ResizeObserver | null = null; + private snapshot = { + open: false, + placement: "top" as FindPlacement, + query: "", + replacement: "", + current: 0, + total: 0, + limited: false, + readOnly: true, + }; + readonly subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + readonly getSnapshot = () => this.snapshot; + + readonly extension: Extension = [ + // The widget floats over the editor as a sibling overlay, so this panel is an + // inert placeholder that only reports whether Find is open. Hide the placeholder + // itself, never the shared panel container — Go to line's dialog lives there too. + EditorView.theme({ ".cm-panel.paseo-file-find": { display: "none" } }), + // Reveal matches clear of the widget instead of underneath it. + EditorView.scrollMargins.of((view) => { + const clearance = this.clearance(view); + if (!clearance) return null; + return this.snapshot.placement === "top" ? { top: clearance } : { bottom: clearance }; + }), + search({ literal: true, top: true, createPanel: (view) => this.createPanel(view) }), + EditorState.transactionExtender.of((transaction) => { + // CodeMirror seeds every search-opening command from the selection. A + // single-line field cannot represent CR/LF, so keep the previous query. + const multilineSeed = transaction.effects.some( + (effect) => effect.is(setSearchQuery) && /[\r\n]/.test(effect.value.search), + ); + if (!multilineSeed) return null; + return { effects: setSearchQuery.of(getSearchQuery(transaction.startState)) }; + }), + keymap.of(searchKeymap.filter((binding) => binding.key !== "Mod-f")), + ]; + + readonly open = (view = this.view) => { + if (view) openSearchPanel(view); + }; + readonly close = () => { + if (this.view) { + closeSearchPanel(this.view); + this.view.focus(); + } + }; + readonly next = () => { + if (this.view) findNext(this.view); + }; + readonly previous = () => { + if (this.view) findPrevious(this.view); + }; + readonly replace = () => { + if (this.view) replaceNext(this.view); + }; + readonly replaceAll = () => { + if (this.view) replaceAll(this.view); + }; + readonly setReplacement = (replacement: string) => + this.setQuery(this.snapshot.query, replacement); + readonly setSearch = (query: string) => { + this.setQuery(query, this.snapshot.replacement); + if (this.view && this.matches.length > 0) { + this.view.dispatch({ selection: { anchor: this.view.state.selection.main.from } }); + findNext(this.view); + } + }; + + private setQuery(query: string, replacement: string) { + this.view?.dispatch({ + effects: setSearchQuery.of( + new SearchQuery({ search: query, replace: replacement, literal: true }), + ), + }); + } + + /** + * The widget renders outside CodeMirror, so the editor cannot see it. Handing the + * node here lets this model — which already owns reveal — measure the obstruction. + */ + readonly setWidgetNode = (node: HTMLElement | null) => { + if (this.widget === node) return; + this.widgetResize?.disconnect(); + this.widgetResize = null; + this.widget = node; + if (!node) return; + this.widgetResize = new ResizeObserver(() => this.reposition()); + this.widgetResize.observe(node); + this.reposition(); + }; + + private createPanel(view: EditorView): Panel { + this.view = view; + const dom = document.createElement("div"); + dom.className = "paseo-file-find"; + return { + dom, + top: true, + mount: () => { + this.snapshot = { ...this.snapshot, open: true, placement: "top" }; + this.update(view, true); + }, + update: (update: ViewUpdate) => { + const queryChanged = !getSearchQuery(update.startState).eq(getSearchQuery(update.state)); + if ( + update.docChanged || + update.selectionSet || + queryChanged || + update.geometryChanged || + update.viewportChanged + ) + this.update(view, update.docChanged || queryChanged); + }, + destroy: () => { + this.snapshot = { ...this.snapshot, open: false, placement: "top" }; + this.publish(); + }, + }; + } + + /** Vertical space the widget takes out of the editor, including its inset. */ + private clearance(view: EditorView): number { + const widget = this.widget; + if (!widget || !this.snapshot.open) return 0; + const box = widget.getBoundingClientRect(); + if (!box.height) return 0; + const editor = view.scrollDOM.getBoundingClientRect(); + const clearance = + this.snapshot.placement === "top" ? box.bottom - editor.top : editor.bottom - box.top; + return Math.max(0, clearance); + } + + /** + * Move the widget to the opposite corner when it sits on top of the active match + * and the editor has no scroll range left to reveal it. Placement only changes + * when the current corner collides, so navigating matches never makes it bounce. + * + * Measurement runs through `requestMeasure` because CodeMirror refuses layout + * reads while an update is in progress, and every caller here sits inside one. + */ + private reposition() { + const view = this.view; + if (!view || !this.widget || !this.snapshot.open) return; + view.requestMeasure({ + key: this, + read: () => this.measurePlacement(view), + write: (placement) => { + if (placement === this.snapshot.placement) return; + this.snapshot = { ...this.snapshot, placement }; + this.publish(); + }, + }); + } + + private measurePlacement(view: EditorView): FindPlacement { + const current = this.snapshot.placement; + const widget = this.widget; + if (!widget) return current; + const selection = view.state.selection.main; + if (selection.empty) return current; + const from = view.coordsAtPos(selection.from); + const to = view.coordsAtPos(selection.to); + if (!from || !to) return current; + const clearance = this.clearance(view); + if (!clearance) return current; + const box = widget.getBoundingClientRect(); + const editor = view.scrollDOM.getBoundingClientRect(); + const match = { + top: Math.min(from.top, to.top), + bottom: Math.max(from.bottom, to.bottom), + right: Math.max(from.right, to.right), + }; + const hits = (placement: FindPlacement) => { + if (match.right <= box.left) return false; + return placement === "top" + ? match.top < editor.top + clearance && match.bottom > editor.top + : match.bottom > editor.bottom - clearance && match.top < editor.bottom; + }; + if (!hits(current)) return current; + const other: FindPlacement = current === "top" ? "bottom" : "top"; + return hits(other) ? current : other; + } + + private update(view: EditorView, recount: boolean) { + const query = getSearchQuery(view.state); + let limited = this.snapshot.limited; + if (recount) { + this.matches = []; + limited = false; + if (query.valid) { + const cursor = query.getCursor(view.state); + for (let match = cursor.next(); !match.done; match = cursor.next()) { + if (this.matches.length === MATCH_STATUS_LIMIT) { + limited = true; + break; + } + this.matches.push(match.value); + } + } + } + const selection = view.state.selection.main; + const current = + this.matches.findIndex( + (match) => match.from === selection.from && match.to === selection.to, + ) + 1; + this.snapshot = { + ...this.snapshot, + query: query.search, + replacement: query.replace, + current, + total: this.matches.length, + limited, + readOnly: view.state.readOnly, + }; + this.publish(); + this.reposition(); + } + + private publish() { + for (const listener of this.listeners) listener(); + } +} diff --git a/packages/app/src/file-pane/source/view.web.tsx b/packages/app/src/file-pane/source/view.web.tsx index 3a38b5d073..3499ac7d8e 100644 --- a/packages/app/src/file-pane/source/view.web.tsx +++ b/packages/app/src/file-pane/source/view.web.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; +import { FileFind, FileFindModel } from "../find/index.web"; import { Compartment, EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { getLanguageForFile } from "@getpaseo/highlight"; @@ -59,6 +60,7 @@ function ReadonlyCodeMirror({ }: Omit & { presentation: Exclude; }) { + const [find] = useState(() => new FileFindModel()); const hostRef = useRef(null); const viewRef = useRef(null); const initial = useRef({ content, filename, presentation, theme }); @@ -71,7 +73,12 @@ function ReadonlyCodeMirror({ state: EditorState.create({ doc: values.content, extensions: [ + find.extension, EditorState.readOnly.of(true), + EditorView.contentAttributes.of({ + tabindex: "0", + "aria-label": `Source for ${values.filename}`, + }), EditorView.editable.of(false), languageCompartment.of( languageFor({ filename: values.filename, presentation: values.presentation }), @@ -85,7 +92,7 @@ function ReadonlyCodeMirror({ view.destroy(); viewRef.current = null; }; - }, []); + }, [find]); useEffect(() => { const view = viewRef.current; @@ -110,7 +117,12 @@ function ReadonlyCodeMirror({ view.dispatch({ effects: EditorView.scrollIntoView(from, { y: "center" }) }); }, [location.lineStart, navigationRevision]); - return
; + return ( +
+
+ +
+ ); } function languageFor(input: { @@ -122,6 +134,13 @@ function languageFor(input: { : []; } +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; const UNSUPPORTED_STYLE = { alignItems: "center", diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 8d45d1decf..5bff5a704b 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ar: TranslationResources = { + paneFind: { + title: "بحث", + placeholder: "بحث في اللوحة", + close: "إغلاق البحث", + matches: "نتائج البحث", + previous: "التطابق السابق", + next: "التطابق التالي", + toggleReplace: "إظهار الاستبدال", + replaceWith: "استبدال بـ", + replace: "استبدال", + replaceAll: "استبدال الكل", + noMatches: "لا توجد تطابقات", + position: "{{current}} من {{total}}", + total: "{{total}} تطابقات", + }, common: { back: "خلف", loading: "تحميل...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 272d6d1139..a5f73de0c8 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1,4 +1,19 @@ export const en = { + paneFind: { + title: "Find", + placeholder: "Find in pane", + close: "Close Find", + matches: "Find matches", + previous: "Previous match", + next: "Next match", + toggleReplace: "Toggle replace", + replaceWith: "Replace with", + replace: "Replace", + replaceAll: "Replace all", + noMatches: "No matches", + position: "{{current}} of {{total}}", + total: "{{total}} matches", + }, common: { back: "Back", loading: "Loading...", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 99ba8a454f..0b298d59a3 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const es: TranslationResources = { + paneFind: { + title: "Buscar", + placeholder: "Buscar en el panel", + close: "Cerrar búsqueda", + matches: "Coincidencias", + previous: "Coincidencia anterior", + next: "Siguiente coincidencia", + toggleReplace: "Mostrar reemplazo", + replaceWith: "Reemplazar con", + replace: "Reemplazar", + replaceAll: "Reemplazar todo", + noMatches: "Sin coincidencias", + position: "{{current}} de {{total}}", + total: "{{total}} coincidencias", + }, common: { back: "Atrás", loading: "Cargando...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index ace3abcdcc..d5fced76f6 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const fr: TranslationResources = { + paneFind: { + title: "Rechercher", + placeholder: "Rechercher dans le panneau", + close: "Fermer la recherche", + matches: "Résultats de recherche", + previous: "Résultat précédent", + next: "Résultat suivant", + toggleReplace: "Afficher le remplacement", + replaceWith: "Remplacer par", + replace: "Remplacer", + replaceAll: "Tout remplacer", + noMatches: "Aucun résultat", + position: "{{current}} sur {{total}}", + total: "{{total}} résultats", + }, common: { back: "Dos", loading: "Chargement...", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 2ca151fc54..4d6d6b6f35 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ja: TranslationResources = { + paneFind: { + title: "検索", + placeholder: "ペイン内を検索", + close: "検索を閉じる", + matches: "検索結果", + previous: "前の一致", + next: "次の一致", + toggleReplace: "置換を切り替え", + replaceWith: "置換後の文字列", + replace: "置換", + replaceAll: "すべて置換", + noMatches: "一致なし", + position: "{{current}} / {{total}}", + total: "{{total}} 件の一致", + }, common: { back: "戻る", loading: "読み込み中...", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 21b74c3a01..f255969548 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ko: TranslationResources = { + paneFind: { + title: "찾기", + placeholder: "패널에서 찾기", + close: "찾기 닫기", + matches: "검색 결과", + previous: "이전 일치 항목", + next: "다음 일치 항목", + toggleReplace: "바꾸기 표시 전환", + replaceWith: "바꿀 내용", + replace: "바꾸기", + replaceAll: "모두 바꾸기", + noMatches: "일치 항목 없음", + position: "{{current}} / {{total}}", + total: "일치 항목 {{total}}개", + }, common: { back: "뒤로", loading: "불러오는 중...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 6317d54a57..883c088759 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ptBR: TranslationResources = { + paneFind: { + title: "Buscar", + placeholder: "Buscar no painel", + close: "Fechar busca", + matches: "Correspondências", + previous: "Correspondência anterior", + next: "Próxima correspondência", + toggleReplace: "Mostrar substituição", + replaceWith: "Substituir por", + replace: "Substituir", + replaceAll: "Substituir tudo", + noMatches: "Nenhuma correspondência", + position: "{{current}} de {{total}}", + total: "{{total}} correspondências", + }, common: { back: "Voltar", loading: "Carregando...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 5f14c32ef7..51fa5f8af7 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ru: TranslationResources = { + paneFind: { + title: "Найти", + placeholder: "Найти в панели", + close: "Закрыть поиск", + matches: "Совпадения", + previous: "Предыдущее совпадение", + next: "Следующее совпадение", + toggleReplace: "Показать замену", + replaceWith: "Заменить на", + replace: "Заменить", + replaceAll: "Заменить всё", + noMatches: "Нет совпадений", + position: "{{current}} из {{total}}", + total: "Совпадений: {{total}}", + }, common: { back: "Назад", loading: "Загрузка...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index a2e64d3da6..547da542a3 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2,6 +2,21 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const zhCN: TranslationResources = { + paneFind: { + title: "查找", + placeholder: "在窗格中查找", + close: "关闭查找", + matches: "查找结果", + previous: "上一个匹配项", + next: "下一个匹配项", + toggleReplace: "切换替换", + replaceWith: "替换为", + replace: "替换", + replaceAll: "全部替换", + noMatches: "无匹配项", + position: "{{current}} / {{total}}", + total: "{{total}} 个匹配项", + }, common: { back: "返回", loading: "加载中...", diff --git a/packages/app/src/pane-find/index.tsx b/packages/app/src/pane-find/index.tsx new file mode 100644 index 0000000000..dac417023d --- /dev/null +++ b/packages/app/src/pane-find/index.tsx @@ -0,0 +1,363 @@ +import { + forwardRef, + useCallback, + useLayoutEffect, + useMemo, + useImperativeHandle, + useRef, + useState, + type ReactNode, + type KeyboardEvent, +} from "react"; +import { + Text, + View, + type NativeSyntheticEvent, + type TextInputKeyPressEventData, + type TextInputProps, +} from "react-native"; +import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, X } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { EditingTextInput, type EditingTextInputHandle } from "@/components/ui/text-input"; +import { + createControlGeometry, + resolveControlInteractionStyles, +} from "@/components/ui/control-geometry"; +import { + mutedIconColorMapping, + smallIconButtonChromeFrameSize, +} from "@/components/ui/icon-button-chrome"; +import { paneContentToolbarIconSize, ToolbarButton } from "@/components/ui/pane-content-toolbar"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isWeb } from "@/constants/platform"; +import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; + +const TextInput = withUnistyles(EditingTextInput, (theme) => ({ + placeholderTextColor: theme.colors.foregroundMuted, +})); +const ArrowUpIcon = withUnistyles(ArrowUp, mutedIconColorMapping); +const ArrowDownIcon = withUnistyles(ArrowDown, mutedIconColorMapping); +const CloseIcon = withUnistyles(X, mutedIconColorMapping); +const ChevronDownIcon = withUnistyles(ChevronDown, mutedIconColorMapping); +const ChevronRightIcon = withUnistyles(ChevronRight, mutedIconColorMapping); + +export interface PaneFindHandle { + focus(): void; +} + +export interface PaneFindProps { + query: string; + status: string; + canNavigate: boolean; + onQueryChange(query: string): void; + onNext(): void; + onPrevious(): void; + onClose(): void; + replace?: { + value: string; + onChange(value: string): void; + onReplace(): void; + onReplaceAll(): void; + }; +} + +/** + * The bordered box around one Find input. It owns the field chrome so the query + * row and the replacement row land on the same rails, and so the match count can + * sit inside the query box instead of widening the widget. + */ +const FindField = forwardRef< + EditingTextInputHandle, + { + label: string; + initialValue: string; + height: number; + trailing?: ReactNode; + onChangeText(value: string): void; + onKeyPress(event: NativeSyntheticEvent): void; + autoFocus?: boolean; + returnKeyType?: TextInputProps["returnKeyType"]; + } +>(function FindField( + { label, initialValue, height, trailing, onChangeText, onKeyPress, autoFocus, returnKeyType }, + ref, +) { + const [focused, setFocused] = useState(false); + const onFocus = useCallback(() => setFocused(true), []); + const onBlur = useCallback(() => setFocused(false), []); + const fieldStyle = useMemo( + () => [ + styles.field, + { minHeight: height }, + resolveControlInteractionStyles( + { + controlRest: styles.controlRest, + controlHover: styles.controlHover, + controlActive: styles.controlActive, + }, + { focused }, + ), + ], + [focused, height], + ); + return ( + + + {trailing} + + ); +}); + +/** Pane-local chrome. The content owner supplies search state and commands. */ +export const PaneFind = forwardRef(function PaneFind( + { query, status, canNavigate, onQueryChange, onNext, onPrevious, onClose, replace }, + ref, +) { + const { t } = useTranslation(); + const isCompact = useIsCompactFormFactor(); + const controlSize = smallIconButtonChromeFrameSize(isCompact); + const glyphSize = paneContentToolbarIconSize(isCompact); + const rowHeight = Math.max(COLLAPSED_FIELD_HEIGHT, controlSize); + // Keep the replace actions on the same row height as the icon controls beside them. + const actionSize = isCompact ? "sm" : "xs"; + const input = useRef(null); + const replacementInput = useRef(null); + useLayoutEffect(() => { + if (input.current?.getText() !== query) input.current?.replaceText(query); + }, [query]); + useLayoutEffect(() => { + if (replace && replacementInput.current?.getText() !== replace.value) + replacementInput.current?.replaceText(replace.value); + }, [replace]); + const [replaceExpanded, setReplaceExpanded] = useState(false); + const focus = useCallback(() => { + input.current?.focus(); + const text = input.current?.getText() ?? ""; + input.current?.replaceText(text, { start: 0, end: text.length }); + }, []); + useImperativeHandle(ref, () => ({ focus }), [focus]); + + const onKeyPress = useCallback( + (event: NativeSyntheticEvent) => { + const key = event.nativeEvent as TextInputKeyPressEventData & { + shiftKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + isComposing?: boolean; + keyCode?: number; + }; + if (isImeComposingKeyboardEvent(key)) return; + if ( + (key.metaKey || key.ctrlKey) && + !key.altKey && + !key.shiftKey && + key.key.toLowerCase() === "f" + ) { + // RN Web inputs stop keydown before the pane's document listener. + event.preventDefault(); + event.stopPropagation(); + focus(); + } else if (key.key === "Escape" || key.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + if (key.key === "Escape") onClose(); + else if (key.shiftKey) onPrevious(); + else onNext(); + } + }, + [focus, onClose, onNext, onPrevious], + ); + const toggleReplace = useCallback(() => setReplaceExpanded((expanded) => !expanded), []); + const gutterStyle = useMemo( + () => [styles.gutter, { width: controlSize, height: rowHeight }], + [controlSize, rowHeight], + ); + const matchCount = useMemo( + () => ( + + {status} + + ), + [status, t], + ); + + return ( + { + if (event.key === "Escape" && !isImeComposingKeyboardEvent(event.nativeEvent)) { + event.preventDefault(); + event.stopPropagation(); + onClose(); + } + }, + } + : {})} + > + {replace ? ( + + + {replaceExpanded ? ( + + ) : ( + + )} + + + ) : null} + + + + + + + + + + + + + + {replace && replaceExpanded ? ( + + + + + + ) : null} + + + ); +}); + +/** Desktop find rows run one step tighter than a form field — this is pane chrome. */ +const COLLAPSED_FIELD_HEIGHT = 28; +const FIND_WIDGET_WIDTH = 340; + +const styles = StyleSheet.create((theme) => { + const geometry = createControlGeometry(theme); + + return { + widget: { + width: FIND_WIDGET_WIDTH, + maxWidth: "100%", + flexDirection: "row", + padding: theme.spacing[1.5], + gap: theme.spacing[1], + backgroundColor: theme.colors.surface1, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + ...theme.shadow.md, + }, + // The disclosure column spans both rows so the two fields share a leading rail. + gutter: { alignItems: "center", justifyContent: "center", flexShrink: 0 }, + rows: { flex: 1, minWidth: 0, gap: theme.spacing[1] }, + row: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1] }, + field: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.md, + }, + controlRest: { ...geometry.controlRest }, + controlHover: { ...geometry.controlHover }, + controlActive: { ...geometry.controlActive }, + input: { + flex: 1, + minWidth: 0, + paddingHorizontal: 0, + paddingVertical: 0, + color: theme.colors.foreground, + outlineWidth: 0, + outlineColor: "transparent", + ...geometry.fieldTextSm, + }, + status: { + flexShrink: 0, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + // Ghost actions sit on the field's rail, so they carry the field's padding. + replaceAction: { paddingHorizontal: theme.spacing[2] }, + }; +});