Файли
openidea/src/features/vcs/__tests__/diffFind.test.tsx
T
Jaw b31edc3482 fix(editor): give each file its own find bar, query and caret (#1296)
The find bar belonged to the pane and every bar shared one query, and its effect re-ran the search and moved the caret whenever the active tab changed, so switching files dragged the previous file's search into the next one and lost its caret. Each tab now has its own find bar with its own query and options, as with IDEA's per-editor search session. The last search only pre-fills a newly opened bar (or the selection does) and drives Find Next without a bar; showing a bar again only highlights, and the caret moves only when the query or an option changes in that bar. Bars of closed tabs are dropped.

Co-authored-by: jaw52 <[email protected]>
2026-11-04 23:11:05 +08:00

232 рядки
9.6 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { EditorView } from "@codemirror/view";
import type { EditorInput } from "@/workbench/contributions";
import { useKeymap } from "@/platform/keymap";
import { useFindOptions, useFindSessions } from "@/editor/core/view/FindBar";
const texts: Record<string, string> = { HEAD: "foo\nbar\nfoo\n", abc123: "foo\nbaz\nfoo\n" };
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return {
...real,
commands: {
...real.commands,
fsProbe: () => Promise.resolve({ status: "ok", data: { size: 3, binary: false, tooLarge: false } }),
vcsFileAt: (_path: string, rev: string) => Promise.resolve({ status: "ok", data: texts[rev] }),
fsReadText: () => Promise.resolve({ status: "ok", data: "foo\nqux\nfoo\n" }),
},
};
});
vi.mock("@/workbench/contributions", async (importOriginal) => ({
...(await importOriginal<typeof import("@/workbench/contributions")>()),
editorService: () => ({ openFile: vi.fn() }),
}));
/** The working file: an editable document. */
vi.mock("@/editor/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/editor/api")>()),
attachDocument: (path: string) =>
Promise.resolve({
path,
text: () => "foo\nqux\nfoo\n",
readOnly: false,
extension: [],
save: () => Promise.resolve(),
release: () => {},
}),
}));
import { DiffEditor } from "../DiffEditor";
import type { DiffData } from "../diffInput";
const diff = (right: object) =>
({
kind: "diff",
key: `diff:${JSON.stringify(right)}`,
data: { path: "/p/a.ts", left: { type: "rev", rev: "HEAD" }, right },
}) as unknown as EditorInput;
const revisions = diff({ type: "rev", rev: "abc123" });
const working = diff({ type: "worktree" });
const KEYMAP = {
"find.inFile": ["Meta+F"],
"find.replaceInFile": ["Meta+R"],
"find.next": ["Meta+G"],
"find.previous": ["Meta+Shift+G"],
};
const pane = async (index: number) => {
await waitFor(() => expect(document.querySelectorAll(".cm-mergeViewEditor .cm-editor").length).toBe(2));
return EditorView.findFromDOM(document.querySelectorAll<HTMLElement>(".cm-mergeViewEditor .cm-editor")[index]!)!;
};
/** Focuses side `index` and presses `code` with ⌘ (and `shift`). */
const press = async (index: number, code: string, shift = false) => {
const v = await pane(index);
v.contentDOM.dispatchEvent(new FocusEvent("focus"));
fireEvent.keyDown(v.contentDOM, { code, key: code.slice(3).toLowerCase(), metaKey: true, shiftKey: shift });
return v;
};
afterEach(() => {
useKeymap.setState({ overrides: {} });
useFindOptions.setState({ search: "", replace: "" });
useFindSessions.setState({}, true);
});
describe("Find in the diff viewer", () => {
it("preserves external focus when loading and changing a working-tree preview", async () => {
const preview = (input: EditorInput) => (
<>
<button>Changes tree</button>
<DiffEditor input={input} />
</>
);
const { rerender } = render(preview(working));
const tree = screen.getByRole("button", { name: "Changes tree" });
tree.focus();
const oldPane = await pane(1);
expect(tree).toHaveFocus();
rerender(preview({ ...working, key: "diff:b.ts", data: { ...(working.data as DiffData), path: "/p/b.ts" } }));
await waitFor(() => expect(oldPane.dom.isConnected).toBe(false));
await pane(1);
expect(tree).toHaveFocus();
});
it("opens the editor's find bar from the keymap and closes it with Esc", async () => {
useKeymap.setState({ overrides: KEYMAP });
render(<DiffEditor input={revisions} />);
await press(1, "KeyF");
const bar = await screen.findByRole("search", { name: "Find in file" });
expect(document.querySelector(".cm-search")).toBeNull();
fireEvent.keyDown(screen.getByRole("textbox", { name: "Find" }), { key: "Escape" });
await waitFor(() => expect(bar.isConnected).toBe(false));
});
it("closes with Esc in the editor while a found match is selected", async () => {
useKeymap.setState({ overrides: KEYMAP });
render(<DiffEditor input={revisions} />);
const v = await press(1, "KeyF");
fireEvent.change(await screen.findByRole("textbox", { name: "Find" }), { target: { value: "foo" } });
await waitFor(() => expect(v.state.selection.main.empty).toBe(false));
v.focus();
fireEvent.keyDown(v.contentDOM, { key: "Escape", code: "Escape", keyCode: 27 });
await waitFor(() => expect(screen.queryByRole("search")).toBeNull());
expect(document.querySelectorAll(".cm-openidea-findMatch")).toHaveLength(0);
});
it("follows a rebound shortcut", async () => {
useKeymap.setState({ overrides: { ...KEYMAP, "find.inFile": ["Meta+J"] } });
render(<DiffEditor input={revisions} />);
await press(1, "KeyF");
expect(screen.queryByRole("search")).toBeNull();
await press(1, "KeyJ");
await screen.findByRole("search", { name: "Find in file" });
});
it("only finds on a revision side", async () => {
useKeymap.setState({ overrides: KEYMAP });
render(<DiffEditor input={revisions} />);
await press(1, "KeyR");
await screen.findByRole("search", { name: "Find in file" });
expect(screen.queryByRole("button", { name: "Show Replace" })).toBeNull();
expect(screen.queryByRole("textbox", { name: "Replace" })).toBeNull();
});
it("offers Replace on the working copy but not on the revision beside it", async () => {
useKeymap.setState({ overrides: KEYMAP });
render(<DiffEditor input={working} />);
await press(1, "KeyR");
await screen.findByRole("search", { name: "Replace in file" });
await press(0, "KeyR");
await screen.findByRole("search", { name: "Find in file" });
expect(screen.queryByRole("button", { name: "Show Replace" })).toBeNull();
});
it.each(["Meta", "Ctrl"])("opens collapsed with %s+F and expanded with Replace", async (modifier) => {
useKeymap.setState({
overrides: {
...KEYMAP,
"find.inFile": [`${modifier}+F`],
"find.replaceInFile": [`${modifier}+R`],
},
});
render(<DiffEditor input={working} />);
const v = await pane(1);
const pressKey = (key: string) => {
v.contentDOM.dispatchEvent(new FocusEvent("focus"));
fireEvent.keyDown(v.contentDOM, {
code: `Key${key}`,
key: key.toLowerCase(),
metaKey: modifier === "Meta",
ctrlKey: modifier === "Ctrl",
});
};
pressKey("F");
expect(await screen.findByRole("button", { name: "Show Replace" })).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole("textbox", { name: "Replace" })).toBeNull();
pressKey("R");
expect(await screen.findByRole("button", { name: "Hide Replace" })).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("textbox", { name: "Replace" })).toBeInTheDocument();
pressKey("F");
expect(await screen.findByRole("button", { name: "Show Replace" })).toHaveAttribute("aria-expanded", "false");
});
it("toggles with pointer and keyboard while preserving query, options, and focus", async () => {
const user = userEvent.setup();
useKeymap.setState({ overrides: KEYMAP });
render(<DiffEditor input={working} />);
const v = await press(1, "KeyF");
const field = await screen.findByRole("textbox", { name: "Find" });
fireEvent.change(field, { target: { value: "foo" } });
const matchCase = screen.getByRole("button", { name: "Match Case" });
await user.click(matchCase);
const pressed = matchCase.getAttribute("aria-pressed");
field.focus();
(field as HTMLInputElement).setSelectionRange(1, 2);
const disclosure = screen.getByRole("button", { name: "Show Replace" });
expect(screen.getByRole("search").firstElementChild).toBe(disclosure);
await user.click(disclosure);
expect(field).toHaveFocus();
expect((field as HTMLInputElement).selectionStart).toBe(1);
expect((field as HTMLInputElement).selectionEnd).toBe(2);
expect(field).toHaveValue("foo");
expect(matchCase).toHaveAttribute("aria-pressed", pressed);
const replacement = screen.getByRole("textbox", { name: "Replace" });
await user.type(replacement, "baz");
await user.click(screen.getByRole("button", { name: "Hide Replace" }));
expect(field).toHaveFocus();
expect(screen.queryByRole("textbox", { name: "Replace" })).toBeNull();
const toggle = screen.getByRole("button", { name: "Show Replace" });
toggle.focus();
await user.keyboard("{Enter}");
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(toggle).toHaveFocus();
expect(screen.getByRole("textbox", { name: "Replace" })).toHaveValue("baz");
await user.keyboard(" ");
expect(toggle).toHaveAttribute("aria-expanded", "false");
await user.keyboard(" ");
field.focus();
await user.tab();
expect(screen.getByRole("textbox", { name: "Replace" })).toHaveFocus();
await user.click(screen.getByRole("button", { name: "Replace All" }));
expect(v.state.doc.toString()).toBe("baz\nqux\nbaz\n");
});
it("finds the next occurrence of the last search with ⌘G", async () => {
useKeymap.setState({ overrides: KEYMAP });
render(<DiffEditor input={revisions} />);
const v = await press(1, "KeyF");
const field = await screen.findByRole("textbox", { name: "Find" });
fireEvent.change(field, { target: { value: "foo" } });
await waitFor(() => expect(v.state.selection.main.from).toBe(0));
await press(1, "KeyG");
expect(v.state.selection.main.from).toBe(8);
});
});