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,