From 545fb937449c07f714e90b2b46637a4118acb2cd Mon Sep 17 00:00:00 2001
From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Date: Fri, 30 Oct 2026 15:11:35 +0800
Subject: [PATCH] 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
---
src/editor/core/view/EditorArea.module.css | 10 +++
src/editor/core/view/FindBar.tsx | 29 +++++---
src/features/vcs/__tests__/diffFind.test.tsx | 71 ++++++++++++++++++++
3 files changed, 100 insertions(+), 10 deletions(-)
diff --git a/src/editor/core/view/EditorArea.module.css b/src/editor/core/view/EditorArea.module.css
index 3455430c..40fbc0dd 100644
--- a/src/editor/core/view/EditorArea.module.css
+++ b/src/editor/core/view/EditorArea.module.css
@@ -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;
}
diff --git a/src/editor/core/view/FindBar.tsx b/src/editor/core/view/FindBar.tsx
index 1be5b2dc..df139ed0 100644
--- a/src/editor/core/view/FindBar.tsx
+++ b/src/editor/core/view/FindBar.tsx
@@ -390,7 +390,25 @@ export function FindPanel({ target, version, replace, seq, onReplaceShown, onClo
const canReplace = !!opts.search && !invalid;
return (
-
+
+ {onReplaceShown && (
+
e.preventDefault()}
+ onClick={() => {
+ if (replaceBoxRef.current?.contains(document.activeElement)) inputRef.current?.focus();
+ onReplaceShown(!replace);
+ }}
+ />
+ )}
setFilterMenu(true)}
/>
- {onReplaceShown && (
- onReplaceShown(!replace)}
- />
- )}
{replace && (
diff --git a/src/features/vcs/__tests__/diffFind.test.tsx b/src/features/vcs/__tests__/diffFind.test.tsx
index 592dfde3..e7cdde2d 100644
--- a/src/features/vcs/__tests__/diffFind.test.tsx
+++ b/src/features/vcs/__tests__/diffFind.test.tsx
@@ -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();
+ 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();