diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index c143ca8d5a..ed96bbb673 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -140,6 +140,7 @@ import { computePasteCaretResyncTarget } from "@/lib/editor/queryEditorPasteCare import { queryEditorCommentTokens, queryEditorLineCommentToken } from "@/lib/editor/queryEditorLineComment"; import { createShellLineCommentHighlight } from "@/lib/editor/codemirrorShellLineCommentHighlight"; import { extendQueryEditorSelection, runQueryEditorAltExtendSelection } from "@/lib/editor/queryEditorExtendSelection"; +import { addNextQueryEditorSelectionOccurrence, selectAllQueryEditorSelectionOccurrences } from "@/lib/editor/queryEditorOccurrenceSelection"; import { createQueryEditorStringMouseSelection } from "@/lib/editor/queryEditorStringMouseSelection"; import { createQueryEditorCompletionShortcutBindings } from "@/lib/editor/queryEditorCompletionShortcut"; import { createQueryEditorSelectionCaseShortcutBindings } from "@/lib/editor/queryEditorSelectionCaseShortcut"; @@ -1950,6 +1951,18 @@ const contextMenuItems = computed(() => { disabled: props.readOnly || !canCopySelectedSql.value, icon: List, }, + { + label: t("editor.contextMenu.addNextSelectionOccurrence"), + action: addNextSelectionOccurrenceFromContextMenu, + icon: TextSelect, + shortcut: shortcuts.addNextSelectionOccurrence, + }, + { + label: t("editor.contextMenu.selectAllSelectionOccurrences"), + action: selectAllSelectionOccurrencesFromContextMenu, + icon: TextSelect, + shortcut: shortcuts.selectAllSelectionOccurrences, + }, { label: "", separator: true }, { label: t("editor.contextMenu.findReplace"), @@ -2114,6 +2127,8 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view") ...binding(shortcuts.redo, (view) => codeMirrorRedo?.(view) ?? false), ...binding(shortcuts.selectAll, (view) => codeMirrorSelectAll?.(view) ?? false), ...binding(shortcuts.extendSelection, extendQueryEditorSelectionForView), + ...binding(shortcuts.addNextSelectionOccurrence, addNextQueryEditorSelectionOccurrence), + ...binding(shortcuts.selectAllSelectionOccurrences, selectAllQueryEditorSelectionOccurrences), ...createQueryEditorSelectionCaseShortcutBindings(shortcuts.uppercaseSelection, () => convertSelectedSqlCase("upper")), ...createQueryEditorSelectionCaseShortcutBindings(shortcuts.lowercaseSelection, () => convertSelectedSqlCase("lower")), ...binding(shortcuts.toggleLineComment, (view) => codeMirrorToggleLineComment?.(view) ?? false), @@ -2202,6 +2217,18 @@ function extendQueryEditorSelectionForView(currentView: EditorViewType): boolean }); } +function addNextSelectionOccurrenceFromContextMenu() { + if (!view.value) return; + addNextQueryEditorSelectionOccurrence(view.value); + focusEditor(); +} + +function selectAllSelectionOccurrencesFromContextMenu() { + if (!view.value) return; + selectAllQueryEditorSelectionOccurrences(view.value); + focusEditor(); +} + function acceptCompletionOrNextSnippetField(view: EditorViewType): boolean { // Any non-empty selection range means Tab is being used for block indent, // not word completion. A completion popup can still appear as a side effect diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 1b66ba8988..d036e4990a 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1372,6 +1372,8 @@ export default { uppercaseSelection: "Convert to uppercase", lowercaseSelection: "Convert to lowercase", delimitedList: "Convert to delimited list", + addNextSelectionOccurrence: "Add next occurrence", + selectAllSelectionOccurrences: "Select all occurrences", expandSelectStar: "Expand * to columns", expandSelectStarUnavailable: "Unable to load columns for this table", findReplace: "Find/Replace", @@ -6844,6 +6846,8 @@ export default { shortcutUndo: "Undo", shortcutRedo: "Redo", shortcutSelectAll: "Select all", + shortcutAddNextSelectionOccurrence: "Add next occurrence", + shortcutSelectAllSelectionOccurrences: "Select all occurrences", shortcutExtendSelection: "Extend selection", shortcutEditTableStructure: "Edit table structure", shortcutCopyCurrentRow: "Copy current data row", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index fcf19413b9..9ed09eed28 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1344,6 +1344,8 @@ export default withEnglishFallback({ lowercaseSelection: "Convertir a minusculas", selectAll: "Seleccionar todo", delimitedList: "Convertir a lista delimitada", + addNextSelectionOccurrence: "Añadir siguiente coincidencia", + selectAllSelectionOccurrences: "Seleccionar todas las coincidencias", expandSelectStar: "Expandir * a columnas", expandSelectStarUnavailable: "No se pudieron cargar las columnas de esta tabla", findReplace: "Buscar/Reemplazar", @@ -6471,6 +6473,8 @@ export default withEnglishFallback({ shortcutUndo: "Deshacer", shortcutRedo: "Rehacer", shortcutSelectAll: "Seleccionar todo", + shortcutAddNextSelectionOccurrence: "Añadir siguiente coincidencia", + shortcutSelectAllSelectionOccurrences: "Seleccionar todas las coincidencias", shortcutExtendSelection: "Ampliar selección", shortcutEditTableStructure: "Editar estructura de tabla", shortcutCopyCurrentRow: "Copiar fila de datos actual", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index ccf08fb66a..76c56daf09 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1342,6 +1342,8 @@ export default withEnglishFallback({ lowercaseSelection: "Converti in minuscolo", selectAll: "Seleziona tutto", delimitedList: "Converti in lista delimitata", + addNextSelectionOccurrence: "Aggiungi occorrenza successiva", + selectAllSelectionOccurrences: "Seleziona tutte le occorrenze", expandSelectStar: "Espandi * in colonne", expandSelectStarUnavailable: "Impossibile caricare le colonne di questa tabella", findReplace: "Trova/Sostituisci", @@ -6471,6 +6473,8 @@ export default withEnglishFallback({ shortcutUndo: "Annulla", shortcutRedo: "Ripeti", shortcutSelectAll: "Seleziona tutto", + shortcutAddNextSelectionOccurrence: "Aggiungi occorrenza successiva", + shortcutSelectAllSelectionOccurrences: "Seleziona tutte le occorrenze", shortcutExtendSelection: "Estendi selezione", shortcutEditTableStructure: "Modifica struttura tabella", shortcutCopyCurrentRow: "Copia riga dati corrente", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index a7fb675e7f..285c3eae59 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1362,6 +1362,8 @@ export default withEnglishFallback({ lowercaseSelection: "小文字に変換", selectAll: "すべて選択", delimitedList: "区切り文字付きリストに変換", + addNextSelectionOccurrence: "次の出現箇所を選択に追加", + selectAllSelectionOccurrences: "すべての出現箇所を選択", expandSelectStar: "* を列に展開", expandSelectStarUnavailable: "このテーブルの列を読み込めません", findReplace: "検索/置換", @@ -6479,6 +6481,8 @@ export default withEnglishFallback({ shortcutUndo: "元に戻す", shortcutRedo: "やり直す", shortcutSelectAll: "すべて選択", + shortcutAddNextSelectionOccurrence: "次の出現箇所を選択に追加", + shortcutSelectAllSelectionOccurrences: "すべての出現箇所を選択", shortcutExtendSelection: "選択範囲を拡張", shortcutEditTableStructure: "テーブル構造を編集", shortcutCopyCurrentRow: "現在のデータ行をコピー", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 752a5b980c..8d3bb2b469 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1255,6 +1255,8 @@ export default withEnglishFallback({ uppercaseSelection: "대문자로 변환", lowercaseSelection: "소문자로 변환", delimitedList: "구분자 목록으로 변환", + addNextSelectionOccurrence: "다음 일치 항목 선택 추가", + selectAllSelectionOccurrences: "모든 일치 항목 선택", expandSelectStar: "*을(를) 열로 확장", expandSelectStarUnavailable: "이 테이블의 열을 불러올 수 없습니다", findReplace: "찾기/바꾸기", @@ -6222,6 +6224,8 @@ export default withEnglishFallback({ shortcutUndo: "실행 취소", shortcutRedo: "다시 실행", shortcutSelectAll: "모두 선택", + shortcutAddNextSelectionOccurrence: "다음 일치 항목 선택 추가", + shortcutSelectAllSelectionOccurrences: "모든 일치 항목 선택", shortcutEditTableStructure: "테이블 구조 편집", shortcutCopyCurrentRow: "현재 데이터 행 복사", shortcutDeleteCurrentRow: "현재 데이터 행 삭제", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index bb639e5d0e..e84cde10df 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1343,6 +1343,8 @@ export default withEnglishFallback({ lowercaseSelection: "Converter para minúsculas", selectAll: "Selecionar tudo", delimitedList: "Converter para lista delimitada", + addNextSelectionOccurrence: "Adicionar próxima ocorrência", + selectAllSelectionOccurrences: "Selecionar todas as ocorrências", expandSelectStar: "Expandir * em colunas", expandSelectStarUnavailable: "Não foi possível carregar as colunas desta tabela", findReplace: "Localizar/substituir", @@ -6473,6 +6475,8 @@ export default withEnglishFallback({ shortcutUndo: "Desfazer", shortcutRedo: "Refazer", shortcutSelectAll: "Selecionar tudo", + shortcutAddNextSelectionOccurrence: "Adicionar próxima ocorrência", + shortcutSelectAllSelectionOccurrences: "Selecionar todas as ocorrências", shortcutExtendSelection: "Expandir seleção", shortcutEditTableStructure: "Editar estrutura da tabela", shortcutCopyCurrentRow: "Copiar linha de dados atual", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index ac276e74f2..42cbb03cb5 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1296,6 +1296,8 @@ export default withEnglishFallback({ uppercaseSelection: "转为大写", lowercaseSelection: "转为小写", delimitedList: "转换为带分隔符的列表", + addNextSelectionOccurrence: "增加下一个选中词", + selectAllSelectionOccurrences: "选中所有选中词", expandSelectStar: "将 * 展开为字段", expandSelectStarUnavailable: "无法读取该表的字段信息", findReplace: "查找/替换", @@ -6825,6 +6827,8 @@ export default withEnglishFallback({ shortcutUndo: "撤销", shortcutRedo: "重做", shortcutSelectAll: "全选", + shortcutAddNextSelectionOccurrence: "增加下一个选中词", + shortcutSelectAllSelectionOccurrences: "选中所有选中词", shortcutExtendSelection: "扩展选择", shortcutUppercaseSelection: "选中内容转为大写", shortcutLowercaseSelection: "选中内容转为小写", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index a2b291566b..9d50469a3f 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1342,6 +1342,8 @@ export default withEnglishFallback({ lowercaseSelection: "轉為小寫", selectAll: "全選", delimitedList: "轉換為帶分隔符的清單", + addNextSelectionOccurrence: "增加下一個選取詞", + selectAllSelectionOccurrences: "選取所有選取詞", expandSelectStar: "將 * 展開為欄位", expandSelectStarUnavailable: "無法讀取此資料表的欄位資訊", findReplace: "查找/取代", @@ -5776,6 +5778,8 @@ export default withEnglishFallback({ shortcutUndo: "復原", shortcutRedo: "重做", shortcutSelectAll: "全選", + shortcutAddNextSelectionOccurrence: "增加下一個選取詞", + shortcutSelectAllSelectionOccurrences: "選取所有選取詞", shortcutExtendSelection: "擴展選取範圍", shortcutEditTableStructure: "編輯資料表結構", shortcutCopyCurrentRow: "複製目前資料列", diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorOccurrenceSelection.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorOccurrenceSelection.spec.ts new file mode 100644 index 0000000000..0904deb611 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorOccurrenceSelection.spec.ts @@ -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 }, + ]); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts index 751fd01c1d..6df0648def 100644 --- a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts @@ -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"); diff --git a/apps/desktop/src/lib/editor/queryEditorOccurrenceSelection.ts b/apps/desktop/src/lib/editor/queryEditorOccurrenceSelection.ts new file mode 100644 index 0000000000..cdf99f879b --- /dev/null +++ b/apps/desktop/src/lib/editor/queryEditorOccurrenceSelection.ts @@ -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; +} diff --git a/apps/desktop/src/lib/editor/shortcutRegistry.ts b/apps/desktop/src/lib/editor/shortcutRegistry.ts index 0118460aa3..de90e4feb4 100644 --- a/apps/desktop/src/lib/editor/shortcutRegistry.ts +++ b/apps/desktop/src/lib/editor/shortcutRegistry.ts @@ -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