feat(editor): add occurrence selection actions

This commit is contained in:
Ryan Liu authored and GitHub committed 2026-09-02 20:38:17 +08:00
1 parent 91f309f6c7
commit 7d92433689
13 files changed
+284 -1

No files matched your search

@@ -0,0 +1,97 @@
// @vitest-environment happy-dom
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { describe, expect, it } from "vitest";
import { addNextQueryEditorSelectionOccurrence, selectAllQueryEditorSelectionOccurrences } from "@/lib/editor/queryEditorOccurrenceSelection";
function createView(doc: string, selection: EditorSelection) {
return new EditorView({
parent: document.createElement("div"),
state: EditorState.create({
doc,
selection,
extensions: [EditorState.allowMultipleSelections.of(true)],
}),
});
}
function ranges(view: EditorView) {
return view.state.selection.ranges.map(({ from, to }) => ({ from, to }));
}
describe("queryEditorOccurrenceSelection", () => {
it("adds the next occurrence of the current selection", () => {
const view = createView("foo bar foo baz foo", EditorSelection.single(0, 3));
expect(addNextQueryEditorSelectionOccurrence(view)).toBe(true);
expect(ranges(view)).toEqual([
{ from: 0, to: 3 },
{ from: 8, to: 11 },
]);
});
it("expands an empty cursor to the current word before adding the next occurrence", () => {
const view = createView("select id from users where id = 1", EditorSelection.cursor(8));
expect(addNextQueryEditorSelectionOccurrence(view)).toBe(true);
expect(ranges(view)).toEqual([
{ from: 7, to: 9 },
{ from: 27, to: 29 },
]);
});
it("word-expands every empty range when multiple cursors are active", () => {
// One cursor sits on a word, the second (Alt+click) is empty on another
// word: the single-cursor helper cannot run, so the native command
// word-expands every empty range and adds the next occurrence.
const view = createView("foo bar foo", EditorSelection.create([EditorSelection.range(0, 3), EditorSelection.cursor(4)]));
expect(addNextQueryEditorSelectionOccurrence(view)).toBe(true);
// "bar" has no further occurrence, so the selection stays at the two
// word ranges — the point is the empty cursor was expanded, not dropped.
expect(ranges(view)).toEqual([
{ from: 0, to: 3 },
{ from: 4, to: 7 },
]);
});
it("selects every occurrence of the selected text", () => {
const view = createView("foo bar foo baz foo", EditorSelection.single(0, 3));
expect(selectAllQueryEditorSelectionOccurrences(view)).toBe(true);
expect(ranges(view)).toEqual([
{ from: 0, to: 3 },
{ from: 8, to: 11 },
{ from: 16, to: 19 },
]);
});
it("selects every occurrence after the next occurrence was already added", () => {
const view = createView("foo bar foo baz foo", EditorSelection.single(0, 3));
expect(addNextQueryEditorSelectionOccurrence(view)).toBe(true);
expect(selectAllQueryEditorSelectionOccurrences(view)).toBe(true);
expect(ranges(view)).toEqual([
{ from: 0, to: 3 },
{ from: 8, to: 11 },
{ from: 16, to: 19 },
]);
});
it("selects all occurrences of the word around an empty cursor", () => {
const view = createView("select id from users where id = 1", EditorSelection.cursor(8));
expect(selectAllQueryEditorSelectionOccurrences(view)).toBe(true);
expect(ranges(view)).toEqual([
{ from: 7, to: 9 },
{ from: 27, to: 29 },
]);
});
});
@@ -1,5 +1,16 @@
import { describe, expect, it } from "vitest";
import { closeOtherTabsDefaultShortcut, DEFAULT_SHORTCUT_SETTINGS, SHORTCUT_DEFINITIONS, findShortcutConflict, formatShortcut, normalizeModifierOnlyShortcut, normalizeShortcutSettings, shortcutToCodeMirrorKey, type ShortcutActionId } from "@/lib/editor/shortcutRegistry";
import {
closeOtherTabsDefaultShortcut,
DEFAULT_SHORTCUT_SETTINGS,
SHORTCUT_DEFINITIONS,
findShortcutConflict,
formatShortcut,
normalizeModifierOnlyShortcut,
normalizeShortcutSettings,
selectionOccurrenceDefaultShortcut,
shortcutToCodeMirrorKey,
type ShortcutActionId,
} from "@/lib/editor/shortcutRegistry";
describe("shortcutRegistry editor actions", () => {
const formatterEditorActionIds: ShortcutActionId[] = [
@@ -18,6 +29,8 @@ describe("shortcutRegistry editor actions", () => {
"undo",
"redo",
"selectAll",
"addNextSelectionOccurrence",
"selectAllSelectionOccurrences",
"uppercaseSelection",
"lowercaseSelection",
"exPasteSqlInCondition",
@@ -233,6 +246,9 @@ describe("shortcutRegistry editor actions", () => {
expect(shortcuts.redo).toBe("Shift+Mod+Z");
expect(shortcuts.selectAll).toBe("Mod+A");
expect(shortcuts.extendSelection).toBe("Alt+W");
// 测试平台(Linux runner)解析为非 mac 默认键;mac 变体在下方单独断言。
expect(shortcuts.addNextSelectionOccurrence).toBe(selectionOccurrenceDefaultShortcut("addNextSelectionOccurrence"));
expect(shortcuts.selectAllSelectionOccurrences).toBe(selectionOccurrenceDefaultShortcut("selectAllSelectionOccurrences"));
expect(shortcuts.uppercaseSelection).toBe("Shift+Alt+U");
expect(shortcuts.lowercaseSelection).toBe("Shift+Alt+L");
expect(shortcuts.exPasteSqlInCondition).toBe("");
@@ -246,6 +262,29 @@ describe("shortcutRegistry editor actions", () => {
expect(DEFAULT_SHORTCUT_SETTINGS.extendSelection).toBe("Alt+W");
});
it("registers occurrence selection shortcuts for query editor multi-selection", () => {
const next = SHORTCUT_DEFINITIONS.find((item) => item.id === "addNextSelectionOccurrence");
const all = SHORTCUT_DEFINITIONS.find((item) => item.id === "selectAllSelectionOccurrences");
expect(next).toMatchObject({ scope: "editor", defaultShortcut: "Ctrl+G" });
expect(all).toMatchObject({ scope: "editor", defaultShortcut: "Ctrl+Mod+G" });
expect(findShortcutConflict("selectAllSelectionOccurrences", DEFAULT_SHORTCUT_SETTINGS.selectAllSelectionOccurrences, DEFAULT_SHORTCUT_SETTINGS)).toBeNull();
});
it("resolves occurrence selection defaults per platform", () => {
expect(selectionOccurrenceDefaultShortcut("addNextSelectionOccurrence", "MacIntel")).toBe("Ctrl+G");
expect(selectionOccurrenceDefaultShortcut("selectAllSelectionOccurrences", "MacIntel")).toBe("Ctrl+Mod+G");
// Ctrl+Mod+G is unreachable on non-mac (CodeMirror expands Mod→Ctrl) and
// Ctrl+G there is find-next, so Windows/Linux use the JetBrains-style keys.
expect(selectionOccurrenceDefaultShortcut("addNextSelectionOccurrence", "Win32")).toBe("Alt+J");
expect(selectionOccurrenceDefaultShortcut("selectAllSelectionOccurrences", "Win32")).toBe("Ctrl+Alt+Shift+J");
expect(selectionOccurrenceDefaultShortcut("addNextSelectionOccurrence", "Linux x86_64")).toBe("Alt+J");
expect(selectionOccurrenceDefaultShortcut("selectAllSelectionOccurrences", "Linux x86_64")).toBe("Ctrl+Alt+Shift+J");
// Both platform defaults must survive the CodeMirror key conversion.
expect(shortcutToCodeMirrorKey(selectionOccurrenceDefaultShortcut("selectAllSelectionOccurrences", "Win32"))).toBe("Ctrl-Alt-Shift-j");
expect(shortcutToCodeMirrorKey(selectionOccurrenceDefaultShortcut("addNextSelectionOccurrence", "Win32"))).toBe("Alt-j");
});
it("registers an IDEA/DataGrip-style Alt+/ shortcut for manually triggering completion", () => {
const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === "triggerCompletion");
@@ -0,0 +1,59 @@
import { EditorSelection, type EditorState } from "@codemirror/state";
import { SearchCursor, selectNextOccurrence } from "@codemirror/search";
import type { EditorView } from "@codemirror/view";
const MAX_OCCURRENCE_SELECTIONS = 1000;
function selectionOccurrenceQuery(state: EditorState): string | null {
const ranges = state.selection.ranges;
if (ranges.length === 1 && ranges[0].empty) {
const word = state.wordAt(ranges[0].head);
return word ? state.sliceDoc(word.from, word.to) : null;
}
const query = state.sliceDoc(ranges[0].from, ranges[0].to);
if (!query.trim()) return null;
return ranges.every((range) => !range.empty && state.sliceDoc(range.from, range.to) === query) ? query : null;
}
export function addNextQueryEditorSelectionOccurrence(view: EditorView): boolean {
const ranges = view.state.selection.ranges;
const hasEmptyRange = ranges.some((range) => range.empty);
if (hasEmptyRange && ranges.length > 1) {
// The single-cursor word-expansion helper cannot run with multiple
// cursors; CodeMirror's native command word-expands every empty range
// and adds the next occurrence of the shared query.
return selectNextOccurrence(view);
}
if (hasEmptyRange && !selectCurrentWord(view)) return false;
return selectNextOccurrence(view);
}
export function selectAllQueryEditorSelectionOccurrences(view: EditorView): boolean {
const query = selectionOccurrenceQuery(view.state);
if (!query) return false;
const ranges = [];
let mainIndex = 0;
const main = view.state.selection.main;
const cursor = new SearchCursor(view.state.doc, query);
for (let match = cursor.next(); !match.done; match = cursor.next()) {
if (ranges.length >= MAX_OCCURRENCE_SELECTIONS) return false;
if (match.value.from === main.from) mainIndex = ranges.length;
ranges.push(EditorSelection.range(match.value.from, match.value.to));
}
if (!ranges.length) return false;
view.dispatch({ selection: EditorSelection.create(ranges, mainIndex), userEvent: "select.search.matches" });
return true;
}
function selectCurrentWord(view: EditorView): boolean {
const selection = view.state.selection;
if (selection.ranges.length !== 1 || !selection.main.empty) return false;
const word = view.state.wordAt(selection.main.head);
if (!word || word.from === word.to) return false;
view.dispatch({ selection: EditorSelection.single(word.from, word.to), scrollIntoView: true, userEvent: "select.search" });
return true;
}
@@ -24,6 +24,8 @@ export type ShortcutActionId =
| "redo"
| "selectAll"
| "extendSelection"
| "addNextSelectionOccurrence"
| "selectAllSelectionOccurrences"
| "uppercaseSelection"
| "lowercaseSelection"
| "exPasteSqlInCondition"
@@ -98,6 +100,17 @@ export function closeOtherTabsDefaultShortcut(platform = globalThis.navigator?.p
return isMacShortcutPlatform(platform) ? "Alt+Mod+W" : "Shift+Alt+W";
}
// 同词项选择的平台相关默认键。Windows/Linux 上 Ctrl+Mod+G 经 CodeMirror 的
// Mod→Ctrl 展开后成为不可达的 Ctrl-Ctrl-G,且 Ctrl+G 本身是编辑器内
// find-next(Mod-G 的非 mac 展开),因此非 mac 平台改用 JetBrains 风格的
// Alt+J / Ctrl+Alt+Shift+J;macOS 维持 Ctrl+G / Ctrl+Cmd+G。
export function selectionOccurrenceDefaultShortcut(actionId: "addNextSelectionOccurrence" | "selectAllSelectionOccurrences", platform = globalThis.navigator?.platform || ""): string {
if (isMacShortcutPlatform(platform)) {
return actionId === "addNextSelectionOccurrence" ? "Ctrl+G" : "Ctrl+Mod+G";
}
return actionId === "addNextSelectionOccurrence" ? "Alt+J" : "Ctrl+Alt+Shift+J";
}
export function tabNavigationHistoryDefaultShortcut(direction: "back" | "forward", platform = globalThis.navigator?.platform || ""): string {
const modifier = isMacShortcutPlatform(platform) ? "Ctrl" : "Mod";
const key = direction === "back" ? "ArrowLeft" : "ArrowRight";
@@ -108,6 +121,8 @@ const PLATFORM_DEFAULT_SHORTCUTS: Partial<Record<ShortcutActionId, ReadonlySet<s
closeOtherTabs: new Set(["Alt+Mod+W", "Shift+Alt+W"]),
navigateTabHistoryBack: new Set(["Ctrl+Alt+ArrowLeft", "Mod+Alt+ArrowLeft"]),
navigateTabHistoryForward: new Set(["Ctrl+Alt+ArrowRight", "Mod+Alt+ArrowRight"]),
addNextSelectionOccurrence: new Set(["Ctrl+G", "Alt+J"]),
selectAllSelectionOccurrences: new Set(["Ctrl+Mod+G", "Ctrl+Alt+Shift+J"]),
};
const LEGACY_CLOSE_TAB_DEFAULT = "Meta+W";
const LEGACY_COPY_CURRENT_ROW_DEFAULT = "Mod+D";
@@ -252,6 +267,18 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
scope: "editor",
defaultShortcut: "Alt+W",
},
{
id: "addNextSelectionOccurrence",
labelKey: "settings.shortcutAddNextSelectionOccurrence",
scope: "editor",
defaultShortcut: "Ctrl+G",
},
{
id: "selectAllSelectionOccurrences",
labelKey: "settings.shortcutSelectAllSelectionOccurrences",
scope: "editor",
defaultShortcut: "Ctrl+Mod+G",
},
{
id: "uppercaseSelection",
labelKey: "settings.shortcutUppercaseSelection",
@@ -574,6 +601,8 @@ function shortcutsUseSameKeys(first: string, second: string, platform = globalTh
}
function shortcutDefaultForPlatform(definition: ShortcutDefinition, platform: string): string {
if (definition.id === "addNextSelectionOccurrence") return selectionOccurrenceDefaultShortcut("addNextSelectionOccurrence", platform);
if (definition.id === "selectAllSelectionOccurrences") return selectionOccurrenceDefaultShortcut("selectAllSelectionOccurrences", platform);
if (definition.id === "closeOtherTabs") return closeOtherTabsDefaultShortcut(platform);
if (definition.id === "navigateTabHistoryBack") return tabNavigationHistoryDefaultShortcut("back", platform);
if (definition.id === "navigateTabHistoryForward") return tabNavigationHistoryDefaultShortcut("forward", platform);