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();