revert(app): undo file Find merge pending maintainer review

Reverts 69510ac72f. The feature must return to an open pull request for maintainer review.
This commit is contained in:
Mohamed Boudra
2026-09-09 19:39:14 +02:00
parent 69510ac72f
commit ec5ab3fc4d
16 changed files with 14 additions and 1019 deletions
-293
View File
@@ -1,293 +0,0 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
function source(page: Page) {
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
}
function query(page: Page) {
return page.getByRole("textbox", { name: "Find in pane", exact: true });
}
function status(page: Page) {
return page.getByRole("status", { name: "Find matches" });
}
async function openSource(page: Page, filename: string) {
await openFileExplorer(page);
await openFileFromExplorer(page, filename);
await expect(source(page)).toBeVisible();
}
async function findInSource(page: Page, text: string) {
await source(page).focus();
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill(text);
}
async function expectQuerySelected(page: Page, text: string) {
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue(text);
await expect
.poll(() =>
query(page).evaluate((input: HTMLInputElement) => [input.selectionStart, input.selectionEnd]),
)
.toEqual([0, text.length]);
}
async function closeFind(page: Page) {
await query(page).press("Escape");
await expect(source(page)).toBeFocused();
}
async function expectFindAtTopRight(page: Page) {
const pane = await page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.boundingBox();
const widget = await page
.getByLabel("Find", { exact: true })
.filter({ visible: true })
.boundingBox();
expect(widget!.x + widget!.width).toBeLessThanOrEqual(pane!.x + pane!.width);
expect(widget!.x).toBeGreaterThanOrEqual(pane!.x);
expect(widget!.y).toBeLessThan(pane!.y + 70);
}
test("finds literal text at the top of editable source and returns focus at the inspected match", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-" });
const file = path.join(workspace.repoPath, "source.txt");
await writeFile(file, "first a.b\nsecond axb\nthird a.b\n");
await workspace.navigateTo();
await openSource(page, "source.txt");
await findInSource(page, "a.b");
await expect(status(page)).toHaveText("1 of 2");
await query(page).fill("A.B");
await expect(status(page)).toHaveText("1 of 2");
await query(page).fill("a.b");
await expectFindAtTopRight(page);
await query(page).press("Enter");
await expect(status(page)).toHaveText("2 of 2");
await query(page).press("Shift+Enter");
await expect(status(page)).toHaveText("1 of 2");
await page.screenshot({ path: testInfo.outputPath("editable-find.png") });
await closeFind(page);
await expect(query(page)).toBeHidden();
await expect(page.getByLabel("Line 1, column 10")).toBeVisible();
await test.step("reopen from touch action, preserve replace and Undo/save", async () => {
await page.getByRole("button", { name: "Find", exact: true }).click();
await expect(query(page)).toBeFocused();
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("literal");
await page.getByRole("button", { name: "Replace", exact: true }).click();
await expect(source(page)).toContainText("first literal");
await expect(status(page)).toHaveText("1 of 1");
await page.screenshot({ path: testInfo.outputPath("replace.png") });
await closeFind(page);
await source(page).press("ControlOrMeta+z");
await expect(source(page)).toContainText("first a.b");
await source(page).press("ControlOrMeta+s");
await expect.poll(() => readFile(file, "utf8")).toBe("first a.b\nsecond axb\nthird a.b\n");
await findInSource(page, "a.b");
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("all");
await page.getByRole("button", { name: "Replace all", exact: true }).click();
await expect(status(page)).toHaveText("No matches");
await closeFind(page);
await source(page).press("ControlOrMeta+s");
await expect.poll(() => readFile(file, "utf8")).toBe("first all\nsecond axb\nthird all\n");
});
});
test("searches the unsaved buffer while disconnected", async ({ page, withWorkspace }) => {
const gate = await installDaemonWebSocketGate(page);
const workspace = await withWorkspace({ prefix: "pane-find-draft-" });
const file = path.join(workspace.repoPath, "draft.txt");
await writeFile(file, "saved on disk\n");
await workspace.navigateTo();
await openSource(page, "draft.txt");
await gate.drop();
await source(page).fill("unsaved needle\nsecond needle\n");
await findInSource(page, "needle");
await expect(status(page)).toHaveText("1 of 2");
expect(await readFile(file, "utf8")).toBe("saved on disk\n");
gate.restore();
});
test("searches read-only source beyond the viewport without replace controls", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-readonly-" });
await writeFile(
path.join(workspace.repoPath, "large.txt"),
"needle first\n" + "plain source line\n".repeat(70_000) + "needle last\n",
);
await workspace.navigateTo();
await openSource(page, "large.txt");
await findInSource(page, "needle");
await expect(source(page)).toHaveAttribute("contenteditable", "false");
await expect(status(page)).toHaveText("1 of 2");
await expect(page.getByRole("button", { name: "Toggle replace" })).toHaveCount(0);
await query(page).press("Enter");
await expect(status(page)).toHaveText("2 of 2");
await expect(source(page)).toContainText("needle last");
await page.screenshot({ path: testInfo.outputPath("readonly-find.png") });
await closeFind(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await query(page).fill("plain");
await expect(status(page)).toHaveText("1 of 10000+");
});
test("targets the focused source in split panes and refocuses an open query", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-split-" });
await writeFile(path.join(workspace.repoPath, "left.txt"), "left needle\n");
await writeFile(path.join(workspace.repoPath, "right.txt"), "right needle\nright needle\n");
await workspace.navigateTo();
await openSource(page, "left.txt");
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await openFileFromExplorer(page, "right.txt");
const left = source(page).filter({ hasText: "left needle" });
const right = source(page).filter({ hasText: "right needle" });
await expect(left).toBeVisible();
await expect(right).toBeVisible();
await left.click();
await left.press("ControlOrMeta+f");
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 1");
await query(page).press("Escape");
await right.click();
await right.press("ControlOrMeta+f");
await query(page).fill("needle");
await expect(status(page)).toHaveText("1 of 2");
await right.click();
await right.press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue("needle");
await page.screenshot({ path: testInfo.outputPath("split-find.png") });
});
test.describe("narrow touch browser", () => {
test.use({ hasTouch: true });
test("opens Find by touch and keeps controls inside the source pane", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-touch-" });
await writeFile(path.join(workspace.repoPath, "touch.txt"), "touch needle\nnext needle\n");
await workspace.navigateTo();
await openSource(page, "touch.txt");
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole("button", { name: "Find", exact: true }).tap();
await expect(query(page)).toBeFocused();
await query(page).fill("needle");
await page.getByRole("button", { name: "Next match" }).tap();
await expect(status(page)).toHaveText("2 of 2");
await expectFindAtTopRight(page);
await page.screenshot({ path: testInfo.outputPath("touch-find.png") });
await page.getByRole("button", { name: "Close Find" }).tap();
await expect(source(page)).toBeFocused();
});
});
async function selectFirstTwoLines(page: Page) {
await source(page).focus();
await source(page).press("ControlOrMeta+Home");
await source(page).press("Shift+ArrowDown");
await source(page).press("Shift+End");
}
async function selectLastWord(page: Page) {
await source(page).focus();
await source(page).press("ControlOrMeta+End");
await source(page).press("ArrowUp");
await source(page).press("Home");
await source(page).press("Shift+End");
}
test("declines multiline selection seeds and keeps replacement aligned with the visible query", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-single-line-" });
const file = path.join(workspace.repoPath, "selection.txt");
await writeFile(file, "alpha\nbeta\nalphabeta\n");
await workspace.navigateTo();
await openSource(page, "selection.txt");
await selectFirstTwoLines(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue("");
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
await query(page).press("Enter");
await expect(query(page)).toHaveValue("");
await closeFind(page);
await selectFirstTwoLines(page);
await source(page).press("ControlOrMeta+g");
await expect(query(page)).toHaveValue("");
await expect(page.getByRole("button", { name: "Next match" })).toBeDisabled();
await closeFind(page);
await selectLastWord(page);
await source(page).press("ControlOrMeta+f");
await expect(query(page)).toHaveValue("alphabeta");
await query(page).press("Enter");
await expect(status(page)).toHaveText("1 of 1");
await closeFind(page);
await expect(page.getByLabel("Line 3, column 10")).toBeVisible();
await selectFirstTwoLines(page);
await page.getByRole("button", { name: "Find", exact: true }).click();
await expect(query(page)).toHaveValue("alphabeta");
await query(page).press("Enter");
await expect(status(page)).toHaveText("1 of 1");
await page.getByRole("button", { name: "Toggle replace" }).click();
await page.getByRole("textbox", { name: "Replace with" }).fill("replaced");
await page.getByRole("button", { name: "Replace", exact: true }).click();
await expect(source(page)).toContainText("alpha");
await expect(source(page)).toContainText("beta");
await expect(status(page)).toHaveText("No matches");
await page.screenshot({ path: testInfo.outputPath("single-line-query-replacement.png") });
await closeFind(page);
await source(page).press("ControlOrMeta+s");
await expect.poll(() => readFile(file, "utf8")).toBe("alpha\nbeta\nreplaced\n");
});
for (const startingField of ["Find in pane", "Replace with"] as const) {
test(`repeated Find from ${startingField} selects the query for new typing`, async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "pane-find-repeat-input-" });
await writeFile(path.join(workspace.repoPath, "source.txt"), "needle first\nneedle second\n");
await workspace.navigateTo();
await openSource(page, "source.txt");
await findInSource(page, "needle");
await page.getByRole("button", { name: "Toggle replace" }).click();
const replacement = page.getByRole("textbox", { name: "Replace with" });
await replacement.fill("replacement");
await test.step("repeat Find from the input and type a fresh query", async () => {
const startingInput = page.getByRole("textbox", { name: startingField, exact: true });
await startingInput.press("End");
await startingInput.press("ControlOrMeta+f");
await expectQuerySelected(page, "needle");
await page.keyboard.type("first");
await expect(query(page)).toHaveValue("first");
await expect(replacement).toHaveValue("replacement");
await expect(status(page)).toHaveText("1 of 1");
});
await page.screenshot({ path: testInfo.outputPath("repeat-find-input.png") });
await closeFind(page);
await expect(page.getByLabel("Line 1, column 13")).toBeVisible();
});
}
@@ -5,6 +5,7 @@ import {
indentOnInput,
syntaxHighlighting,
} from "@codemirror/language";
import { searchKeymap } from "@codemirror/search";
import {
EditorView,
drawSelection,
@@ -41,6 +42,7 @@ export function editorBaseExtensions(onSave: () => void) {
indentWithTab,
...defaultKeymap,
...historyKeymap,
...searchKeymap,
]),
];
}
@@ -61,13 +63,6 @@ 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": {
+9 -22
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { FileFind, FileFindModel } from "../find/index.web";
import { useEffect, useRef, useSyncExternalStore } from "react";
import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
@@ -39,7 +38,6 @@ 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);
@@ -56,7 +54,6 @@ 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)),
@@ -84,7 +81,7 @@ export function FileEditorView({
view.destroy();
viewRef.current = null;
};
}, [find]);
}, []);
useEffect(() => {
const view = viewRef.current;
@@ -144,25 +141,15 @@ export function FileEditorView({
}, [onVimModeChange, vimEnabled]);
return (
<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>
<div
ref={hostRef}
data-pmono=""
data-testid="file-source-editor"
aria-label={`Source editor for ${filename}`}
style={HOST_STYLE}
/>
);
}
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;
@@ -1,122 +0,0 @@
import {
useCallback,
useMemo,
useEffect,
useRef,
useSyncExternalStore,
type RefObject,
} from "react";
import { createPortal } from "react-dom";
import { EditorView } from "@codemirror/view";
import { Search } from "lucide-react-native";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { PaneFind, type PaneFindHandle } from "@/pane-find";
import { usePaneFocus } from "@/panels/pane-context";
import { hasActiveWebOverlay } from "@/lib/overlay-root";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { FileFindModel } from "./model.web";
export { FileFindModel } from "./model.web";
export function FileFind({
model,
editor,
}: {
model: FileFindModel;
editor: RefObject<EditorView | null>;
}) {
const { t } = useTranslation();
const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
const widget = useRef<PaneFindHandle>(null);
const { isInteractive, focusPane } = usePaneFocus();
const active = useRetainedPanelActive();
const open = useCallback(() => {
focusPane();
model.open(editor.current);
widget.current?.focus();
}, [editor, focusPane, model]);
useEffect(() => {
if (!isInteractive || !active) return;
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
return;
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === "f"
) {
event.preventDefault();
model.open(editor.current);
widget.current?.focus();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [active, editor, isInteractive, model]);
const replace = useMemo(
() =>
state.readOnly
? undefined
: {
value: state.replacement,
onChange: model.setReplacement,
onReplace: model.replace,
onReplaceAll: model.replaceAll,
},
[model, state.readOnly, state.replacement],
);
if (!state.panel)
return (
<View style={styles.trigger}>
<Button
variant="ghost"
size="sm"
leftIcon={Search}
accessibilityLabel={t("paneFind.title")}
onPress={open}
/>
</View>
);
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 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,
);
}
const styles = StyleSheet.create((theme) => ({
panel: { alignItems: "flex-end", padding: theme.spacing[2] },
trigger: {
position: "absolute",
top: theme.spacing[2],
right: theme.spacing[2],
zIndex: 1,
backgroundColor: theme.colors.surface1,
borderRadius: theme.borderRadius.md,
},
}));
@@ -1,158 +0,0 @@
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;
/** 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 snapshot = {
panel: null as HTMLElement | null,
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 custom panel lives inside the editor's monospace subtree.
EditorView.theme({
".paseo-file-find, .paseo-file-find *": { fontFamily: "var(--paseo-ui-font)" },
}),
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 }),
),
});
}
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, panel: dom };
this.update(view, true);
},
update: (update: ViewUpdate) => {
const queryChanged = !getSearchQuery(update.startState).eq(getSearchQuery(update.state));
if (update.docChanged || update.selectionSet || queryChanged)
this.update(view, update.docChanged || queryChanged);
},
destroy: () => {
this.snapshot = { ...this.snapshot, panel: null };
this.publish();
},
};
}
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();
}
private publish() {
for (const listener of this.listeners) listener();
}
}
+3 -22
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { FileFind, FileFindModel } from "../find/index.web";
import { useEffect, useRef } from "react";
import { Compartment, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
@@ -60,7 +59,6 @@ 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 });
@@ -73,12 +71,7 @@ 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 }),
@@ -92,7 +85,7 @@ function ReadonlyCodeMirror({
view.destroy();
viewRef.current = null;
};
}, [find]);
}, []);
useEffect(() => {
const view = viewRef.current;
@@ -117,12 +110,7 @@ function ReadonlyCodeMirror({
view.dispatch({ effects: EditorView.scrollIntoView(from, { y: "center" }) });
}, [location.lineStart, navigationRevision]);
return (
<div style={FRAME_STYLE}>
<div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />
<FileFind model={find} editor={viewRef} />
</div>
);
return <div ref={hostRef} data-testid="file-source-editor" style={HOST_STYLE} />;
}
function languageFor(input: {
@@ -134,13 +122,6 @@ 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,21 +2,6 @@ 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,19 +1,4 @@
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,21 +2,6 @@ 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,21 +2,6 @@ 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,21 +2,6 @@ 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,21 +2,6 @@ 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,21 +2,6 @@ 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,21 +2,6 @@ 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,21 +2,6 @@ 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: "加载中...",
-260
View File
@@ -1,260 +0,0 @@
import {
forwardRef,
useCallback,
useLayoutEffect,
useMemo,
useImperativeHandle,
useRef,
useState,
type KeyboardEvent,
} from "react";
import {
Text,
View,
type NativeSyntheticEvent,
type TextInputKeyPressEventData,
} 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 { EditingTextInput, type EditingTextInputHandle } from "@/components/ui/text-input";
import { Button } from "@/components/ui/button";
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
import { isWeb } from "@/constants/platform";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
const TextInput = withUnistyles(EditingTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
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;
};
}
/** 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 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 replaceAccessibilityState = useMemo(
() => ({ expanded: replaceExpanded }),
[replaceExpanded],
);
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();
}
},
}
: {})}
>
<View style={styles.findRow}>
<View style={styles.queryRow}>
{replace ? (
<Button
variant="ghost"
size="sm"
leftIcon={replaceExpanded ? ChevronDown : ChevronRight}
accessibilityLabel={t("paneFind.toggleReplace")}
accessibilityState={replaceAccessibilityState}
onPress={toggleReplace}
/>
) : null}
<TextInput
ref={input}
autoFocus
selectTextOnFocus
initialValue={query}
onChangeText={onQueryChange}
onKeyPress={onKeyPress}
accessibilityLabel={t("paneFind.placeholder")}
placeholder={t("paneFind.placeholder")}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
blurOnSubmit={false}
style={styles.input}
/>
</View>
<View style={styles.row}>
<Text
style={styles.status}
role="status"
accessibilityLabel={t("paneFind.matches")}
accessibilityLiveRegion="polite"
>
{status}
</Text>
<Button
variant="ghost"
size="sm"
leftIcon={ArrowUp}
accessibilityLabel={t("paneFind.previous")}
disabled={!canNavigate}
onPress={onPrevious}
/>
<Button
variant="ghost"
size="sm"
leftIcon={ArrowDown}
accessibilityLabel={t("paneFind.next")}
disabled={!canNavigate}
onPress={onNext}
/>
</View>
<Button
variant="ghost"
size="sm"
leftIcon={X}
accessibilityLabel={t("paneFind.close")}
onPress={onClose}
/>
</View>
{replace && replaceExpanded ? (
<View style={styles.replacement}>
<TextInput
ref={replacementInput}
initialValue={replace.value}
onChangeText={replace.onChange}
onKeyPress={onKeyPress}
accessibilityLabel={t("paneFind.replaceWith")}
placeholder={t("paneFind.replaceWith")}
autoCapitalize="none"
autoCorrect={false}
style={styles.input}
/>
<View style={styles.row}>
<Button variant="ghost" size="sm" disabled={!canNavigate} onPress={replace.onReplace}>
{t("paneFind.replace")}
</Button>
<Button
variant="ghost"
size="sm"
disabled={!canNavigate}
onPress={replace.onReplaceAll}
>
{t("paneFind.replaceAll")}
</Button>
</View>
</View>
) : null}
</View>
);
});
const styles = StyleSheet.create((theme) => ({
widget: {
width: 480,
maxWidth: "100%",
padding: theme.spacing[2],
gap: theme.spacing[1],
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
},
findRow: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: theme.spacing[1] },
queryRow: {
flex: 1,
minWidth: 140,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
row: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1] },
replacement: { gap: theme.spacing[1] },
input: {
flex: 1,
minWidth: 0,
minHeight: CONTROL_HEIGHTS.compact,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.sm,
},
status: {
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
paddingHorizontal: theme.spacing[2],
},
}));