mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
fix(app): keep the active match visible and drop the Find button
The floating widget covered the active match whenever the match sat under it and the document was too short to scroll clear. CodeMirror's own panel scroll margin cannot reveal a first line, so the file integration now owns placement: the widget renders as an overlay beside the editor, the model measures it against the active match, and flips it to the opposite corner only when the current corner collides. Hysteresis keeps navigation from bouncing the widget between corners. Also removes the standalone Find button shown while Find is closed, per user direction. Cmd/Ctrl+F is the entry point.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<PaneFindHandle>(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 (
|
||||
<View style={styles.trigger}>
|
||||
<ToolbarButton label={t("paneFind.title")} compact={isCompact} onPress={open}>
|
||||
<SearchIcon size={paneContentToolbarIconSize(isCompact)} />
|
||||
</ToolbarButton>
|
||||
</View>
|
||||
);
|
||||
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(
|
||||
<View style={styles.panel}>
|
||||
<PaneFind
|
||||
ref={widget}
|
||||
query={state.query}
|
||||
status={status}
|
||||
canNavigate={state.total > 0}
|
||||
onQueryChange={model.setSearch}
|
||||
onNext={model.next}
|
||||
onPrevious={model.previous}
|
||||
onClose={model.close}
|
||||
replace={replace}
|
||||
/>
|
||||
</View>,
|
||||
state.panel,
|
||||
return (
|
||||
<View
|
||||
style={[styles.overlay, state.placement === "top" ? styles.overlayTop : styles.overlayBottom]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<View ref={setWidgetNode} style={styles.widget}>
|
||||
<PaneFind
|
||||
ref={widget}
|
||||
query={state.query}
|
||||
status={status}
|
||||
canNavigate={state.total > 0}
|
||||
onQueryChange={model.setSearch}
|
||||
onNext={model.next}
|
||||
onPrevious={model.previous}
|
||||
onClose={model.close}
|
||||
replace={replace}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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] },
|
||||
}));
|
||||
|
||||
@@ -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<FindPlacement>({
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user