diff --git a/packages/app/e2e/browser/pane-find.spec.ts b/packages/app/e2e/browser/pane-find.spec.ts index 48d0d58a5c..1beb244735 100644 --- a/packages/app/e2e/browser/pane-find.spec.ts +++ b/packages/app/e2e/browser/pane-find.spec.ts @@ -38,18 +38,46 @@ async function closeFind(page: Page) { await query(page).press("Escape"); await expect(source(page)).toBeFocused(); } -async function expectFindAtTopRight(page: Page) { - const pane = await page +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 + .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); + .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 ({ @@ -75,9 +103,10 @@ test("finds literal text at the top of editable source and returns focus at the 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 from touch action, preserve replace and Undo/save", async () => { - await page.getByRole("button", { name: "Find", exact: true }).click(); + 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"); @@ -175,7 +204,7 @@ test("targets the focused source in split panes and refocuses an open query", as test.describe("narrow touch browser", () => { test.use({ hasTouch: true }); - test("opens Find by touch and keeps controls inside the source pane", async ({ + test("keeps Find controls inside a narrow source pane", async ({ page, withWorkspace, }, testInfo) => { @@ -184,12 +213,15 @@ test.describe("narrow touch browser", () => { 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(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 expectFindAtTopRight(page); + 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(); @@ -245,7 +277,7 @@ test("declines multiline selection seeds and keeps replacement aligned with the await expect(page.getByLabel("Line 3, column 10")).toBeVisible(); await selectFirstTwoLines(page); - await page.getByRole("button", { name: "Find", exact: true }).click(); + 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"); @@ -291,3 +323,70 @@ for (const startingField of ["Find in pane", "Replace with"] as const) { 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(); + }); +}); diff --git a/packages/app/src/file-pane/find/index.web.tsx b/packages/app/src/file-pane/find/index.web.tsx index 6cd27fe9c4..7136542415 100644 --- a/packages/app/src/file-pane/find/index.web.tsx +++ b/packages/app/src/file-pane/find/index.web.tsx @@ -6,15 +6,10 @@ import { 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, withUnistyles } from "react-native-unistyles"; +import { View, type View as ViewInstance } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { mutedIconColorMapping } from "@/components/ui/icon-button-chrome"; -import { paneContentToolbarIconSize, ToolbarButton } from "@/components/ui/pane-content-toolbar"; -import { useIsCompactFormFactor } from "@/constants/layout"; import { PaneFind, type PaneFindHandle } from "@/pane-find"; import { usePaneFocus } from "@/panels/pane-context"; import { hasActiveWebOverlay } from "@/lib/overlay-root"; @@ -24,8 +19,6 @@ import { FileFindModel } from "./model.web"; export { FileFindModel } from "./model.web"; -const SearchIcon = withUnistyles(Search, mutedIconColorMapping); - export function FileFind({ model, editor, @@ -35,16 +28,14 @@ export function FileFind({ }) { const { t } = useTranslation(); const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); - const isCompact = useIsCompactFormFactor(); const widget = useRef(null); - const { isInteractive, focusPane } = usePaneFocus(); + const { isInteractive } = usePaneFocus(); const active = useRetainedPanelActive(); - const open = useCallback(() => { - focusPane(); - model.open(editor.current); - widget.current?.focus(); - }, [editor, focusPane, model]); - + // 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) { @@ -77,14 +68,7 @@ export function FileFind({ }, [model, state.readOnly, state.replacement], ); - if (!state.panel) - return ( - - - - - - ); + if (!state.open) return null; const total = `${state.total}${state.limited ? "+" : ""}`; let status = ""; if (state.query) { @@ -92,37 +76,40 @@ export function FileFind({ else if (state.current) status = t("paneFind.position", { current: state.current, total }); else status = t("paneFind.total", { total }); } - return createPortal( - - 0} - onQueryChange={model.setSearch} - onNext={model.next} - onPrevious={model.previous} - onClose={model.close} - replace={replace} - /> - , - state.panel, + return ( + + + 0} + onQueryChange={model.setSearch} + onNext={model.next} + onPrevious={model.previous} + onClose={model.close} + replace={replace} + /> + + ); } const styles = StyleSheet.create((theme) => ({ - panel: { alignItems: "flex-end", maxWidth: "100%", padding: theme.spacing[2] }, - // Carries the open widget's chrome so the entry point reads as the same object. - trigger: { + // 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", - top: theme.spacing[2], + left: theme.spacing[2], right: theme.spacing[2], + alignItems: "flex-end", zIndex: 1, - padding: theme.spacing[1], - backgroundColor: theme.colors.surface1, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - borderRadius: theme.borderRadius.lg, - ...theme.shadow.md, }, + // 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 index ba28ce5f99..2640684f4d 100644 --- a/packages/app/src/file-pane/find/model.web.ts +++ b/packages/app/src/file-pane/find/model.web.ts @@ -16,13 +16,19 @@ import { 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 = { - panel: null as HTMLElement | null, + open: false, + placement: "top" as FindPlacement, query: "", replacement: "", current: 0, @@ -39,22 +45,14 @@ export class FileFindModel { readonly getSnapshot = () => this.snapshot; readonly extension: Extension = [ - EditorView.theme({ - // CodeMirror lays panels out as flex siblings of the scroller, which pushes - // the first lines down every time Find opens. Take the panel out of flow so - // the widget floats over the top-right of the content instead. - ".cm-panels.cm-panels-top": { - position: "absolute", - top: "0", - right: "0", - left: "auto", - maxWidth: "100%", - zIndex: "4", - border: "none", - backgroundColor: "transparent", - }, - // The custom panel lives inside the editor's monospace subtree. - ".paseo-file-find, .paseo-file-find *": { fontFamily: "var(--paseo-ui-font)" }, + // The widget floats over the editor as a sibling overlay, so CodeMirror's own + // panel slot stays empty and contributes no layout or scroll margin of its own. + EditorView.theme({ ".cm-panels": { 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) => { @@ -108,6 +106,21 @@ export class FileFindModel { }); } + /** + * 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"); @@ -116,21 +129,90 @@ export class FileFindModel { dom, top: true, mount: () => { - this.snapshot = { ...this.snapshot, panel: dom }; + 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) + if ( + update.docChanged || + update.selectionSet || + queryChanged || + update.geometryChanged || + update.viewportChanged + ) this.update(view, update.docChanged || queryChanged); }, destroy: () => { - this.snapshot = { ...this.snapshot, panel: null }; + 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; @@ -163,6 +245,7 @@ export class FileFindModel { readOnly: view.state.readOnly, }; this.publish(); + this.reposition(); } private publish() {