feat(editor): expand Replace from the Find bar with a chevron (#378)

The in-editor Find bar gains a chevron at its left that expands and collapses the Replace row (Replace, Replace All, Exclude), replacing the separate Replace icon button. Cmd/Ctrl+F opens the bar collapsed and Cmd/Ctrl+R expanded; the search text, options and focus are kept when toggling. The chevron reports aria-expanded.

Fixes #362
This commit is contained in:
l0ng-ai
2026-10-30 15:11:35 +08:00
committed by GitHub
parent d9d1caa476
commit 545fb93744
3 changed files with 100 additions and 10 deletions
@@ -117,6 +117,16 @@
background: var(--bg-editor-inline, var(--bg-elev));
border-bottom: 1px solid var(--border-editor-tabs, var(--border));
}
.findExpandable {
grid-template-columns: auto minmax(220px, 420px) auto 1fr;
}
.findExpand {
grid-column: 1;
grid-row: 1;
}
.findExpandable .findField {
grid-column: 2;
}
.findField {
min-width: 0;
}
+19 -10
View File
@@ -390,7 +390,25 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo
const canReplace = !!opts.search && !invalid;
return (
<div className={styles.find} role="search" aria-label={replace ? "Replace in file" : "Find in file"}>
<div
className={clsx(styles.find, onReplaceShown && styles.findExpandable)}
role="search"
aria-label={replace ? "Replace in file" : "Find in file"}
>
{onReplaceShown && (
<IconButton
icon={replace ? "chevron-down" : "chevron-right"}
title={replace ? "Hide Replace" : "Show Replace"}
size="small"
className={styles.findExpand}
aria-expanded={replace}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
if (replaceBoxRef.current?.contains(document.activeElement)) inputRef.current?.focus();
onReplaceShown(!replace);
}}
/>
)}
<div ref={findBoxRef} className={styles.findField}>
<SearchField
ref={inputRef}
@@ -450,15 +468,6 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo
pressed={scoped || opts.context !== "any"}
onClick={() => setFilterMenu(true)}
/>
{onReplaceShown && (
<IconButton
icon="replace"
title={replace ? "Hide Replace" : "Show Replace"}
size="small"
pressed={replace}
onClick={() => onReplaceShown(!replace)}
/>
)}
</div>
<IconButton icon="close" title="Close" shortcut="⎋" size="small" className={styles.findClose} onClick={close} />
{replace && (
@@ -1,5 +1,6 @@
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";
@@ -123,6 +124,76 @@ describe("Find in the diff viewer", () => {
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} />);