Add pane Find to editable and read-only file source (#4589)

* feat(app): add pane Find to file source

* fix(app): float the pane Find widget and tighten its chrome

Find pushed the first lines of the file down, ran 480px wide with 32px
ghost buttons, and let the replacement input start left of the query
input. Float the CodeMirror top panel out of flow so the widget overlays
the top right of the pane, move the disclosure toggle into a gutter that
spans both rows so the two fields share a leading rail, put the match
count inside the query field, and drop the icon controls to the shared
pane-toolbar chrome the file bar above already uses.

* 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.

* fix(app): stop the Find placeholder from hiding Go to line

Hiding `.cm-panels` suppressed every CodeMirror panel in the file editor,
including the Go to line dialog that Mod-Alt-g opens through the same
search keymap. Hide only the inert Find placeholder panel instead, by its
own class, and leave the shared panel container alone.
This commit is contained in:
Mohamed Boudra
2026-09-15 17:20:27 +02:00
committed by GitHub
parent cb90806045
commit 26e0273acb
16 changed files with 1349 additions and 14 deletions
+430
View File
@@ -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");
});
});
@@ -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": {
+22 -9
View File
@@ -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<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(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 (
<div
ref={hostRef}
data-pmono=""
data-testid="file-source-editor"
aria-label={`Source editor for ${filename}`}
style={HOST_STYLE}
/>
<div style={FRAME_STYLE}>
<div
ref={hostRef}
data-pmono=""
data-testid="file-source-editor"
aria-label={`Source editor for ${filename}`}
style={HOST_STYLE}
/>
<FileFind model={find} editor={viewRef} />
</div>
);
}
const remoteUpdate = Annotation.define<boolean>();
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;
@@ -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<EditorView | null>;
}) {
const { t } = useTranslation();
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const widget = useRef<PaneFindHandle>(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 (
<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) => ({
// 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] },
}));
@@ -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<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;
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();
}
}
+22 -3
View File
@@ -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<FileSourceViewProps, "size" | "tooLargeMessage"> & {
presentation: Exclude<SourcePresentation, "unsupported">;
}) {
const [find] = useState(() => new FileFindModel());
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(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 <div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />;
return (
<div style={FRAME_STYLE}>
<div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />
<FileFind model={find} editor={viewRef} />
</div>
);
}
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",
+15
View File
@@ -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: "تحميل...",
+15
View File
@@ -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...",
+15
View File
@@ -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...",
+15
View File
@@ -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...",
+15
View File
@@ -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: "読み込み中...",
+15
View File
@@ -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: "불러오는 중...",
+15
View File
@@ -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...",
+15
View File
@@ -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: "Загрузка...",
+15
View File
@@ -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: "加载中...",
+363
View File
@@ -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<TextInputKeyPressEventData>): 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 (
<View style={fieldStyle}>
<TextInput
ref={ref}
autoFocus={autoFocus}
selectTextOnFocus
initialValue={initialValue}
onChangeText={onChangeText}
onKeyPress={onKeyPress}
onFocus={onFocus}
onBlur={onBlur}
accessibilityLabel={label}
placeholder={label}
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}
returnKeyType={returnKeyType}
style={styles.input}
/>
{trailing}
</View>
);
});
/** Pane-local chrome. The content owner supplies search state and commands. */
export const PaneFind = forwardRef<PaneFindHandle, PaneFindProps>(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<EditingTextInputHandle>(null);
const replacementInput = useRef<EditingTextInputHandle>(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<TextInputKeyPressEventData>) => {
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(
() => (
<Text
style={styles.status}
role="status"
accessibilityLabel={t("paneFind.matches")}
accessibilityLiveRegion="polite"
>
{status}
</Text>
),
[status, t],
);
return (
<View
style={styles.widget}
accessibilityLabel={t("paneFind.title")}
{...(isWeb
? {
onKeyDown: (event: KeyboardEvent) => {
if (event.key === "Escape" && !isImeComposingKeyboardEvent(event.nativeEvent)) {
event.preventDefault();
event.stopPropagation();
onClose();
}
},
}
: {})}
>
{replace ? (
<View style={gutterStyle}>
<ToolbarButton
label={t("paneFind.toggleReplace")}
aria-expanded={replaceExpanded}
compact={isCompact}
onPress={toggleReplace}
>
{replaceExpanded ? (
<ChevronDownIcon size={glyphSize} />
) : (
<ChevronRightIcon size={glyphSize} />
)}
</ToolbarButton>
</View>
) : null}
<View style={styles.rows}>
<View style={styles.row}>
<FindField
ref={input}
autoFocus
label={t("paneFind.placeholder")}
initialValue={query}
height={rowHeight}
returnKeyType="search"
trailing={matchCount}
onChangeText={onQueryChange}
onKeyPress={onKeyPress}
/>
<ToolbarButton
label={t("paneFind.previous")}
compact={isCompact}
disabled={!canNavigate}
onPress={onPrevious}
>
<ArrowUpIcon size={glyphSize} />
</ToolbarButton>
<ToolbarButton
label={t("paneFind.next")}
compact={isCompact}
disabled={!canNavigate}
onPress={onNext}
>
<ArrowDownIcon size={glyphSize} />
</ToolbarButton>
<ToolbarButton label={t("paneFind.close")} compact={isCompact} onPress={onClose}>
<CloseIcon size={glyphSize} />
</ToolbarButton>
</View>
{replace && replaceExpanded ? (
<View style={styles.row}>
<FindField
ref={replacementInput}
label={t("paneFind.replaceWith")}
initialValue={replace.value}
height={rowHeight}
onChangeText={replace.onChange}
onKeyPress={onKeyPress}
/>
<Button
variant="ghost"
size={actionSize}
style={styles.replaceAction}
disabled={!canNavigate}
onPress={replace.onReplace}
>
{t("paneFind.replace")}
</Button>
<Button
variant="ghost"
size={actionSize}
style={styles.replaceAction}
disabled={!canNavigate}
onPress={replace.onReplaceAll}
>
{t("paneFind.replaceAll")}
</Button>
</View>
) : null}
</View>
</View>
);
});
/** 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] },
};
});