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 = { HEAD: "foo\nbar\nfoo\n", abc123: "foo\nbaz\nfoo\n" }; vi.mock("@/platform/ipc", async (importOriginal) => { const real = await importOriginal(); 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()), editorService: () => ({ openFile: vi.fn() }), })); /** The working file: an editable document. */ vi.mock("@/editor/api", async (importOriginal) => ({ ...(await importOriginal()), 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(".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) => ( <> ); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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); }); });