From a7c970a6dbaec047e639db8b894f8782e72c08f6 Mon Sep 17 00:00:00 2001 From: Jaw <53564781+jaw52@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:20:23 +0800 Subject: [PATCH] feat(vcs): Use the editor's find bar in the diff viewer (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Find in the diff viewer (⌘F / ⌘R / ⌘G) opens the editor's find bar instead of CodeMirror's search panel: same field, Match Case / Words / Regex, counter, navigation, filter menu and highlights. In the side-by-side viewer the bar sits over the side with the caret, Replace is offered only on editable sides, and the shortcuts come from the keymap. Esc closes the bar even while a match is selected. Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA Co-authored-by: jaw52 <53564781+jaw52@users.noreply.github.com> --- src/editor/core/cm/theme.ts | 44 +++-- src/editor/core/view/FindBar.tsx | 172 +++++++++++++------ src/features/vcs/DiffEditor.module.css | 23 +++ src/features/vcs/DiffEditor.tsx | 108 +++++++++++- src/features/vcs/__tests__/diffFind.test.tsx | 136 +++++++++++++++ src/features/vcs/cm.ts | 17 +- 6 files changed, 421 insertions(+), 79 deletions(-) create mode 100644 src/features/vcs/__tests__/diffFind.test.tsx diff --git a/src/editor/core/cm/theme.ts b/src/editor/core/cm/theme.ts index 52e6c0f41..0a95f9177 100644 --- a/src/editor/core/cm/theme.ts +++ b/src/editor/core/cm/theme.ts @@ -8,6 +8,31 @@ import { EditorView } from "@codemirror/view"; import { BRACKET_COLORS } from "./rainbowBrackets"; +/** The `--editor-*` locals of `findStyles`, for the `&` rule. */ +export const findLocals = { + "--editor-search": "var(--bg-search-match, color-mix(in srgb, var(--warning) 30%, transparent))", + "--editor-search-current": "var(--bg-search-match-current, color-mix(in srgb, var(--warning) 48%, transparent))", +}; + +/** Find in file highlights (cm/find.ts), also for viewers with their own theme (diff). */ +export const findStyles = { + ".cm-openidea-findMatch": { + backgroundColor: "var(--editor-search)", + borderRadius: "2px", + }, + ".cm-openidea-findExcluded": { + textDecoration: "line-through", + textDecorationColor: "var(--fg-muted)", + }, + ".cm-openidea-findScope": { + backgroundColor: "var(--editor-selection-inactive, var(--bg-editor-selection-inactive))", + }, + ".cm-openidea-findMatch-current": { + backgroundColor: "var(--editor-search-current)", + outline: "1px solid color-mix(in srgb, var(--editor-search-current) 60%, var(--fg-strong))", + }, +}; + /** The caret and Settings › Editor › General › Appearance caret options (`caretExtension`), also for viewers. */ export const caretStyles = { ".cm-cursor, .cm-dropCursor": { @@ -52,8 +77,7 @@ export const openideaTheme = EditorView.theme({ "--editor-selection-inactive": "var(--bg-editor-selection-inactive, color-mix(in srgb, var(--accent) 14%, transparent))", "--editor-indent-guide": "var(--border-indent-guide, var(--border))", - "--editor-search": "var(--bg-search-match, color-mix(in srgb, var(--warning) 30%, transparent))", - "--editor-search-current": "var(--bg-search-match-current, color-mix(in srgb, var(--warning) 48%, transparent))", + ...findLocals, "--editor-brace": "var(--bg-matched-brace, color-mix(in srgb, var(--accent) 22%, transparent))", "--editor-fold-chip": "var(--bg-fold-placeholder, color-mix(in srgb, var(--fg-muted) 14%, transparent))", }, @@ -220,21 +244,7 @@ export const openideaTheme = EditorView.theme({ }, // Find in file (cm/find.ts) - ".cm-openidea-findMatch": { - backgroundColor: "var(--editor-search)", - borderRadius: "2px", - }, - ".cm-openidea-findExcluded": { - textDecoration: "line-through", - textDecorationColor: "var(--fg-muted)", - }, - ".cm-openidea-findScope": { - backgroundColor: "var(--editor-selection-inactive)", - }, - ".cm-openidea-findMatch-current": { - backgroundColor: "var(--editor-search-current)", - outline: "1px solid color-mix(in srgb, var(--editor-search-current) 60%, var(--fg-strong))", - }, + ...findStyles, // Overlays ".cm-tooltip": { diff --git a/src/editor/core/view/FindBar.tsx b/src/editor/core/view/FindBar.tsx index b8e35a5aa..1be5b2dc3 100644 --- a/src/editor/core/view/FindBar.tsx +++ b/src/editor/core/view/FindBar.tsx @@ -5,7 +5,8 @@ * menu (Search In Selection ⌥E — on by default when Replace opens over a * multi-line selection — and the search scope: anywhere, in / except comments * and string literals), n/N counter, ↑/↓ (⇧↵ / ↵), Replace / Replace All / - * Exclude with Preserve Case, Esc closes. + * Exclude with Preserve Case, Esc closes. `FindPanel` is the bar itself, for + * any CodeMirror view (the diff viewer's sides); `FindBar` is it on an editor pane. */ import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react"; import { create } from "zustand"; @@ -57,12 +58,45 @@ const useFindScoped = create>(() => ({})); /** The selection the bar was opened over, for toggling "In Selection" on later. */ const openSelections = new Map(); +/** The editor a find bar searches. */ +export interface FindTarget { + /** Keys the bar's counter and selection scope. */ + id: string; + view(): EditorView | null; + /** Calls `fn` on document / selection changes of the searched editor; returns the unsubscribe. */ + watch(fn: (u: { docChanged: boolean; selectionSet: boolean }) => void): () => void; + /** Still open: an unmount that isn't a close (React's dev-mode remount) keeps the highlights. */ + isOpen(): boolean; +} + function activeView(paneId: string): EditorView | null { const pane = useTabs.getState().panes[paneId]; if (!pane?.active) return null; return editorController().slotFor(pane.active)?.view ?? null; } +const paneTargets = new Map(); + +function paneTarget(paneId: string): FindTarget { + let target = paneTargets.get(paneId); + if (!target) { + target = { + id: paneId, + view: () => activeView(paneId), + watch(fn) { + const tab = useTabs.getState().panes[paneId]?.active; + const slot = tab ? editorController().slotFor(tab) : null; + if (!slot) return () => {}; + slot.listeners.add(fn); + return () => void slot.listeners.delete(fn); + }, + isOpen: () => !!useFindBars.getState()[paneId], + }; + paneTargets.set(paneId, target); + } + return target; +} + export function closeFindBar(paneId: string) { useFindBars.setState({ [paneId]: undefined }); } @@ -77,12 +111,18 @@ const MAX_QUERY = 10_000; * multi-line query isn't kept for a later search without a selection. */ export function openFindBar(paneId: string, replace: boolean) { - const view = activeView(paneId); + seedFind(paneTarget(paneId), replace); + useFindBars.setState((s) => ({ [paneId]: { replace, seq: (s[paneId]?.seq ?? 0) + 1 } })); +} + +/** The query and scope a bar opening over `target`'s selection starts with (see openFindBar). */ +export function seedFind(target: FindTarget, replace: boolean) { + const view = target.view(); const sel = view?.state.selection.main; if (view && sel && !sel.empty) { const text = view.state.sliceDoc(sel.from, sel.to); const multiline = text.includes("\n"); - openSelections.set(paneId, { from: sel.from, to: sel.to }); + openSelections.set(target.id, { from: sel.from, to: sel.to }); if (multiline && replace) { view.dispatch({ effects: setFindScope.of({ from: sel.from, to: sel.to }) }); useFindOptions.setState({ search: "" }); @@ -94,7 +134,6 @@ export function openFindBar(paneId: string, replace: boolean) { } else if (useFindOptions.getState().search.includes("\n")) { useFindOptions.setState({ search: "" }); } - useFindBars.setState((s) => ({ [paneId]: { replace, seq: (s[paneId]?.seq ?? 0) + 1 } })); } /** Show / Hide Replace on the open bar, keeping its query and scope. */ @@ -104,44 +143,49 @@ function setReplaceShown(paneId: string, replace: boolean) { /** ⌘G / ⇧⌘G: next / previous occurrence of the last search, with or without the bar. */ export function findAgain(paneId: string, forward: boolean) { - const view = activeView(paneId); - const opts = useFindOptions.getState(); + const target = paneTarget(paneId); + const view = target.view(); if (!view) return; - if (!opts.search || (opts.regexp && !regexValid(opts.search))) { - openFindBar(paneId, false); - return; - } + if (!searchAgain(target, forward)) openFindBar(paneId, false); +} + +/** Next / previous occurrence of the last search in `target`; false without a usable query. */ +export function searchAgain(target: FindTarget, forward: boolean): boolean { + const view = target.view(); + const opts = useFindOptions.getState(); + if (!view || !opts.search || (opts.regexp && !regexValid(opts.search))) return false; setQuery(view, opts); if (forward) findNext(view); else findPrevious(view); - if (useFindBars.getState()[paneId]) recount(paneId); + if (target.isOpen()) recount(target); + return true; } /** Toggles "In Selection": limit to the selection the bar was opened over (or the current one). */ -function toggleScope(paneId: string) { - const view = activeView(paneId); +function toggleScope(target: FindTarget) { + const view = target.view(); if (!view) return; if (view.state.field(findScope, false)) { view.dispatch({ effects: setFindScope.of(null) }); } else { const main = view.state.selection.main; const len = view.state.doc.length; - const saved = openSelections.get(paneId); + const saved = openSelections.get(target.id); const range = saved && saved.to <= len && saved.to - saved.from > main.to - main.from ? saved : main; if (range.to <= range.from) return; view.dispatch({ effects: setFindScope.of({ from: range.from, to: range.to }) }); } - recount(paneId); + recount(target); } -function recount(paneId: string) { - const view = activeView(paneId); +function recount(target: FindTarget) { + const view = target.view(); if (!view) return; const opts = useFindOptions.getState(); const r = countMatches(view.state); const { from, to } = view.state.selection.main; - useFindCounts.setState({ [paneId]: opts.search ? { ...r, excluded: isExcluded(view.state, from, to) } : null }); - useFindScoped.setState({ [paneId]: !!view.state.field(findScope, false) }); + useFindCounts.setState({ [target.id]: opts.search ? { ...r, excluded: isExcluded(view.state, from, to) } : null }); + useFindScoped.setState({ [target.id]: !!view.state.field(findScope, false) }); view.dispatch({ effects: setFindMarks.of(opts.search ? r.starts : []) }); } @@ -153,15 +197,41 @@ function rememberQuery(replace: boolean) { export function FindBar({ paneId }: { paneId: string }) { const bar = useFindBars((s) => s[paneId]); + const activeTab = useTabs((s) => s.panes[paneId]?.active ?? null); if (!bar) return null; - return ; + return ( + setReplaceShown(paneId, replace)} + onClose={() => { + closeFindBar(paneId); + activeView(paneId)?.focus(); + }} + /> + ); } -function FindBarInner({ paneId, replace, seq }: { paneId: string; replace: boolean; seq: number }) { - const activeTab = useTabs((s) => s.panes[paneId]?.active ?? null); +export interface FindPanelProps { + target: FindTarget; + /** Changes when the target's view does (another tab, a rebuilt viewer). */ + version: unknown; + replace: boolean; + /** Bumped by each open (⌘F again): refocuses the field. */ + seq: number; + /** Show / Hide Replace; without it the bar only finds. */ + onReplaceShown?: (replace: boolean) => void; + /** Closes the bar (the owner unmounts it) and focuses the editor. */ + onClose(): void; +} + +export function FindPanel({ target, version, replace, seq, onReplaceShown, onClose }: FindPanelProps) { + const id = target.id; const opts = useFindOptions(); - const count = useFindCounts((s) => s[paneId] ?? null); - const scoped = useFindScoped((s) => !!s[paneId]); + const count = useFindCounts((s) => s[id] ?? null); + const scoped = useFindScoped((s) => !!s[id]); const inputRef = useRef(null); const replaceRef = useRef(null); const findBoxRef = useRef(null); @@ -179,7 +249,7 @@ function FindBarInner({ paneId, replace, seq }: { paneId: string; replace: boole // Push the query into the editor; incremental search selects the nearest match. useEffect(() => { - const view = activeView(paneId); + const view = target.view(); if (!view) return; const valid = !opts.regexp || regexValid(opts.search); setQuery(view, valid ? opts : { ...opts, search: "" }); @@ -189,43 +259,39 @@ function FindBarInner({ paneId, replace, seq }: { paneId: string; replace: boole view.dispatch({ selection: { anchor: from } }); findNext(view); } - recount(paneId); - }, [opts, paneId, activeTab]); + recount(target); + }, [opts, target, version]); // Keep the counter current while the document / selection changes. useEffect(() => { - const slot = activeTab ? editorController().slotFor(activeTab) : null; - if (!slot) return; let timer: ReturnType | undefined; - const listener = (u: { docChanged: boolean; selectionSet: boolean }) => { + const unwatch = target.watch((u) => { if (!u.docChanged && !u.selectionSet) return; clearTimeout(timer); - timer = setTimeout(() => recount(paneId), u.docChanged ? 150 : 0); - }; - slot.listeners.add(listener); + timer = setTimeout(() => recount(target), u.docChanged ? 150 : 0); + }); return () => { clearTimeout(timer); - slot.listeners.delete(listener); + unwatch(); }; - }, [activeTab, paneId]); + }, [target, version]); // Closing clears highlights, marks and the selection scope (not React's dev-mode remount). useEffect( () => () => { - if (!useFindBars.getState()[paneId]) activeView(paneId)?.dispatch({ effects: [setFindOpen.of(false)] }); + if (!target.isOpen()) target.view()?.dispatch({ effects: [setFindOpen.of(false)] }); }, - [paneId], + [target], ); const close = () => { rememberQuery(replace); - closeFindBar(paneId); - activeView(paneId)?.focus(); + onClose(); }; const run = (cmd: (v: EditorView) => boolean) => { - const view = activeView(paneId); + const view = target.view(); if (view) cmd(view); - recount(paneId); + recount(target); }; const doReplace = () => { rememberQuery(true); @@ -239,9 +305,9 @@ function FindBarInner({ paneId, replace, seq }: { paneId: string; replace: boole useFindOptions.setState((o) => ({ [key]: !o[key] })); const onKeyDown = (e: KeyboardEvent, field: "find" | "replace") => { - const target = e.target as HTMLTextAreaElement | HTMLInputElement; + const input = e.target as HTMLTextAreaElement | HTMLInputElement; // Inside a multi-line query the arrows move the caret between its lines. - const multiLine = target.value.includes("\n"); + const multiLine = input.value.includes("\n"); if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); @@ -271,7 +337,7 @@ function FindBarInner({ paneId, replace, seq }: { paneId: string; replace: boole toggle(k); } else if (e.code === "KeyE") { e.preventDefault(); - toggleScope(paneId); + toggleScope(target); } else if (replace && e.code === "KeyP") { e.preventDefault(); doReplace(); @@ -312,7 +378,7 @@ function FindBarInner({ paneId, replace, seq }: { paneId: string; replace: boole : [{ label: kind === "find" ? "No recent searches" : "No recent replacements", disabled: true }]; }; const filterItems: MenuItem[] = [ - { label: "Search In Selection", shortcut: "⌥E", checked: scoped || undefined, run: () => toggleScope(paneId) }, + { label: "Search In Selection", shortcut: "⌥E", checked: scoped || undefined, run: () => toggleScope(target) }, { kind: "separator" }, { kind: "header", label: "Search Scope" }, ...SEARCH_CONTEXTS.map((c) => ({ @@ -384,13 +450,15 @@ function FindBarInner({ paneId, replace, seq }: { paneId: string; replace: boole pressed={scoped || opts.context !== "any"} onClick={() => setFilterMenu(true)} /> - setReplaceShown(paneId, !replace)} - /> + {onReplaceShown && ( + onReplaceShown(!replace)} + /> + )} {replace && ( diff --git a/src/features/vcs/DiffEditor.module.css b/src/features/vcs/DiffEditor.module.css index 9fd99716a..d01b4c87f 100644 --- a/src/features/vcs/DiffEditor.module.css +++ b/src/features/vcs/DiffEditor.module.css @@ -61,6 +61,29 @@ font-size: var(--fs-tiny); line-height: 15px; } +/* The editor's find bar, over the side it searches (side-by-side) or the whole viewer. */ +.findRow { + flex: none; + display: flex; + background: var(--bg-editor-inline, var(--bg-elev)); + border-bottom: 1px solid var(--border-editor-tabs, var(--border)); +} +.findRow > div { + flex: 1; + min-width: 0; + border-bottom: none; +} +.findRowSplit > div { + flex: 0 0 50%; + border-right: 1px solid var(--border); +} +.findRowRight { + justify-content: flex-end; +} +.findRowRight > div { + border-right: none; + border-left: 1px solid var(--border); +} .message { flex: 1; display: flex; diff --git a/src/features/vcs/DiffEditor.tsx b/src/features/vcs/DiffEditor.tsx index 4e21ad2a1..50e445567 100644 --- a/src/features/vcs/DiffEditor.tsx +++ b/src/features/vcs/DiffEditor.tsx @@ -1,9 +1,9 @@ /** Diff viewer editor tab (side-by-side / unified) built on @codemirror/merge. */ import { focusedDiffNavigator, setFocusedDiffNavigator, type DiffNavigator } from "./diffNavigator"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; import { EditorView, keymap } from "@codemirror/view"; -import { Text, type Extension } from "@codemirror/state"; +import { Prec, Text, type Extension } from "@codemirror/state"; import { history, historyKeymap } from "@codemirror/commands"; import { MergeView, @@ -15,9 +15,11 @@ import { Chunk, } from "@codemirror/merge"; import { attachDocument, embeddedEditorExtensions, type DocumentAttachment } from "@/editor/api"; +import { setFindOpen } from "@/editor/core/cm/find"; +import { FindPanel, searchAgain, seedFind, type FindTarget } from "@/editor/core/view/FindBar"; import { setContext } from "@/platform/context"; import { call, commands } from "@/platform/ipc"; -import { shortcutLabel } from "@/platform/keymap"; +import { bindingsFor, normalizeBinding, shortcutLabel, strokeOf } from "@/platform/keymap"; import { tx } from "@/platform/i18n"; import { IconButton, Select, Spinner, T } from "@/ui"; import { editorService, type EditorInput } from "@/workbench/contributions"; @@ -112,6 +114,15 @@ async function attachSide(side: DiffSide, path: string): Promise [history(), keymap.of(historyKeymap)]; +type Side = "a" | "b"; +type ViewUpdateFn = (u: { docChanged: boolean; selectionSet: boolean }) => void; + +/** Whether `e` is one of the strokes bound to `actionId`. */ +function isStrokeOf(e: KeyboardEvent, actionId: string): boolean { + const stroke = strokeOf(e.nativeEvent); + return !!stroke && bindingsFor(actionId).some((b) => normalizeBinding(b) === stroke); +} + export function DiffEditor({ input }: { input: EditorInput }) { const data = input.data as DiffData; const { path, left, right } = data; @@ -126,7 +137,9 @@ export function DiffEditor({ input }: { input: EditorInput }) { const hostRef = useRef(null); const viewsRef = useRef(null); const staticsRef = useRef(statics); - const focusedSide = useRef<"a" | "b">("b"); + const focusedSide = useRef("b"); + /** Bumped when the views are rebuilt, so the find bar re-applies its query. */ + const [viewsGen, setViewsGen] = useState(0); const version = useVcs((s) => s.version); const leftKey = sideKey(left); const rightKey = sideKey(right); @@ -230,6 +243,7 @@ export function DiffEditor({ input }: { input: EditorInput }) { const listener = EditorView.updateListener.of((u) => { if (u.selectionSet || u.docChanged) updateChunkInfo(); }); + const findWatch = (s: Side) => EditorView.updateListener.of((u) => findWatchers.current[s].forEach((fn) => fn(u))); const focusTracker = (side: "a" | "b") => EditorView.domEventHandlers({ focus: () => { @@ -242,7 +256,9 @@ export function DiffEditor({ input }: { input: EditorInput }) { const highlightChanges = opts.highlight === "words"; const inclusion = includable ? inclusionGutter(path, textA) : []; const side = (att: DocumentAttachment | null, editable: boolean, notes?: "left" | "right"): Extension[] => [ - viewerBase({ readOnly: !editable }), + viewerBase({ readOnly: !editable, findBar: true }), + // Esc closes the find bar before it collapses the selection (a found match); open popups go first. + Prec.high(keymap.of([{ key: "Escape", run: () => findEscape.current() }])), lang, att?.extension ?? [], editable ? [editableExtensions(), embeddedEditorExtensions(path)] : [], @@ -262,6 +278,7 @@ export function DiffEditor({ input }: { input: EditorInput }) { extensions: [ side(attached.a, editableA, "left"), focusTracker("a"), + findWatch("a"), editableA ? hoverRevert((_v, i) => merge && applyChunk(merge, i, "b")) : [], ], }, @@ -271,6 +288,7 @@ export function DiffEditor({ input }: { input: EditorInput }) { side(attached.b, editableB, "right"), listener, focusTracker("b"), + findWatch("b"), inclusion, editableB ? hoverRevert((_v, i) => merge && applyChunk(merge, i, "a")) : [], ], @@ -295,6 +313,7 @@ export function DiffEditor({ input }: { input: EditorInput }) { side(attached.b, editableB, "right"), listener, focusTracker("b"), + findWatch("b"), inclusion, unifiedMergeView({ original: textA, @@ -323,6 +342,7 @@ export function DiffEditor({ input }: { input: EditorInput }) { if (scrollTop != null) requestAnimationFrame(() => (view.scrollDOM.scrollTop = scrollTop)); } viewsRef.current = views; + setViewsGen((g) => g + 1); requestAnimationFrame(updateChunkInfo); }, [ attached, @@ -507,6 +527,65 @@ export function DiffEditor({ input }: { input: EditorInput }) { } }; + // Find (⌘F / ⌘R / ⌘G): the editor's find bar over the focused side. + const [find, setFind] = useState<{ side: Side; replace: boolean; seq: number } | null>(null); + const findWatchers = useRef>>({ a: new Set(), b: new Set() }); + const findEscape = useRef<() => boolean>(() => false); + /** The side the bar searches (the last one, once closed: closing clears its highlights). */ + const findSide = useRef("b"); + const findOpen = useRef(false); + const findId = useId(); + const viewOf = (s: Side) => (s === "a" && viewsRef.current?.a) || viewsRef.current?.b || null; + const findTarget = useMemo(() => { + const sideOf = (): Side => (findSide.current === "a" && viewsRef.current?.a ? "a" : "b"); + return { + id: `diff:${findId}`, + view: () => (sideOf() === "a" ? viewsRef.current?.a : viewsRef.current?.b) ?? null, + watch(fn) { + const set = findWatchers.current[sideOf()]; + set.add(fn); + return () => void set.delete(fn); + }, + isOpen: () => findOpen.current, + }; + }, [findId]); + const editableSide = (s: Side) => (s === "a" ? editableA : editableB); + + const openFind = (replace: boolean) => { + const views = viewsRef.current; + if (!views) return; + const s: Side = focusedSide.current === "a" && views.a ? "a" : "b"; + if (findOpen.current && findSide.current !== s) + viewOf(findSide.current)?.dispatch({ effects: setFindOpen.of(false) }); + findSide.current = s; + findOpen.current = true; + const withReplace = replace && editableSide(s); + seedFind(findTarget, withReplace); + setFind((f) => ({ side: s, replace: withReplace, seq: (f?.seq ?? 0) + 1 })); + }; + const closeFind = () => { + findOpen.current = false; + setFind(null); + viewOf(findSide.current)?.focus(); + }; + findEscape.current = () => { + if (!findOpen.current) return false; + closeFind(); + return true; + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.defaultPrevented) return; + if (isStrokeOf(e, "find.inFile")) openFind(false); + else if (isStrokeOf(e, "find.replaceInFile")) openFind(true); + else if (isStrokeOf(e, "find.next") || isStrokeOf(e, "find.previous")) { + if (!findOpen.current) findSide.current = focusedSide.current; + if (!searchAgain(findTarget, isStrokeOf(e, "find.next"))) openFind(false); + } else if (e.key === "Escape" && findOpen.current) closeFind(); + else return; + e.preventDefault(); + e.stopPropagation(); + }; + const nav = useRef({ next: () => step(1), prev: () => step(-1), jumpToSource }); useEffect(() => { nav.current = { next: () => step(1), prev: () => step(-1), jumpToSource }; @@ -531,6 +610,7 @@ export function DiffEditor({ input }: { input: EditorInput }) { return (
{ setFocusedDiffNavigator(nav.current); setContext("diffEditorFocus", true); @@ -673,6 +753,24 @@ export function DiffEditor({ input }: { input: EditorInput }) {
)} + {find && ready && ( +
+ setFind((f) => f && { ...f, replace }) : undefined} + onClose={closeFind} + /> +
+ )} {error ? (
{error}
) : !ready ? ( diff --git a/src/features/vcs/__tests__/diffFind.test.tsx b/src/features/vcs/__tests__/diffFind.test.tsx new file mode 100644 index 000000000..592dfde32 --- /dev/null +++ b/src/features/vcs/__tests__/diffFind.test.tsx @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { EditorView } from "@codemirror/view"; +import type { EditorInput } from "@/workbench/contributions"; +import { useKeymap } from "@/platform/keymap"; + +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"; + +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: {} })); + +describe("Find in the diff viewer", () => { + 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("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); + }); +}); diff --git a/src/features/vcs/cm.ts b/src/features/vcs/cm.ts index f7b530b95..47e943470 100644 --- a/src/features/vcs/cm.ts +++ b/src/features/vcs/cm.ts @@ -18,8 +18,9 @@ import { getChunks } from "@codemirror/merge"; import { defaultKeymap } from "@codemirror/commands"; import { search, searchKeymap } from "@codemirror/search"; import { tags as t } from "@lezer/highlight"; +import { findExtension } from "@/editor/core/cm/find"; import { caretExtension } from "@/editor/core/cm/prefs"; -import { caretStyles } from "@/editor/core/cm/theme"; +import { caretStyles, findLocals, findStyles } from "@/editor/core/cm/theme"; import { editorPrefs } from "@/platform/editorSettings"; import { useProject } from "@/platform/project"; @@ -101,6 +102,7 @@ export const viewerTheme = EditorView.theme( backgroundColor: "var(--bg-app)", color: "var(--fg)", fontSize: "var(--fs-code)", + ...findLocals, }, ".cm-scroller": { fontFamily: "var(--font-mono)", fontWeight: "var(--fw-code, 400)", lineHeight: "var(--lh-code)" }, ".cm-content": { caretColor: "var(--caret, var(--fg-strong))", WebkitUserSelect: "text", userSelect: "text" }, @@ -166,6 +168,7 @@ export const viewerTheme = EditorView.theme( }, ".cm-panels": { backgroundColor: "var(--bg-elev)", color: "var(--fg)" }, ".cm-searchMatch": { backgroundColor: "color-mix(in srgb, var(--accent) 22%, transparent)" }, + ...findStyles, }, { dark: true }, ); @@ -189,14 +192,18 @@ function viewerCaret(): Extension { ]; } -/** Base extensions for a read-only (or editable) viewer pane. */ -export function viewerBase(opts: { readOnly: boolean; lineNumbers?: boolean }): Extension[] { +/** + * Base extensions for a read-only (or editable) viewer pane. `findBar`: the + * viewer shows the editor's find bar (FindPanel) instead of CodeMirror's panel. + */ +export function viewerBase(opts: { readOnly: boolean; lineNumbers?: boolean; findBar?: boolean }): Extension[] { return [ opts.lineNumbers === false ? [] : lineNumbers(), viewerCaret(), highlightActiveLine(), - search({ top: true }), - keymap.of([...searchKeymap, ...defaultKeymap]), + opts.findBar + ? [findExtension(), keymap.of(defaultKeymap)] + : [search({ top: true }), keymap.of([...searchKeymap, ...defaultKeymap])], syntaxHighlighting(viewerHighlight), viewerTheme, wholeChangedLines,