дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
feat(vcs): Use the editor's find bar in the diff viewer (#148)
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 <[email protected]>
Цей коміт міститься в:
6 files changed
+421
-79
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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<Record<string, boolean>>(() => ({}));
|
||||
/** The selection the bar was opened over, for toggling "In Selection" on later. */
|
||||
const openSelections = new Map<string, FindScope>();
|
||||
|
||||
/** 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<string, FindTarget>();
|
||||
|
||||
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 <FindBarInner paneId={paneId} replace={bar.replace} seq={bar.seq} />;
|
||||
return (
|
||||
<FindPanel
|
||||
target={paneTarget(paneId)}
|
||||
version={activeTab}
|
||||
replace={bar.replace}
|
||||
seq={bar.seq}
|
||||
onReplaceShown={(replace) => 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<HTMLInputElement>(null);
|
||||
const replaceRef = useRef<HTMLInputElement>(null);
|
||||
const findBoxRef = useRef<HTMLDivElement>(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<typeof setTimeout> | 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)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="replace"
|
||||
title={replace ? "Hide Replace" : "Show Replace"}
|
||||
size="small"
|
||||
pressed={replace}
|
||||
onClick={() => setReplaceShown(paneId, !replace)}
|
||||
/>
|
||||
{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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<DocumentAttachm
|
||||
|
||||
const editableExtensions = (): Extension => [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<HTMLDivElement>(null);
|
||||
const viewsRef = useRef<Views | null>(null);
|
||||
const staticsRef = useRef(statics);
|
||||
const focusedSide = useRef<"a" | "b">("b");
|
||||
const focusedSide = useRef<Side>("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<Record<Side, Set<ViewUpdateFn>>>({ 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<Side>("b");
|
||||
const findOpen = useRef(false);
|
||||
const findId = useId();
|
||||
const viewOf = (s: Side) => (s === "a" && viewsRef.current?.a) || viewsRef.current?.b || null;
|
||||
const findTarget = useMemo<FindTarget>(() => {
|
||||
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<DiffNavigator>({ 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 (
|
||||
<div
|
||||
className={styles.root}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocusCapture={() => {
|
||||
setFocusedDiffNavigator(nav.current);
|
||||
setContext("diffEditorFocus", true);
|
||||
@@ -673,6 +753,24 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{find && ready && (
|
||||
<div
|
||||
className={clsx(
|
||||
styles.findRow,
|
||||
opts.mode === "split" && styles.findRowSplit,
|
||||
opts.mode === "split" && find.side === "b" && styles.findRowRight,
|
||||
)}
|
||||
>
|
||||
<FindPanel
|
||||
target={findTarget}
|
||||
version={`${viewsGen}:${find.side}`}
|
||||
replace={find.replace}
|
||||
seq={find.seq}
|
||||
onReplaceShown={editableSide(find.side) ? (replace) => setFind((f) => f && { ...f, replace }) : undefined}
|
||||
onClose={closeFind}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error ? (
|
||||
<div className={styles.message}>{error}</div>
|
||||
) : !ready ? (
|
||||
|
||||
@@ -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<string, string> = { HEAD: "foo\nbar\nfoo\n", abc123: "foo\nbaz\nfoo\n" };
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
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<typeof import("@/workbench/contributions")>()),
|
||||
editorService: () => ({ openFile: vi.fn() }),
|
||||
}));
|
||||
|
||||
/** The working file: an editable document. */
|
||||
vi.mock("@/editor/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/editor/api")>()),
|
||||
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<HTMLElement>(".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(<DiffEditor input={revisions} />);
|
||||
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(<DiffEditor input={revisions} />);
|
||||
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(<DiffEditor input={revisions} />);
|
||||
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(<DiffEditor input={revisions} />);
|
||||
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(<DiffEditor input={working} />);
|
||||
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(<DiffEditor input={revisions} />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Заблокувати користувача