diff --git a/apps/desktop/src/components/editor/EditorSearchPanel.vue b/apps/desktop/src/components/editor/EditorSearchPanel.vue index 928ec2b50..50fa8a580 100644 --- a/apps/desktop/src/components/editor/EditorSearchPanel.vue +++ b/apps/desktop/src/components/editor/EditorSearchPanel.vue @@ -4,8 +4,10 @@ import { useI18n } from "vue-i18n"; import type { EditorView } from "@codemirror/view"; import { EditorSelection } from "@codemirror/state"; import { setSearchQuery, openSearchPanel as cmOpenSearchPanel, findNext as cmFindNext, findPrevious as cmFindPrevious, replaceNext as cmReplaceNext, replaceAll as cmReplaceAll } from "@codemirror/search"; -import { ChevronUp, ChevronDown, ChevronRight, X } from "@lucide/vue"; -import { collectEditorSearchMatches, createEditorSearchQuery, replaceEditorSearchMatches } from "@/lib/editor/editorSearchQuery"; +import { ChevronUp, ChevronDown, ChevronRight, TextSelect, X } from "@lucide/vue"; +import { collectEditorSearchMatches, countEditorSearchMatches, createEditorSearchQuery, replaceEditorSearchMatches, type EditorSearchMatch } from "@/lib/editor/editorSearchQuery"; +import { appendSearchMatchSelection, findSearchMatch, isSearchAddSelectionModifier, selectionRangesForSearchMatches, type EditorSearchSelectionDirection } from "@/lib/editor/editorSearchSelection"; +import { useSettingsStore } from "@/stores/settingsStore"; const props = defineProps<{ view: EditorView | null; @@ -13,6 +15,7 @@ const props = defineProps<{ }>(); const { t } = useI18n(); +const settingsStore = useSettingsStore(); const searchVisible = ref(false); const searchText = ref(""); @@ -24,7 +27,7 @@ const matchCount = ref(0); const currentMatchIndex = ref(0); const searchInputRef = ref(); const replaceInputRef = ref(); -const matchCountLimited = ref(false); +const selectionLimitReached = ref(false); // Scoped search: restrict find/replace to the original selection range let searchScopeFrom: number | null = null; @@ -33,11 +36,13 @@ const inSelectionScope = ref(false); const SEARCH_UPDATE_DELAY_MS = 120; const DOCUMENT_SEARCH_UPDATE_DELAY_MS = 500; -const MATCH_COUNT_LIMIT = 1000; - let searchUpdateTimer: ReturnType | null = null; let documentSearchUpdateTimer: ReturnType | null = null; +function searchMatchLimit(): number { + return settingsStore.editorSettings.regexMaxMatchCount; +} + function clearDocumentSearchUpdate() { if (!documentSearchUpdateTimer) return; clearTimeout(documentSearchUpdateTimer); @@ -72,7 +77,7 @@ function clearSearchQuery() { }); matchCount.value = 0; currentMatchIndex.value = 0; - matchCountLimited.value = false; + selectionLimitReached.value = false; } /** @@ -98,7 +103,7 @@ function computeReplacementForMatch(v: EditorView, matchFrom: number, matchTo: n /** * Collect all search matches within the scoped range. */ -function collectScopedMatches(v: EditorView, limit = MATCH_COUNT_LIMIT) { +function collectScopedMatches(v: EditorView, limit = Number.POSITIVE_INFINITY) { if (searchScopeFrom == null || searchScopeTo == null) return null; const q = createEditorSearchQuery({ search: searchText.value, @@ -109,6 +114,33 @@ function collectScopedMatches(v: EditorView, limit = MATCH_COUNT_LIMIT) { return collectEditorSearchMatches(q, v.state, searchScopeFrom, searchScopeTo, limit); } +function collectAllMatches(v: EditorView, limit = Number.POSITIVE_INFINITY) { + if (!searchText.value) return []; + const query = createEditorSearchQuery({ + search: searchText.value, + caseSensitive: caseSensitive.value, + useRegex: useRegex.value, + }); + if (!query.valid) return []; + return collectEditorSearchMatches(query, v.state, searchScopeFrom ?? 0, searchScopeTo ?? v.state.doc.length, limit); +} + +function* iterateAllMatches(v: EditorView): Generator { + if (!searchText.value) return; + const query = createEditorSearchQuery({ + search: searchText.value, + caseSensitive: caseSensitive.value, + useRegex: useRegex.value, + }); + if (!query.valid) return; + const from = searchScopeFrom ?? 0; + const to = searchScopeTo ?? v.state.doc.length; + const cursor = query.getCursor(v.state); + for (let result = cursor.next(); !result.done; result = cursor.next()) { + if (result.value.from >= from && result.value.to <= to) yield { from: result.value.from, to: result.value.to }; + } +} + /** * Find next/previous match within the scoped range. * Returns true if a match was found. @@ -116,18 +148,9 @@ function collectScopedMatches(v: EditorView, limit = MATCH_COUNT_LIMIT) { function findInScope(direction: "next" | "prev"): boolean { const v = props.view; if (!v || !searchText.value || searchScopeFrom == null || searchScopeTo == null) return false; - const matches = collectScopedMatches(v); - if (!matches || matches.length === 0) return false; - - const cursor = v.state.selection.main.head; - let target: { from: number; to: number } | null = null; - - if (direction === "next") { - target = matches.find((m) => m.from >= cursor) ?? matches.find((m) => m.from >= searchScopeFrom!) ?? null; - } else { - const before = matches.filter((m) => m.to <= cursor); - target = before.length > 0 ? before[before.length - 1] : matches[matches.length - 1]; - } + const selection = v.state.selection.main; + const cursor = direction === "next" ? selection.head : selection.from; + const target = findSearchMatch(iterateAllMatches(v), cursor, direction); if (target) { v.dispatch({ @@ -144,27 +167,24 @@ function updateMatchInfo(autoSelect = false) { if (!v || !searchText.value) { matchCount.value = 0; currentMatchIndex.value = 0; - matchCountLimited.value = false; return; } + if (selectionLimitReached.value && v.state.selection.ranges.length !== searchMatchLimit()) selectionLimitReached.value = false; try { // Scoped: use custom find logic if (searchScopeFrom != null && searchScopeTo != null) { if (autoSelect) findInScope("next"); - const matches = collectScopedMatches(v); - if (!matches) { - matchCount.value = 0; - currentMatchIndex.value = 0; - matchCountLimited.value = false; - return; - } - const count = matches.length; - matchCount.value = count; - matchCountLimited.value = count >= MATCH_COUNT_LIMIT; + const q = createEditorSearchQuery({ + search: searchText.value, + caseSensitive: caseSensitive.value, + useRegex: useRegex.value, + }); + if (!q.valid) return; const selFrom = v.state.selection.main.from; const selTo = v.state.selection.main.to; - const idx = matches.findIndex((m) => m.from === selFrom && m.to === selTo); - currentMatchIndex.value = idx >= 0 ? idx + 1 : count > 0 ? 1 : 0; + const { count, currentIndex } = countEditorSearchMatches(q, v.state, searchScopeFrom, searchScopeTo, { from: selFrom, to: selTo }); + matchCount.value = count; + currentMatchIndex.value = currentIndex || (count > 0 ? 1 : 0); return; } @@ -177,31 +197,19 @@ function updateMatchInfo(autoSelect = false) { if (!q.valid) { matchCount.value = 0; currentMatchIndex.value = 0; - matchCountLimited.value = false; return; } if (autoSelect) { cmFindNext(v); } - const iter = q.getCursor(v.state); - let count = 0; - let curIdx = 0; const selFrom = v.state.selection.main.from; const selTo = v.state.selection.main.to; - let r = iter.next(); - while (!r.done) { - count++; - if (r.value.from === selFrom && r.value.to === selTo) curIdx = count; - if (count >= MATCH_COUNT_LIMIT) break; - r = iter.next(); - } + const { count, currentIndex } = countEditorSearchMatches(q, v.state, 0, v.state.doc.length, { from: selFrom, to: selTo }); matchCount.value = count; - matchCountLimited.value = count >= MATCH_COUNT_LIMIT && !r.done; - currentMatchIndex.value = curIdx || (count > 0 ? 1 : 0); + currentMatchIndex.value = currentIndex || (count > 0 ? 1 : 0); } catch { matchCount.value = 0; currentMatchIndex.value = 0; - matchCountLimited.value = false; } } @@ -215,6 +223,7 @@ function scheduleSearchUpdate(autoSelect = false) { clearSearchQuery(); return; } + selectionLimitReached.value = false; dispatchSearchQuery(); searchUpdateTimer = setTimeout(() => { searchUpdateTimer = null; @@ -286,7 +295,37 @@ function closeSearch() { return wasVisible; } -function nextMatch() { +function selectAllMatches() { + const v = props.view; + if (!v) return false; + const limit = searchMatchLimit(); + const matches = collectAllMatches(v, limit + 1); + selectionLimitReached.value = matches.length > limit; + const ranges = selectionRangesForSearchMatches(matches.slice(0, limit)); + if (ranges.length === 0) return false; + v.dispatch({ selection: EditorSelection.create(ranges), scrollIntoView: true }); + updateMatchInfo(); + v.focus(); + return true; +} + +function appendMatch(direction: EditorSearchSelectionDirection) { + const v = props.view; + if (!v) return false; + const selection = appendSearchMatchSelection(v.state.selection, iterateAllMatches(v), direction); + if (!selection) return false; + v.dispatch({ selection, scrollIntoView: true }); + updateMatchInfo(); + v.focus(); + return true; +} + +function nextMatch(event?: MouseEvent) { + if (event && isSearchAddSelectionModifier(event)) { + event.preventDefault(); + appendMatch("next"); + return; + } const v = props.view; if (!v || !searchText.value) return; if (searchScopeFrom != null) { @@ -297,7 +336,12 @@ function nextMatch() { updateMatchInfo(); } -function prevMatch() { +function prevMatch(event?: MouseEvent) { + if (event && isSearchAddSelectionModifier(event)) { + event.preventDefault(); + appendMatch("prev"); + return; + } const v = props.view; if (!v || !searchText.value) return; if (searchScopeFrom != null) { @@ -443,9 +487,18 @@ defineExpose({ .* - - {{ searchText && matchCount > 0 ? `${currentMatchIndex}/${matchCount}${matchCountLimited ? "+" : ""}` : t("editor.search.noResults") }} + + {{ selectionLimitReached ? t("editor.search.selectionLimitSummary", { limit: searchMatchLimit(), total: matchCount }) : searchText && matchCount > 0 ? `${currentMatchIndex}/${matchCount}` : t("editor.search.noResults") }} + {{ t("editor.search.inSelection") }} diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 8e3df36e5..c62afcb98 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -336,6 +336,7 @@ const editDataGridAutoTransposeSingleRow = ref(settingsStore.editorSettings.data const editTableOpenPageSize = ref(settingsStore.editorSettings.tableOpenPageSize); const editInfiniteScroll = ref(settingsStore.editorSettings.infiniteScroll); const editInfiniteScrollMaxRows = ref(settingsStore.editorSettings.infiniteScrollMaxRows); +const editRegexMaxMatchCount = ref(settingsStore.editorSettings.regexMaxMatchCount); const editAutoCalculateTotalRows = ref(settingsStore.editorSettings.autoCalculateTotalRows); const editTableColumnTemplateRows = ref(tableColumnTemplateRowsFromSettings(settingsStore.editorSettings.tableColumnTemplateFields)); const editTableColumnTemplateDatabaseType = ref(TABLE_COLUMN_TEMPLATE_DATABASE_TYPES[0] ?? "mysql"); @@ -486,6 +487,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { tableOpenPageSize: editTableOpenPageSize.value, infiniteScroll: editInfiniteScroll.value, infiniteScrollMaxRows: editInfiniteScrollMaxRows.value, + regexMaxMatchCount: editRegexMaxMatchCount.value, autoCalculateTotalRows: editAutoCalculateTotalRows.value, tableColumnTemplateFields: normalizedEditTableColumnTemplateFields.value, shortcuts: editShortcuts.value, @@ -752,6 +754,7 @@ function syncEditorSettingsDraftFromStore() { editTableOpenPageSize.value = settingsStore.editorSettings.tableOpenPageSize; editInfiniteScroll.value = settingsStore.editorSettings.infiniteScroll; editInfiniteScrollMaxRows.value = settingsStore.editorSettings.infiniteScrollMaxRows; + editRegexMaxMatchCount.value = settingsStore.editorSettings.regexMaxMatchCount; editAutoCalculateTotalRows.value = settingsStore.editorSettings.autoCalculateTotalRows; editTableColumnTemplateRows.value = tableColumnTemplateRowsFromSettings(settingsStore.editorSettings.tableColumnTemplateFields); editShortcuts.value = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts); @@ -988,6 +991,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editTableOpenPageSize.value = DEFAULT_EDITOR_SETTINGS.tableOpenPageSize; editInfiniteScroll.value = DEFAULT_EDITOR_SETTINGS.infiniteScroll; editInfiniteScrollMaxRows.value = DEFAULT_EDITOR_SETTINGS.infiniteScrollMaxRows; + editRegexMaxMatchCount.value = DEFAULT_EDITOR_SETTINGS.regexMaxMatchCount; editAutoCalculateTotalRows.value = DEFAULT_EDITOR_SETTINGS.autoCalculateTotalRows; editDuckDbWorkerProcessIsolation.value = DEFAULT_DESKTOP_SETTINGS.duckdb_worker_process_isolation; editDuckDbWorkerMaxProcesses.value = DEFAULT_DESKTOP_SETTINGS.duckdb_worker_max_processes; @@ -1050,6 +1054,7 @@ function resetAllDefaults() { editTableOpenPageSize.value = DEFAULT_EDITOR_SETTINGS.tableOpenPageSize; editInfiniteScroll.value = DEFAULT_EDITOR_SETTINGS.infiniteScroll; editInfiniteScrollMaxRows.value = DEFAULT_EDITOR_SETTINGS.infiniteScrollMaxRows; + editRegexMaxMatchCount.value = DEFAULT_EDITOR_SETTINGS.regexMaxMatchCount; editAutoCalculateTotalRows.value = DEFAULT_EDITOR_SETTINGS.autoCalculateTotalRows; editTableColumnTemplateRows.value = tableColumnTemplateRowsFromSettings(DEFAULT_EDITOR_SETTINGS.tableColumnTemplateFields); editShortcuts.value = normalizeShortcutSettings(DEFAULT_EDITOR_SETTINGS.shortcuts); @@ -3651,6 +3656,22 @@ onUnmounted(() => { +
+
+ +

{{ t("settings.regexMaxMatchCountDescription") }}

+
+ +
+
diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index be5021142..6cf00ffeb 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -89,6 +89,7 @@ import { createDbxCodeMirrorSqlDialect, type CodeMirrorSqlDialectName } from "@/ import { sqlSemanticTableNameSpansForSyntaxTree } from "@/lib/editor/codemirrorSqlSemanticHighlight"; import { startsQueryEditorRectangularSelection, usesQueryEditorObjectNavigationModifier } from "@/lib/editor/queryEditorPointerSelection"; import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste"; +import { extendQueryEditorSelection, runQueryEditorAltExtendSelection } from "@/lib/editor/queryEditorExtendSelection"; import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation"; import { isSchemaAware, isSingleDatabase, supportsDatabaseNameCompletion, supportsDatabaseSchemaQualifier, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport"; import { metadataSchemaForConnection, sqlSnippetDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; @@ -1439,6 +1440,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view") ...binding(shortcuts.undo, (view) => codeMirrorUndo?.(view) ?? false), ...binding(shortcuts.redo, (view) => codeMirrorRedo?.(view) ?? false), ...binding(shortcuts.selectAll, (view) => codeMirrorSelectAll?.(view) ?? false), + ...binding(shortcuts.extendSelection, extendQueryEditorSelectionForView), ...binding(shortcuts.uppercaseSelection, () => convertSelectedSqlCase("upper")), ...binding(shortcuts.lowercaseSelection, () => convertSelectedSqlCase("lower")), ...binding(shortcuts.toggleLineComment, (view) => codeMirrorToggleLineComment?.(view) ?? false), @@ -1469,6 +1471,16 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view") ]; } +function extendQueryEditorSelectionForView(currentView: EditorViewType): boolean { + const databaseType = props.databaseType; + const language = databaseType === "redis" || databaseType === "mongodb" || databaseType === "elasticsearch" ? "text" : "sql"; + return extendQueryEditorSelection(currentView, { + databaseType, + dialect: props.syntaxDialect ?? props.dialect, + language, + }); +} + function acceptCompletionOrNextSnippetField(view: EditorViewType): boolean { const completionStatus = codeMirrorCompletionStatus?.(view.state) ?? null; if (completionStatus === "active" && (codeMirrorAcceptCompletion?.(view) ?? false)) return true; @@ -4130,6 +4142,14 @@ onMounted(async () => { ]), ), runKeymapComp.of(runKeymapExtension(keymap)), + Prec.highest( + EditorView.domEventHandlers({ + keydown(event, currentView) { + const shortcuts = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts); + return runQueryEditorAltExtendSelection(event, shortcuts.extendSelection, currentView, extendQueryEditorSelectionForView); + }, + }), + ), wordWrapComp.of(props.forceWordWrap || initialSettings.wordWrap ? EditorView.lineWrapping : []), readOnlyComp.of([EditorState.readOnly.of(!!props.readOnly), EditorView.editable.of(!props.readOnly)]), indentComp.of(indentExtension()), diff --git a/apps/desktop/src/components/nacos/NacosAdminConsole.vue b/apps/desktop/src/components/nacos/NacosAdminConsole.vue index 65604b558..31fc70f41 100644 --- a/apps/desktop/src/components/nacos/NacosAdminConsole.vue +++ b/apps/desktop/src/components/nacos/NacosAdminConsole.vue @@ -375,6 +375,7 @@ async function mountConfigEditor() { }, }), basicSetup, + EditorState.allowMultipleSelections.of(true), trimmedSelectionLayer(), Prec.highest(keymap.of([{ key: "Mod-f", run: () => configSearchPanelRef.value?.openSearch() ?? false, preventDefault: true }, { key: "Mod-h", run: () => configSearchPanelRef.value?.openReplace() ?? false, preventDefault: true }, indentWithTab])), keymap.of([...defaultKeymap, ...historyKeymap]), diff --git a/apps/desktop/src/components/objects/DdlViewDialog.vue b/apps/desktop/src/components/objects/DdlViewDialog.vue index 2ce3a89de..c478be260 100644 --- a/apps/desktop/src/components/objects/DdlViewDialog.vue +++ b/apps/desktop/src/components/objects/DdlViewDialog.vue @@ -121,6 +121,7 @@ async function initDdlEditor(content: string) { }, }), basicSetup, + EditorState.allowMultipleSelections.of(true), langSql.sql({ dialect }), themeExt, fontExt, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 3a829c7f6..69731b744 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -885,6 +885,10 @@ export default { expandReplace: "Expand replace", prevMatch: "Previous (Shift+Enter)", nextMatch: "Next (Enter)", + selectAll: "Select all matches", + selectAllLimit: "Select all matches (up to {limit})", + selectionTruncated: "Found {total} matches; selected the first {limit}.", + selectionLimitSummary: "Selected {limit}/{total}", close: "Close (Esc)", noResults: "No results", inSelection: "In selection", @@ -4787,6 +4791,8 @@ export default { infiniteScrollDescription: "Automatically load the next page of data when scrolling to the bottom of the table.", infiniteScrollMaxRows: "Infinite scroll max rows", infiniteScrollMaxRowsDescription: "Maximum number of rows to load in infinite scroll mode (1000–50000).", + regexMaxMatchCount: "Maximum select-all matches", + regexMaxMatchCountDescription: "Limits the number of selections created by Select All Matches; search counting remains complete (100–10000).", tableColumnTemplateFields: "New Table Preset Fields", tableColumnTemplateFieldsDescription: "Choose a database type, then configure the preset field types used when creating new tables.", tableColumnTemplateAdd: "Add Field", @@ -5024,6 +5030,7 @@ export default { shortcutUndo: "Undo", shortcutRedo: "Redo", shortcutSelectAll: "Select all", + shortcutExtendSelection: "Extend selection", shortcutCopyCurrentRow: "Copy current data row", shortcutDeleteCurrentRow: "Delete current data row", shortcutNewQuery: "New query", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 8de78bfe1..8c1531c86 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -851,6 +851,10 @@ export default withEnglishFallback({ expandReplace: "Expandir reemplazo", prevMatch: "Anterior (Shift+Enter)", nextMatch: "Siguiente (Enter)", + selectAll: "Seleccionar todas las coincidencias", + selectAllLimit: "Seleccionar todas las coincidencias (hasta {limit})", + selectionTruncated: "Se encontraron {total} coincidencias; se seleccionaron las primeras {limit}.", + selectionLimitSummary: "Seleccionadas {limit}/{total}", close: "Cerrar (Esc)", noResults: "Sin resultados", inSelection: "En la selección", @@ -4567,6 +4571,8 @@ export default withEnglishFallback({ infiniteScrollDescription: "Carga automáticamente la siguiente página de datos al desplazarse hasta el final de la tabla.", infiniteScrollMaxRows: "Máximo de filas en desplazamiento infinito", infiniteScrollMaxRowsDescription: "Número máximo de filas a cargar en modo desplazamiento infinito (1000–50000).", + regexMaxMatchCount: "Máximo de coincidencias al seleccionar todas", + regexMaxMatchCountDescription: "Limita el número de selecciones creadas por Seleccionar todas las coincidencias; el conteo de búsqueda permanece completo (100–10000).", tableColumnTemplateFields: "Campos predefinidos para tablas nuevas", tableColumnTemplateFieldsDescription: "Elige un tipo de base de datos y configura los tipos de campo predefinidos usados al crear tablas nuevas.", tableColumnTemplateAdd: "Agregar campo", @@ -4761,6 +4767,7 @@ export default withEnglishFallback({ shortcutUndo: "Deshacer", shortcutRedo: "Rehacer", shortcutSelectAll: "Seleccionar todo", + shortcutExtendSelection: "Ampliar selección", shortcutCopyCurrentRow: "Copiar fila de datos actual", shortcutDeleteCurrentRow: "Eliminar fila de datos actual", shortcutNewQuery: "Nueva consulta", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index d7f307f50..aad9b4cee 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -849,6 +849,10 @@ export default withEnglishFallback({ expandReplace: "Espandi sostituzione", prevMatch: "Precedente (Shift+Enter)", nextMatch: "Successivo (Enter)", + selectAll: "Seleziona tutte le corrispondenze", + selectAllLimit: "Seleziona tutte le corrispondenze (fino a {limit})", + selectionTruncated: "Trovate {total} corrispondenze; sono selezionate le prime {limit}.", + selectionLimitSummary: "Selezionate {limit}/{total}", close: "Chiudi (Esc)", noResults: "Nessun risultato", inSelection: "Nella selezione", @@ -4567,6 +4571,8 @@ export default withEnglishFallback({ infiniteScrollDescription: "Carica automaticamente la pagina successiva dei dati quando scorri fino in fondo alla tabella.", infiniteScrollMaxRows: "Righe max a scorrimento infinito", infiniteScrollMaxRowsDescription: "Numero massimo di righe da caricare in modalità scorrimento infinito (1000–50000).", + regexMaxMatchCount: "Numero massimo di corrispondenze per Seleziona tutte", + regexMaxMatchCountDescription: "Limita il numero di selezioni create da Seleziona tutte le corrispondenze; il conteggio della ricerca rimane completo (100–10000).", tableColumnTemplateFields: "Campi predefiniti per nuove tabelle", tableColumnTemplateFieldsDescription: "Scegli un tipo di database e configura i tipi dei campi predefiniti usati durante la creazione di nuove tabelle.", tableColumnTemplateAdd: "Aggiungi campo", @@ -4761,6 +4767,7 @@ export default withEnglishFallback({ shortcutUndo: "Annulla", shortcutRedo: "Ripeti", shortcutSelectAll: "Seleziona tutto", + shortcutExtendSelection: "Estendi selezione", shortcutCopyCurrentRow: "Copia riga dati corrente", shortcutDeleteCurrentRow: "Elimina riga dati corrente", shortcutNewQuery: "Nuova query", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 22af552d9..95e5aed52 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -869,6 +869,10 @@ export default withEnglishFallback({ expandReplace: "置換を展開", prevMatch: "前へ (Shift+Enter)", nextMatch: "次へ (Enter)", + selectAll: "すべての一致を選択", + selectAllLimit: "すべての一致を選択(最大 {limit} 件)", + selectionTruncated: "{total} 件の一致が見つかりました。先頭 {limit} 件を選択しました。", + selectionLimitSummary: "{limit}/{total} 件を選択", close: "閉じる (Esc)", noResults: "結果なし", inSelection: "選択範囲内", @@ -4785,6 +4789,7 @@ export default withEnglishFallback({ shortcutUndo: "元に戻す", shortcutRedo: "やり直す", shortcutSelectAll: "すべて選択", + shortcutExtendSelection: "選択範囲を拡張", shortcutCopyCurrentRow: "現在のデータ行をコピー", shortcutDeleteCurrentRow: "現在のデータ行を削除", shortcutNewQuery: "新しいクエリ", @@ -4977,6 +4982,8 @@ export default withEnglishFallback({ infiniteScrollDescription: "テーブルのスクロール時に次のページのデータを自動読み込みします。", infiniteScrollMaxRows: "無限スクロールの最大行数", infiniteScrollMaxRowsDescription: "無限スクロールモードで読み込む最大行数(1000〜50000)。", + regexMaxMatchCount: "すべての一致を選択する最大数", + regexMaxMatchCountDescription: "「すべての一致を選択」で一度に作成する選択範囲の数を制限します。検索の一致数は完全に集計されます(100〜10000)。", exportRowLimitEnabled: "エクスポート行数を制限", exportRowLimitEnabledDescription: "有効時、クエリ結果とテーブルデータのエクスポートは以下の行数制限で停止します。", exportRowLimit: "データエクスポートの行数制限", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 88e3b5a12..e8c38aafe 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -850,6 +850,10 @@ export default withEnglishFallback({ expandReplace: "Expandir substituição", prevMatch: "Anterior (Shift+Enter)", nextMatch: "Próximo (Enter)", + selectAll: "Selecionar todas as correspondências", + selectAllLimit: "Selecionar todas as correspondências (até {limit})", + selectionTruncated: "Foram encontradas {total} correspondências; as primeiras {limit} foram selecionadas.", + selectionLimitSummary: "Selecionadas {limit}/{total}", close: "Fechar (Esc)", noResults: "Nenhum resultado", inSelection: "Na seleção", @@ -4569,6 +4573,8 @@ export default withEnglishFallback({ infiniteScrollDescription: "Carregar automaticamente a próxima página de dados ao rolar até o final da tabela.", infiniteScrollMaxRows: "Máximo de linhas em rolagem infinita", infiniteScrollMaxRowsDescription: "Número máximo de linhas a carregar no modo de rolagem infinita (1000–50000).", + regexMaxMatchCount: "Máximo de correspondências para selecionar todas", + regexMaxMatchCountDescription: "Limita o número de seleções criadas por Selecionar todas as correspondências; a contagem da pesquisa permanece completa (100–10000).", tableColumnTemplateFields: "Campos predefinidos para novas tabelas", tableColumnTemplateFieldsDescription: "Escolha um tipo de banco de dados e configure os tipos dos campos predefinidos usados ao criar novas tabelas.", tableColumnTemplateAdd: "Adicionar campo", @@ -4763,6 +4769,7 @@ export default withEnglishFallback({ shortcutUndo: "Desfazer", shortcutRedo: "Refazer", shortcutSelectAll: "Selecionar tudo", + shortcutExtendSelection: "Expandir seleção", shortcutCopyCurrentRow: "Copiar linha de dados atual", shortcutDeleteCurrentRow: "Excluir linha de dados atual", shortcutNewQuery: "Nova consulta", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 7b755bf6f..c1be3c660 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -886,6 +886,10 @@ export default withEnglishFallback({ expandReplace: "展开替换", prevMatch: "上一个 (Shift+Enter)", nextMatch: "下一个 (Enter)", + selectAll: "全部选中", + selectAllLimit: "全部选中(最多 {limit} 项)", + selectionTruncated: "共找到 {total} 项,仅选中前 {limit} 项。", + selectionLimitSummary: "已选 {limit}/{total}", close: "关闭 (Esc)", noResults: "无结果", inSelection: "选区内", @@ -4787,6 +4791,8 @@ export default withEnglishFallback({ infiniteScrollDescription: "滚动到表格底部时自动加载下一页数据,无需手动翻页。", infiniteScrollMaxRows: "无限滚动最大行数", infiniteScrollMaxRowsDescription: "无限滚动模式下最多加载的行数(1000–50000)。", + regexMaxMatchCount: "全部选中最大数量", + regexMaxMatchCountDescription: "限制“全部选中”一次创建的选区数量;搜索匹配仍完整统计(100–10000)。", tableColumnTemplateFields: "新建表预设字段", tableColumnTemplateFieldsDescription: "先选择数据库类型,再配置新建表时使用的预设字段类型。", tableColumnTemplateAdd: "新增字段", @@ -5023,6 +5029,7 @@ export default withEnglishFallback({ shortcutUndo: "撤销", shortcutRedo: "重做", shortcutSelectAll: "全选", + shortcutExtendSelection: "扩展选择", shortcutUppercaseSelection: "选中内容转为大写", shortcutLowercaseSelection: "选中内容转为小写", shortcutExPasteSqlInCondition: "ExPaste:粘贴为 IN 条件", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index e71fba0b1..2e961718f 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -849,6 +849,10 @@ export default withEnglishFallback({ expandReplace: "展開取代", prevMatch: "上一個 (Shift+Enter)", nextMatch: "下一個 (Enter)", + selectAll: "選取所有符合項目", + selectAllLimit: "選取所有符合項目(最多 {limit} 項)", + selectionTruncated: "共找到 {total} 項符合結果,僅選取前 {limit} 項。", + selectionLimitSummary: "已選取 {limit}/{total}", close: "關閉 (Esc)", noResults: "無結果", inSelection: "選取區內", @@ -4030,6 +4034,8 @@ export default withEnglishFallback({ infiniteScrollDescription: "滾動到表格底部時自動載入下一頁資料,無需手動翻頁。", infiniteScrollMaxRows: "無限滾動最大筆數", infiniteScrollMaxRowsDescription: "無限滾動模式下最多載入的筆數(1000–50000)。", + regexMaxMatchCount: "全部選取最大數量", + regexMaxMatchCountDescription: "限制「全部選取」一次建立的選取範圍數量;搜尋符合結果仍會完整統計(100–10000)。", tableColumnTemplateFields: "新建表預設欄位", tableColumnTemplateFieldsDescription: "先選擇資料庫類型,再設定新建表時使用的預設欄位類型。", tableColumnTemplateAdd: "新增欄位", @@ -4213,6 +4219,7 @@ export default withEnglishFallback({ shortcutUndo: "復原", shortcutRedo: "重做", shortcutSelectAll: "全選", + shortcutExtendSelection: "擴展選取範圍", shortcutCopyCurrentRow: "複製目前資料列", shortcutDeleteCurrentRow: "刪除目前資料列", shortcutNewQuery: "建立查詢", diff --git a/apps/desktop/src/lib/__tests__/editor/editorSearchQuery.spec.ts b/apps/desktop/src/lib/__tests__/editor/editorSearchQuery.spec.ts index 6a833d9d8..f876ad0f7 100644 --- a/apps/desktop/src/lib/__tests__/editor/editorSearchQuery.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/editorSearchQuery.spec.ts @@ -1,6 +1,6 @@ import { EditorState } from "@codemirror/state"; import { describe, expect, it, vi } from "vitest"; -import { collectEditorSearchMatches, createEditorSearchQuery, replaceEditorSearchMatches } from "@/lib/editor/editorSearchQuery"; +import { collectEditorSearchMatches, countEditorSearchMatches, createEditorSearchQuery, replaceEditorSearchMatches } from "@/lib/editor/editorSearchQuery"; function matchedText(search: string, useRegex: boolean): string[] { const state = EditorState.create({ @@ -26,14 +26,51 @@ describe("editorSearchQuery", () => { expect(matchedText(String.raw`\n`, true)).toEqual(["\n"]); }); - it("collects every scoped match when replacement is uncapped", () => { + it("caps a large match collection without materializing every result", () => { + const state = EditorState.create({ doc: Array.from({ length: 50000 }, () => "x").join(" ") }); + const query = createEditorSearchQuery({ search: "x", caseSensitive: true, useRegex: false }); + const matches = collectEditorSearchMatches(query, state, 0, state.doc.length, 1000); + + expect(matches).toHaveLength(1000); + expect(matches.at(-1)).toEqual({ from: 1998, to: 1999 }); + }); + + it("counts every match without materializing an unbounded range array", () => { + const state = EditorState.create({ doc: Array.from({ length: 50000 }, () => "x").join(" ") }); + const query = createEditorSearchQuery({ search: "x", caseSensitive: true, useRegex: false }); + const startedAt = performance.now(); + const result = countEditorSearchMatches(query, state, 0, state.doc.length, { from: 49998, to: 49999 }); + const elapsed = performance.now() - startedAt; + + expect(result).toEqual({ count: 50000, currentIndex: 25000 }); + expect(elapsed).toBeLessThan(250); + }); + + it("collects zero-width regular expression matches within the requested limit", () => { + const state = EditorState.create({ doc: "xxxxx" }); + const query = createEditorSearchQuery({ search: "(?=x)", caseSensitive: true, useRegex: true }); + + expect(collectEditorSearchMatches(query, state, 0, state.doc.length, 3)).toEqual([ + { from: 0, to: 0 }, + { from: 1, to: 1 }, + { from: 2, to: 2 }, + ]); + }); + + it("counts every zero-width regular expression match", () => { + const state = EditorState.create({ doc: "x".repeat(50000) }); + const query = createEditorSearchQuery({ search: "(?=x)", caseSensitive: true, useRegex: true }); + + expect(countEditorSearchMatches(query, state, 0, state.doc.length)).toEqual({ count: 50000, currentIndex: 0 }); + }); + + it("keeps replacement collection uncapped", () => { const state = EditorState.create({ doc: Array.from({ length: 1001 }, () => "x").join(" ") }); const query = createEditorSearchQuery({ search: "x", caseSensitive: true, useRegex: false }); const matches = collectEditorSearchMatches(query, state, 0, state.doc.length); const dispatch = vi.fn(); expect(matches).toHaveLength(1001); - expect(collectEditorSearchMatches(query, state, 0, state.doc.length, 1000)).toHaveLength(1000); expect(replaceEditorSearchMatches({ dispatch }, matches, () => "y")).toBe(true); expect(dispatch).toHaveBeenCalledOnce(); expect(dispatch.mock.calls[0]?.[0].changes).toHaveLength(1001); diff --git a/apps/desktop/src/lib/__tests__/editor/editorSearchSelection.spec.ts b/apps/desktop/src/lib/__tests__/editor/editorSearchSelection.spec.ts new file mode 100644 index 000000000..8653b6d84 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/editorSearchSelection.spec.ts @@ -0,0 +1,97 @@ +import { readFileSync } from "node:fs"; +import { EditorSelection } from "@codemirror/state"; +import { describe, expect, it } from "vitest"; +import { appendSearchMatchSelection, findSearchMatch, isSearchAddSelectionModifier, selectionRangesForSearchMatches } from "@/lib/editor/editorSearchSelection"; + +const searchPanelSource = readFileSync(new URL("../../../components/editor/EditorSearchPanel.vue", import.meta.url), "utf8"); +const ddlViewSource = readFileSync(new URL("../../../components/objects/DdlViewDialog.vue", import.meta.url), "utf8"); +const nacosSource = readFileSync(new URL("../../../components/nacos/NacosAdminConsole.vue", import.meta.url), "utf8"); + +const matches = [ + { from: 0, to: 3 }, + { from: 6, to: 9 }, + { from: 12, to: 15 }, +]; + +describe("editorSearchSelection", () => { + it("selects every complete search match", () => { + const ranges = selectionRangesForSearchMatches(matches); + expect(ranges.map(({ from, to }) => ({ from, to }))).toEqual(matches); + }); + + it("appends the next unselected match and makes it main", () => { + const current = EditorSelection.create([EditorSelection.range(0, 3), EditorSelection.range(6, 9)], 1); + const next = appendSearchMatchSelection(current, matches, "next"); + + expect(next?.ranges.map(({ from, to }) => ({ from, to }))).toEqual(matches); + expect(next?.main.from).toBe(12); + }); + + it("wraps previous from the first match to the last", () => { + const current = EditorSelection.single(0, 3); + + expect(appendSearchMatchSelection(current, matches, "prev")?.main.from).toBe(12); + }); + + it("finds next and previous matches from a streaming iterable", () => { + function* stream() { + yield* matches; + } + + expect(findSearchMatch(stream(), 7, "next")).toEqual({ from: 12, to: 15 }); + expect(findSearchMatch(stream(), 6, "prev")).toEqual({ from: 0, to: 3 }); + expect(findSearchMatch(stream(), 0, "prev")).toEqual({ from: 12, to: 15 }); + }); + + it("appends after the maximum batch selection without building ordered copies", () => { + function* stream() { + for (let index = 0; index < 50000; index++) yield { from: index * 2, to: index * 2 + 1 }; + } + const selected = EditorSelection.create(Array.from({ length: 10000 }, (_, index) => EditorSelection.range(index * 2, index * 2 + 1))); + const startedAt = performance.now(); + const next = appendSearchMatchSelection(selected, stream(), "next"); + const elapsed = performance.now() - startedAt; + + expect(next?.main).toMatchObject({ from: 20000, to: 20001 }); + expect(elapsed).toBeLessThan(250); + }); + + it("preserves an existing reverse range", () => { + const current = EditorSelection.create([EditorSelection.range(3, 0)]); + const next = appendSearchMatchSelection(current, matches, "next"); + + expect(next?.ranges[0]?.anchor).toBe(3); + expect(next?.ranges[0]?.head).toBe(0); + }); + + it("returns no ranges when there is nothing to select or append", () => { + expect(selectionRangesForSearchMatches([])).toEqual([]); + expect(appendSearchMatchSelection(EditorSelection.create(selectionRangesForSearchMatches(matches)), matches, "next")).toBeNull(); + }); + + it("uses Command-click on macOS and Ctrl-click on Windows and Linux", () => { + expect(isSearchAddSelectionModifier({ metaKey: true }, "MacIntel")).toBe(true); + expect(isSearchAddSelectionModifier({ ctrlKey: true }, "MacIntel")).toBe(false); + expect(isSearchAddSelectionModifier({ ctrlKey: true }, "Win32")).toBe(true); + expect(isSearchAddSelectionModifier({ metaKey: true }, "Win32")).toBe(false); + expect(isSearchAddSelectionModifier({ ctrlKey: true }, "Linux x86_64")).toBe(true); + }); + + it("wires select-all and platform-modifier-click match selection into the search panel", () => { + expect(searchPanelSource).toContain("selectionRangesForSearchMatches"); + expect(searchPanelSource).toContain("appendSearchMatchSelection"); + expect(searchPanelSource).toContain("isSearchAddSelectionModifier(event)"); + expect(searchPanelSource).toContain('@click="selectAllMatches"'); + expect(searchPanelSource).toContain("editor.search.selectAll"); + expect(searchPanelSource).toContain("collectAllMatches(v, limit + 1)"); + expect(searchPanelSource).toContain("matches.slice(0, limit)"); + expect(searchPanelSource).toContain("countEditorSearchMatches"); + expect(searchPanelSource).toContain("editor.search.selectionLimitSummary"); + expect(searchPanelSource).not.toContain("matchCountLimited"); + }); + + it("enables multiple selections in every editor using the shared panel", () => { + expect(ddlViewSource).toContain("EditorState.allowMultipleSelections.of(true)"); + expect(nacosSource).toContain("EditorState.allowMultipleSelections.of(true)"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorExtendSelection.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorExtendSelection.spec.ts new file mode 100644 index 000000000..f5cac68d6 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorExtendSelection.spec.ts @@ -0,0 +1,553 @@ +import { readFileSync } from "node:fs"; +import { selectParentSyntax } from "@codemirror/commands"; +import { sql } from "@codemirror/lang-sql"; +import { EditorSelection, EditorState } from "@codemirror/state"; +import type { Command } from "@codemirror/view"; +import { describe, expect, it } from "vitest"; +import { plainTextSelectionRanges } from "@/lib/editor/plainTextSelectionRanges"; +import { extendQueryEditorSelection, runQueryEditorAltExtendSelection } from "@/lib/editor/queryEditorExtendSelection"; +import { chooseNextSemanticSelectionRange, type SemanticSelectionRange } from "@/lib/editor/semanticSelectionRanges"; +import { sqlSemanticSelectionRanges } from "@/lib/editor/sqlSemanticSelectionRanges"; + +const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8"); + +function runCommand(command: Command, state: EditorState): EditorState { + let nextState = state; + const handled = command({ + state, + dispatch(transaction) { + nextState = transaction.state; + }, + }); + + expect(handled).toBe(true); + return nextState; +} + +function selectedTexts(state: EditorState): string[] { + return state.selection.ranges.map((range) => state.sliceDoc(range.from, range.to)); +} + +function plainTextSelectionSequence(doc: string, cursor: number): string[] { + const result: string[] = []; + let current: SemanticSelectionRange = { from: cursor, to: cursor }; + + while (true) { + const context = { doc, cursor, current }; + const next = chooseNextSemanticSelectionRange(current, cursor, plainTextSelectionRanges(context)); + if (!next) return result; + result.push(doc.slice(next.from, next.to)); + current = next; + } +} + +function selectionSequence(doc: string, cursor: number, language: "sql" | "text"): string[] { + const result: string[] = []; + let current: SemanticSelectionRange = { from: cursor, to: cursor }; + + while (true) { + const context = { doc, cursor, current, preferDelimitedContent: language === "sql" }; + const candidates = plainTextSelectionRanges(context); + if (language === "sql") candidates.push(...sqlSemanticSelectionRanges(context, { databaseType: "mysql" })); + const next = chooseNextSemanticSelectionRange(current, cursor, candidates); + if (!next) return result; + result.push(doc.slice(next.from, next.to)); + current = next; + } +} + +function runExtendSelection(state: EditorState, options: { databaseType?: "mysql"; language?: "sql" | "text" } = {}): EditorState { + let nextState = state; + const handled = extendQueryEditorSelection( + { + state, + dispatch(transaction) { + nextState = state.update(transaction).state; + }, + } as never, + options, + ); + expect(handled).toBe(true); + return nextState; +} + +describe("query editor extend selection", () => { + it("extends plain text through word, quotes, brackets, line, paragraph, and document", () => { + const doc = "prefix (say 'hello world') suffix\ncontinued line\n\nnext paragraph"; + const cursor = doc.indexOf("hello") + 2; + + expect(plainTextSelectionSequence(doc, cursor)).toEqual(["hello", "hello world", "'hello world'", "say 'hello world'", "(say 'hello world')", "prefix (say 'hello world') suffix", "prefix (say 'hello world') suffix\ncontinued line", doc]); + }); + + it("ignores unmatched delimiters in plain text fallback", () => { + const doc = "alpha ('broken beta"; + const cursor = doc.indexOf("beta") + 1; + + expect(plainTextSelectionSequence(doc, cursor)).toEqual(["beta", doc]); + }); + + it("matches IntelliJ SQL selection for a string comparison and where clause", () => { + const doc = "select *\nfrom rq_queuing_record where code = '001';"; + const cursor = doc.indexOf("001") + 1; + const sequence = selectionSequence(doc, cursor, "sql"); + + expect(sequence.slice(0, 4)).toEqual(["001", "'001'", "code = '001'", "where code = '001'"]); + }); + + it("keeps the second command at the complete literal with uppercase SQL and LIMIT", () => { + const doc = "SELECT *\nFROM rq_operate_log\nWHERE code = '001'\nLIMIT 100;"; + const cursor = doc.indexOf("001") + 1; + let state = EditorState.create({ + doc, + selection: EditorSelection.cursor(cursor), + }); + + state = runExtendSelection(state, { databaseType: "mysql", language: "sql" }); + expect(selectedTexts(state)).toEqual(["001"]); + + state = runExtendSelection(state, { databaseType: "mysql", language: "sql" }); + expect(selectedTexts(state)).toEqual(["'001'"]); + }); + + it("continues through real SQL parents instead of a fixed fifth full-document step", () => { + const doc = "select * from t where code = (select code from u where id = '001')"; + const sequence = selectionSequence(doc, doc.indexOf("001") + 1, "sql"); + + expect(sequence.slice(0, 4)).toEqual(["001", "'001'", "id = '001'", "where id = '001'"]); + expect(sequence[4]).not.toBe(doc); + expect(sequence).toContain("select code from u where id = '001'"); + }); + + it("extends comparison through AND, OR, and the enclosing where clause", () => { + const doc = "select * from t where a = 1 and b = 2 or c = 3 order by id"; + const sequence = selectionSequence(doc, doc.indexOf("b = 2") + 1, "sql"); + + expect(sequence).toContain("b = 2"); + expect(sequence).toContain("a = 1 and b = 2"); + expect(sequence).toContain("a = 1 and b = 2 or c = 3"); + expect(sequence).toContain("where a = 1 and b = 2 or c = 3"); + }); + + it("groups mixed AND and OR conditions by SQL operator precedence", () => { + const doc = "select * from t where a = 1 or b = 2 and c = 3"; + const sequence = selectionSequence(doc, doc.indexOf("b = 2") + 1, "sql"); + + const comparison = sequence.indexOf("b = 2"); + const andExpression = sequence.indexOf("b = 2 and c = 3"); + const orExpression = sequence.indexOf("a = 1 or b = 2 and c = 3"); + expect(comparison).toBeGreaterThanOrEqual(0); + expect(andExpression).toBeGreaterThan(comparison); + expect(orExpression).toBeGreaterThan(andExpression); + expect(sequence).not.toContain("a = 1 or b = 2"); + }); + + it("applies arithmetic precedence before comparison and logical operators", () => { + const doc = "select * from t where a = 1 or score = 2 + 3 * 4"; + const sequence = selectionSequence(doc, doc.indexOf("3 * 4") + 1, "sql"); + + expect(sequence).toContain("3 * 4"); + expect(sequence).toContain("2 + 3 * 4"); + expect(sequence).toContain("score = 2 + 3 * 4"); + expect(sequence).toContain("a = 1 or score = 2 + 3 * 4"); + expect(sequence).not.toContain("2 + 3"); + }); + + it("keeps parenthesized boolean expressions as an explicit selection parent", () => { + const doc = "select * from t where a = 1 and (b = 2 or c = 3)"; + const sequence = selectionSequence(doc, doc.indexOf("b = 2") + 1, "sql"); + + expect(sequence).toContain("b = 2 or c = 3"); + expect(sequence).toContain("(b = 2 or c = 3)"); + expect(sequence).toContain("a = 1 and (b = 2 or c = 3)"); + }); + + it("builds logical ranges independently inside nested subqueries", () => { + const doc = "select * from t where id in (select id from u where a = 1 or b = 2 and c = 3) and enabled = 1"; + const sequence = selectionSequence(doc, doc.indexOf("b = 2") + 1, "sql"); + + expect(sequence).toContain("b = 2 and c = 3"); + expect(sequence).toContain("a = 1 or b = 2 and c = 3"); + expect(sequence).toContain("where a = 1 or b = 2 and c = 3"); + expect(sequence).not.toContain("a = 1 or b = 2 and c = 3) and enabled = 1"); + }); + + it("keeps a higher-precedence AND group between two OR branches", () => { + const doc = "select * from t where a = 1 or b = 2 and c = 3 or d = 4"; + const sequence = selectionSequence(doc, doc.indexOf("b = 2") + 1, "sql"); + + expect(sequence).toContain("b = 2 and c = 3"); + expect(sequence).toContain("a = 1 or b = 2 and c = 3"); + expect(sequence).toContain("a = 1 or b = 2 and c = 3 or d = 4"); + expect(sequence).not.toContain("a = 1 or b = 2"); + expect(sequence).not.toContain("c = 3 or d = 4"); + }); + + it("preserves independent parenthesized OR branches joined by AND", () => { + const doc = "select * from t where (a = 1 or b = 2) and (c = 3 or d = 4)"; + const sequence = selectionSequence(doc, doc.indexOf("b = 2") + 1, "sql"); + + expect(sequence).toContain("a = 1 or b = 2"); + expect(sequence).toContain("(a = 1 or b = 2)"); + expect(sequence).toContain("(a = 1 or b = 2) and (c = 3 or d = 4)"); + expect(sequence).not.toContain("where (a = 1 or b = 2)"); + expect(sequence).not.toContain("b = 2) and (c = 3"); + }); + + it("does not treat BETWEEN's AND as a logical operator", () => { + const doc = "select * from orders where amount between 10 and 20 or priority = 1"; + const sequence = selectionSequence(doc, doc.indexOf("10") + 1, "sql"); + + expect(sequence).toContain("amount between 10 and 20"); + expect(sequence).toContain("amount between 10 and 20 or priority = 1"); + expect(sequence).not.toContain("10 and 20"); + }); + + it("keeps NOT IN subquery conditions inside the nested query", () => { + const doc = "select * from users u where u.id not in (select user_id from bans where reason = 'spam' or active = 1) and u.enabled = 1"; + const sequence = selectionSequence(doc, doc.indexOf("reason") + 2, "sql"); + + expect(sequence).toContain("reason = 'spam'"); + expect(sequence).toContain("reason = 'spam' or active = 1"); + expect(sequence).toContain("where reason = 'spam' or active = 1"); + expect(sequence).toContain("select user_id from bans where reason = 'spam' or active = 1"); + expect(sequence).not.toContain("reason = 'spam' or active = 1) and u.enabled = 1"); + }); + + it("builds JOIN ON expressions without crossing into WHERE", () => { + const doc = "select * from accounts a join events e on a.id = e.account_id and (e.kind = 'open' or e.kind = 'close') where a.active = 1"; + const sequence = selectionSequence(doc, doc.indexOf("close") + 2, "sql"); + + expect(sequence).toContain("e.kind = 'open' or e.kind = 'close'"); + expect(sequence).toContain("(e.kind = 'open' or e.kind = 'close')"); + expect(sequence).toContain("a.id = e.account_id and (e.kind = 'open' or e.kind = 'close')"); + expect(sequence).toContain("on a.id = e.account_id and (e.kind = 'open' or e.kind = 'close')"); + expect(sequence).not.toContain("e.kind = 'close') where a.active = 1"); + }); + + it("applies function, multiplication, addition, and comparison levels in order", () => { + const doc = "select * from invoice where round(price * quantity + tax, 2) >= minimum and enabled = 1"; + const sequence = selectionSequence(doc, doc.indexOf("price") + 2, "sql"); + + expect(sequence).toContain("price * quantity"); + expect(sequence).toContain("price * quantity + tax"); + expect(sequence).toContain("round(price * quantity + tax, 2)"); + expect(sequence).toContain("round(price * quantity + tax, 2) >= minimum"); + expect(sequence).toContain("round(price * quantity + tax, 2) >= minimum and enabled = 1"); + }); + + it("isolates a correlated EXISTS subquery from its outer predicate", () => { + const doc = "select * from users u where exists (select 1 from orders o where o.user_id = u.id and (o.paid = 1 or o.total > 100)) or u.admin = 1"; + const sequence = selectionSequence(doc, doc.indexOf("o.paid") + 2, "sql"); + + expect(sequence).toContain("o.paid = 1 or o.total > 100"); + expect(sequence).toContain("(o.paid = 1 or o.total > 100)"); + expect(sequence).toContain("o.user_id = u.id and (o.paid = 1 or o.total > 100)"); + expect(sequence).toContain("where o.user_id = u.id and (o.paid = 1 or o.total > 100)"); + expect(sequence).not.toContain("o.paid = 1 or o.total > 100)) or u.admin = 1"); + }); + + it("ignores logical keywords inside strings and comments", () => { + const doc = "select * from notes where message = 'alpha or beta and gamma' /* or ignored = 1 */ and visible = 1"; + const sequence = selectionSequence(doc, doc.indexOf("beta") + 1, "sql"); + + expect(sequence.slice(0, 2)).toEqual(["alpha or beta and gamma", "'alpha or beta and gamma'"]); + expect(sequence).toContain("message = 'alpha or beta and gamma'"); + expect(sequence).toContain("message = 'alpha or beta and gamma' /* or ignored = 1 */ and visible = 1"); + expect(sequence).not.toContain("beta and gamma"); + }); + + it("keeps long AND chains bounded when collecting semantic ranges", () => { + const conditions = Array.from({ length: 100 }, (_, index) => `c${index} = ${index}`); + const doc = `select * from t where ${conditions.join(" and ")}`; + const cursor = doc.indexOf("c50") + 1; + const startedAt = performance.now(); + const ranges = sqlSemanticSelectionRanges({ doc, cursor, current: { from: cursor, to: cursor } }, { databaseType: "mysql" }); + const elapsed = performance.now() - startedAt; + + expect(ranges.length).toBeLessThan(200); + expect(elapsed).toBeLessThan(250); + }); + + it("extends 1000 selections in an approximately 100 KB plain text document without rescanning it", () => { + const lines = Array.from({ length: 1000 }, (_, index) => `record_${index.toString().padStart(4, "0")} needle ${"payload".repeat(13)}`); + const doc = lines.join("\n"); + const selections: ReturnType[] = []; + let offset = 0; + for (const line of lines) { + const from = offset + line.indexOf("needle"); + selections.push(EditorSelection.range(from, from + "needle".length)); + offset += line.length + 1; + } + const state = EditorState.create({ + doc, + extensions: [EditorState.allowMultipleSelections.of(true)], + selection: EditorSelection.create(selections), + }); + + expect(doc.length).toBeGreaterThanOrEqual(100_000); + const startedAt = performance.now(); + const next = runExtendSelection(state, { language: "text" }); + const elapsed = performance.now() - startedAt; + + expect(next.selection.ranges).toHaveLength(1000); + expect(selectedTexts(next).every((text) => text.startsWith("record_") && text.includes(" needle "))).toBe(true); + expect(elapsed).toBeLessThan(1500); + }); + + it("extends 1000 selections in an approximately 100 KB SQL document with one shared tokenization", () => { + const statements = Array.from({ length: 1000 }, (_, index) => `select * from table_${index} where code = 'needle' and note = '${"payload".repeat(8)}';`); + const doc = statements.join("\n"); + const selections: ReturnType[] = []; + let offset = 0; + for (const statement of statements) { + const from = offset + statement.indexOf("needle"); + selections.push(EditorSelection.range(from, from + "needle".length)); + offset += statement.length + 1; + } + const state = EditorState.create({ + doc, + extensions: [EditorState.allowMultipleSelections.of(true)], + selection: EditorSelection.create(selections), + }); + + expect(doc.length).toBeGreaterThanOrEqual(100_000); + const startedAt = performance.now(); + const next = runExtendSelection(state, { databaseType: "mysql", language: "sql" }); + const elapsed = performance.now() - startedAt; + + expect(next.selection.ranges).toHaveLength(1000); + expect(selectedTexts(next).every((text) => text === "'needle'")).toBe(true); + expect(elapsed).toBeLessThan(1500); + }); + + it("extends through function calls and nested subqueries", () => { + const doc = "select coalesce((select max(score) from scores where user_id = u.id), 0) from users u"; + const sequence = selectionSequence(doc, doc.indexOf("score") + 2, "sql"); + + expect(sequence).toContain("score"); + expect(sequence).toContain("max(score)"); + expect(sequence).toContain("select max(score) from scores where user_id = u.id"); + expect(sequence).toContain("(select max(score) from scores where user_id = u.id)"); + expect(sequence).toContain("coalesce((select max(score) from scores where user_id = u.id), 0)"); + }); + + it("extends through individual query blocks before chained set expressions", () => { + const doc = "select a from t union select b from u union select c from v"; + const sequence = selectionSequence(doc, doc.indexOf("a"), "sql"); + + expect(sequence).toContain("select a from t"); + expect(sequence).toContain("select a from t union select b from u"); + expect(sequence).toContain(doc); + }); + + it("keeps valid statement semantics when a later statement is incomplete", () => { + const doc = "select * from t where a = 1;\nselect ("; + const sequence = selectionSequence(doc, doc.indexOf("a ="), "sql"); + + expect(sequence).toContain("a = 1"); + expect(sequence).toContain("where a = 1"); + }); + + it("selects escaped SQL string content before the complete literal", () => { + const doc = "select * from t where name = 'O''Reilly'"; + const sequence = selectionSequence(doc, doc.indexOf("Reilly") + 2, "sql"); + + expect(sequence.slice(0, 2)).toEqual(["O''Reilly", "'O''Reilly'"]); + }); + + it("falls back to plain text when SQL structure is incomplete", () => { + const doc = "select * from t where code = ('001' and broken"; + const sequence = selectionSequence(doc, doc.indexOf("001") + 1, "sql"); + + expect(sequence.slice(0, 2)).toEqual(["001", "'001'"]); + expect(sequence).not.toContain("code = ('001'"); + expect(sequence[sequence.length - 1]).toBe(doc); + }); + + it("extends every rectangular selection independently", () => { + const doc = "select a = '001';\nselect b = '002';"; + const first = doc.indexOf("001") + 1; + const second = doc.indexOf("002") + 1; + const state = EditorState.create({ + doc, + extensions: [EditorState.allowMultipleSelections.of(true)], + selection: EditorSelection.create([EditorSelection.cursor(first), EditorSelection.cursor(second)]), + }); + + expect(selectedTexts(runExtendSelection(state, { databaseType: "mysql" }))).toEqual(["001", "002"]); + }); + + it("preserves reverse selection direction", () => { + const doc = "where code = '001'"; + const literalFrom = doc.indexOf("'001'"); + const state = EditorState.create({ + doc, + selection: EditorSelection.range(literalFrom + 4, literalFrom + 1), + }); + + const next = runExtendSelection(state, { databaseType: "mysql" }); + expect(next.selection.main.anchor).toBeGreaterThan(next.selection.main.head); + expect(next.sliceDoc(next.selection.main.from, next.selection.main.to)).toBe("'001'"); + }); + + it("binds the configurable editor shortcut to CodeMirror semantic selection", () => { + expect(queryEditorSource).toContain("extendQueryEditorSelection"); + expect(queryEditorSource).not.toContain("selectParentSyntax"); + expect(queryEditorSource).not.toContain("codeMirrorSelectParentSyntax"); + expect(queryEditorSource).toContain("shortcuts.extendSelection"); + expect(queryEditorSource).toContain("runQueryEditorAltExtendSelection"); + }); + + it("matches macOS Option+W by physical key when the event key is transformed", () => { + let commandRuns = 0; + let prevented = false; + const handled = runQueryEditorAltExtendSelection( + { + key: "∑", + code: "KeyW", + altKey: true, + preventDefault() { + prevented = true; + }, + }, + "Alt+W", + {} as never, + () => { + commandRuns += 1; + return true; + }, + "MacIntel", + ); + + expect(handled).toBe(true); + expect(commandRuns).toBe(1); + expect(prevented).toBe(true); + }); + + it("does not prevent macOS Option+W when selection cannot grow", () => { + let prevented = false; + const handled = runQueryEditorAltExtendSelection( + { + key: "∑", + code: "KeyW", + altKey: true, + preventDefault() { + prevented = true; + }, + }, + "Alt+W", + {} as never, + () => false, + "MacIntel", + ); + + expect(handled).toBe(false); + expect(prevented).toBe(false); + }); + + it("handles Windows Alt+W when the keyboard layout changes event.key", () => { + let commandRuns = 0; + let prevented = false; + const handled = runQueryEditorAltExtendSelection( + { + key: "Ц", + code: "KeyW", + altKey: true, + preventDefault() { + prevented = true; + }, + }, + "Alt+W", + {} as never, + () => { + commandRuns += 1; + return true; + }, + "Win32", + ); + + expect(handled).toBe(true); + expect(commandRuns).toBe(1); + expect(prevented).toBe(true); + }); + + it("handles Linux Alt+W while leaving AltGr text input untouched", () => { + let commandRuns = 0; + const handled = runQueryEditorAltExtendSelection( + { + key: "щ", + code: "KeyW", + altKey: true, + preventDefault() {}, + }, + "Alt+W", + {} as never, + () => { + commandRuns += 1; + return true; + }, + "Linux x86_64", + ); + + expect(handled).toBe(true); + expect(commandRuns).toBe(1); + + const altGrHandled = runQueryEditorAltExtendSelection( + { + key: "ł", + code: "KeyW", + altKey: true, + ctrlKey: true, + preventDefault() {}, + }, + "Alt+W", + {} as never, + () => true, + "Linux x86_64", + ); + + expect(altGrHandled).toBe(false); + }); + + it("selects the SQL word first and then expands to a parent syntax range", () => { + const doc = "select customer_name from customers"; + const cursor = doc.indexOf("customer_name") + 3; + let state = EditorState.create({ + doc, + extensions: [sql()], + selection: EditorSelection.cursor(cursor), + }); + + state = runCommand(selectParentSyntax, state); + expect(selectedTexts(state)).toEqual(["customer_name"]); + const wordRange = state.selection.main; + + state = runCommand(selectParentSyntax, state); + expect(state.selection.main.from).toBeLessThanOrEqual(wordRange.from); + expect(state.selection.main.to).toBeGreaterThanOrEqual(wordRange.to); + expect(state.selection.main.to - state.selection.main.from).toBeGreaterThan(wordRange.to - wordRange.from); + }); + + it("extends every selection in rectangular multi-selection mode", () => { + const doc = "select customer_name from customers;\nselect order_total from orders;"; + const firstCursor = doc.indexOf("customer_name") + 2; + const secondCursor = doc.indexOf("order_total") + 2; + let state = EditorState.create({ + doc, + extensions: [sql(), EditorState.allowMultipleSelections.of(true)], + selection: EditorSelection.create([EditorSelection.cursor(firstCursor), EditorSelection.cursor(secondCursor)]), + }); + + state = runCommand(selectParentSyntax, state); + expect(selectedTexts(state)).toEqual(["customer_name", "order_total"]); + const wordRanges = state.selection.ranges; + + state = runCommand(selectParentSyntax, state); + expect(state.selection.ranges).toHaveLength(2); + state.selection.ranges.forEach((range, index) => { + expect(range.from).toBeLessThanOrEqual(wordRanges[index].from); + expect(range.to).toBeGreaterThanOrEqual(wordRanges[index].to); + expect(range.to - range.from).toBeGreaterThan(wordRanges[index].to - wordRanges[index].from); + }); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts index d58a1b4ba..e7cf72f6b 100644 --- a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts @@ -98,12 +98,20 @@ describe("shortcutRegistry editor actions", () => { expect(shortcuts.undo).toBe("Mod+Z"); expect(shortcuts.redo).toBe("Shift+Mod+Z"); expect(shortcuts.selectAll).toBe("Mod+A"); + expect(shortcuts.extendSelection).toBe("Alt+W"); expect(shortcuts.uppercaseSelection).toBe("Shift+Alt+U"); expect(shortcuts.lowercaseSelection).toBe("Shift+Alt+L"); expect(shortcuts.exPasteSqlInCondition).toBe(""); expect(shortcuts.toggleFold).toBe("Mod+."); }); + it("registers IntelliJ-style extend selection as a configurable editor shortcut", () => { + const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === "extendSelection"); + + expect(definition).toMatchObject({ scope: "editor", defaultShortcut: "Alt+W" }); + expect(DEFAULT_SHORTCUT_SETTINGS.extendSelection).toBe("Alt+W"); + }); + it("detects conflicts between formatter editor shortcuts and other editor shortcuts", () => { const shortcuts = normalizeShortcutSettings({ duplicateLine: "Mod+F" }); diff --git a/apps/desktop/src/lib/editor/editorSearchQuery.ts b/apps/desktop/src/lib/editor/editorSearchQuery.ts index a4983c71c..4abadcd6d 100644 --- a/apps/desktop/src/lib/editor/editorSearchQuery.ts +++ b/apps/desktop/src/lib/editor/editorSearchQuery.ts @@ -23,6 +23,25 @@ export interface EditorSearchMatch { to: number; } +export interface EditorSearchMatchCount { + count: number; + currentIndex: number; +} + +export function countEditorSearchMatches(query: SearchQuery, state: EditorState, from: number, to: number, current?: EditorSearchMatch): EditorSearchMatchCount { + let count = 0; + let currentIndex = 0; + const cursor = query.getCursor(state); + + for (let result = cursor.next(); !result.done; result = cursor.next()) { + if (result.value.from < from || result.value.to > to) continue; + count++; + if (current && result.value.from === current.from && result.value.to === current.to) currentIndex = count; + } + + return { count, currentIndex }; +} + export function collectEditorSearchMatches(query: SearchQuery, state: EditorState, from: number, to: number, limit = Number.POSITIVE_INFINITY): EditorSearchMatch[] { const matches: EditorSearchMatch[] = []; const cursor = query.getCursor(state); diff --git a/apps/desktop/src/lib/editor/editorSearchSelection.ts b/apps/desktop/src/lib/editor/editorSearchSelection.ts new file mode 100644 index 000000000..619903d64 --- /dev/null +++ b/apps/desktop/src/lib/editor/editorSearchSelection.ts @@ -0,0 +1,53 @@ +import { EditorSelection, type SelectionRange } from "@codemirror/state"; +import type { EditorSearchMatch } from "@/lib/editor/editorSearchQuery"; +import { isMacShortcutPlatform } from "@/lib/editor/shortcutDisplay"; + +export type EditorSearchSelectionDirection = "next" | "prev"; + +export interface SearchSelectionModifierEvent { + metaKey?: boolean; + ctrlKey?: boolean; +} + +export function isSearchAddSelectionModifier(event: SearchSelectionModifierEvent, platform = globalThis.navigator?.platform || ""): boolean { + return isMacShortcutPlatform(platform) ? !!event.metaKey : !!event.ctrlKey; +} + +function isMatchCovered(selection: EditorSelection, match: EditorSearchMatch): boolean { + let low = 0; + let high = selection.ranges.length; + while (low < high) { + const middle = (low + high) >> 1; + if (selection.ranges[middle].from <= match.from) low = middle + 1; + else high = middle; + } + const range = selection.ranges[low - 1]; + return !!range && range.to >= match.to; +} + +export function findSearchMatch(matches: Iterable, cursor: number, direction: EditorSearchSelectionDirection, accept: (match: EditorSearchMatch) => boolean = () => true): EditorSearchMatch | null { + let wrapped: EditorSearchMatch | null = null; + let target: EditorSearchMatch | null = null; + + for (const match of matches) { + if (!accept(match)) continue; + if (direction === "next") { + wrapped ??= match; + if (match.from >= cursor) return match; + } else { + wrapped = match; + if (match.to <= cursor) target = match; + } + } + + return target ?? wrapped; +} + +export function selectionRangesForSearchMatches(matches: readonly EditorSearchMatch[]): SelectionRange[] { + return matches.map((match) => EditorSelection.range(match.from, match.to)); +} + +export function appendSearchMatchSelection(selection: EditorSelection, matches: Iterable, direction: EditorSearchSelectionDirection): EditorSelection | null { + const target = findSearchMatch(matches, selection.main.head, direction, (match) => !isMatchCovered(selection, match)); + return target ? selection.addRange(EditorSelection.range(target.from, target.to), true) : null; +} diff --git a/apps/desktop/src/lib/editor/plainTextSelectionRanges.ts b/apps/desktop/src/lib/editor/plainTextSelectionRanges.ts new file mode 100644 index 000000000..0592714d0 --- /dev/null +++ b/apps/desktop/src/lib/editor/plainTextSelectionRanges.ts @@ -0,0 +1,149 @@ +import { createSemanticSelectionRangeIndex, type SemanticSelectionContext, type SemanticSelectionRange, type SemanticSelectionRangeIndex } from "@/lib/editor/semanticSelectionRanges"; + +const WORD_CHARACTER = /[\p{L}\p{N}\p{M}_$]/u; +const QUOTE_PAIRS: Readonly> = { "'": "'", '"': '"', "`": "`" }; +const BRACKET_PAIRS: Readonly> = { "(": ")", "[": "]", "{": "}" }; +const CLOSING_BRACKETS = new Set(Object.values(BRACKET_PAIRS)); + +function addRange(ranges: SemanticSelectionRange[], from: number, to: number) { + if (from < to) ranges.push({ from, to }); +} + +function isEscaped(text: string, position: number): boolean { + let backslashes = 0; + for (let index = position - 1; index >= 0 && text[index] === "\\"; index -= 1) backslashes += 1; + return backslashes % 2 === 1; +} + +function scanQuotedRanges(doc: string, ranges: SemanticSelectionRange[]) { + for (let start = 0; start < doc.length; start += 1) { + const close = QUOTE_PAIRS[doc[start] ?? ""]; + if (!close || (start > 0 && isEscaped(doc, start))) continue; + + for (let index = start + 1; index < doc.length; index += 1) { + if (isEscaped(doc, index)) continue; + if (doc[index] !== close) continue; + if (close === "'" || close === '"' || close === "`") { + if (doc[index + 1] === close) { + index += 1; + continue; + } + } + addRange(ranges, start + 1, index); + addRange(ranges, start, index + 1); + start = index; + break; + } + } +} + +function scanBracketRanges(doc: string, ranges: SemanticSelectionRange[]) { + const stack: Array<{ open: string; from: number }> = []; + let quote: string | null = null; + + for (let index = 0; index < doc.length; index += 1) { + const character = doc[index] ?? ""; + if (quote) { + if (character === quote && !isEscaped(doc, index)) { + if (doc[index + 1] === quote) { + index += 1; + } else { + quote = null; + } + } + continue; + } + if (QUOTE_PAIRS[character]) { + quote = character; + continue; + } + if (BRACKET_PAIRS[character]) { + stack.push({ open: character, from: index }); + continue; + } + if (!CLOSING_BRACKETS.has(character)) continue; + const expectedOpen = Object.entries(BRACKET_PAIRS).find(([, close]) => close === character)?.[0]; + if (stack[stack.length - 1]?.open !== expectedOpen) continue; + const opening = stack.pop(); + if (!opening) continue; + addRange(ranges, opening.from + 1, index); + addRange(ranges, opening.from, index + 1); + } +} + +function scanAllWordRanges(doc: string, ranges: SemanticSelectionRange[]) { + let index = 0; + while (index < doc.length) { + if (!WORD_CHARACTER.test(doc[index] ?? "")) { + index += 1; + continue; + } + const start = index; + while (index < doc.length && WORD_CHARACTER.test(doc[index] ?? "")) index += 1; + addRange(ranges, start, index); + } +} + +function scanAllLineAndParagraphRanges(doc: string, ranges: SemanticSelectionRange[]) { + let lineStart = 0; + for (let index = 0; index <= doc.length; index += 1) { + if (index !== doc.length && doc[index] !== "\n") continue; + addRange(ranges, lineStart, index); + lineStart = index + 1; + } + + let paragraphStart = 0; + while (paragraphStart <= doc.length) { + const separator = doc.indexOf("\n\n", paragraphStart); + const paragraphEnd = separator < 0 ? doc.length : separator; + addRange(ranges, paragraphStart, paragraphEnd); + if (separator < 0) break; + paragraphStart = separator + 2; + } +} + +export interface PlainTextSelectionAnalysis { + doc: string; + allRanges: SemanticSelectionRangeIndex; + delimitedRanges: SemanticSelectionRangeIndex; + quotedRanges: SemanticSelectionRangeIndex; +} + +export function analyzePlainTextSelectionRanges(doc: string): PlainTextSelectionAnalysis { + const words: SemanticSelectionRange[] = []; + const quotes: SemanticSelectionRange[] = []; + const delimited: SemanticSelectionRange[] = []; + scanAllWordRanges(doc, words); + scanQuotedRanges(doc, quotes); + delimited.push(...quotes); + scanBracketRanges(doc, delimited); + scanAllLineAndParagraphRanges(doc, delimited); + addRange(delimited, 0, doc.length); + return { + doc, + allRanges: createSemanticSelectionRangeIndex([...words, ...delimited]), + delimitedRanges: createSemanticSelectionRangeIndex(delimited), + quotedRanges: createSemanticSelectionRangeIndex(quotes), + }; +} + +function hasContainingQuote(analysis: PlainTextSelectionAnalysis, cursor: number): boolean { + return analysis.quotedRanges.containing({ from: cursor, to: cursor }).some((range) => range.from < cursor && cursor < range.to); +} + +function selectionRangeIndex(context: SemanticSelectionContext, analysis: PlainTextSelectionAnalysis): SemanticSelectionRangeIndex { + return context.preferDelimitedContent && hasContainingQuote(analysis, context.cursor) ? analysis.delimitedRanges : analysis.allRanges; +} + +export function plainTextSelectionRanges(context: SemanticSelectionContext, analysis = analyzePlainTextSelectionRanges(context.doc)): SemanticSelectionRange[] { + const { doc } = context; + if (doc.length === 0) return []; + if (analysis.doc !== doc) analysis = analyzePlainTextSelectionRanges(doc); + return selectionRangeIndex(context, analysis).containing(context.current); +} + +export function nextPlainTextSelectionRange(context: SemanticSelectionContext, analysis = analyzePlainTextSelectionRanges(context.doc)): SemanticSelectionRange | null { + if (context.doc.length === 0) return null; + if (analysis.doc !== context.doc) analysis = analyzePlainTextSelectionRanges(context.doc); + return selectionRangeIndex(context, analysis).findNext(context.current, context.cursor); +} diff --git a/apps/desktop/src/lib/editor/queryEditorExtendSelection.ts b/apps/desktop/src/lib/editor/queryEditorExtendSelection.ts new file mode 100644 index 000000000..57820f4e5 --- /dev/null +++ b/apps/desktop/src/lib/editor/queryEditorExtendSelection.ts @@ -0,0 +1,49 @@ +import type { Command, EditorView } from "@codemirror/view"; +import { EditorSelection } from "@codemirror/state"; +import { matchesShortcut, type ShortcutLikeEvent } from "@/lib/editor/keyboardShortcuts"; +import { analyzePlainTextSelectionRanges, nextPlainTextSelectionRange } from "@/lib/editor/plainTextSelectionRanges"; +import { chooseNextSemanticSelectionRange, type SemanticSelectionRange } from "@/lib/editor/semanticSelectionRanges"; +import { analyzeSqlSemanticSelectionRanges, nextSqlSemanticSelectionRange, type SqlSemanticSelectionOptions } from "@/lib/editor/sqlSemanticSelectionRanges"; + +export interface QueryEditorExtendSelectionOptions extends SqlSemanticSelectionOptions { + language?: "sql" | "text"; +} + +export function extendQueryEditorSelection(view: EditorView, options: QueryEditorExtendSelectionOptions = {}): boolean { + const doc = view.state.doc.toString(); + const plainTextAnalysis = analyzePlainTextSelectionRanges(doc); + const sqlAnalysis = options.language === "text" ? null : analyzeSqlSemanticSelectionRanges(doc, options); + let changed = false; + const ranges = view.state.selection.ranges.map((range) => { + const context = { + doc, + cursor: range.head, + current: { from: range.from, to: range.to }, + preferDelimitedContent: options.language !== "text", + }; + const candidates: SemanticSelectionRange[] = []; + const plainTextCandidate = nextPlainTextSelectionRange(context, plainTextAnalysis); + if (plainTextCandidate) candidates.push(plainTextCandidate); + const sqlCandidate = sqlAnalysis ? nextSqlSemanticSelectionRange(context, options, sqlAnalysis) : null; + if (sqlCandidate) candidates.push(sqlCandidate); + const next = chooseNextSemanticSelectionRange(context.current, context.cursor, candidates); + if (!next) return range; + changed = true; + return range.anchor <= range.head ? EditorSelection.range(next.from, next.to) : EditorSelection.range(next.to, next.from); + }); + if (!changed) return false; + view.dispatch({ selection: EditorSelection.create(ranges, view.state.selection.mainIndex), scrollIntoView: true }); + return true; +} + +export interface PreventableShortcutEvent extends ShortcutLikeEvent { + preventDefault(): void; +} + +export function runQueryEditorAltExtendSelection(event: PreventableShortcutEvent, shortcut: string, view: EditorView, command: Command | null, platform = globalThis.navigator?.platform || ""): boolean { + if (!command || !event.altKey || event.ctrlKey || event.metaKey) return false; + if (!matchesShortcut(event, shortcut, platform)) return false; + if (!command(view)) return false; + event.preventDefault(); + return true; +} diff --git a/apps/desktop/src/lib/editor/semanticSelectionRanges.ts b/apps/desktop/src/lib/editor/semanticSelectionRanges.ts new file mode 100644 index 000000000..16a230f46 --- /dev/null +++ b/apps/desktop/src/lib/editor/semanticSelectionRanges.ts @@ -0,0 +1,156 @@ +export interface SemanticSelectionRange { + from: number; + to: number; +} + +export type SemanticSelectionNodeKind = + | "document" + | "paragraph" + | "line" + | "quoted-content" + | "quoted" + | "bracket-content" + | "bracketed" + | "word" + | "statement" + | "set-expression" + | "query-block" + | "clause" + | "logical-expression" + | "binary-expression" + | "unary-expression" + | "function-call" + | "qualified-identifier" + | "literal"; + +export interface SemanticSelectionNode extends SemanticSelectionRange { + kind: SemanticSelectionNodeKind; + children: SemanticSelectionNode[]; +} + +export interface SemanticSelectionContext { + doc: string; + cursor: number; + current: SemanticSelectionRange; + preferDelimitedContent?: boolean; +} + +export interface SemanticSelectionRangeIndex { + containing(current: SemanticSelectionRange): SemanticSelectionRange[]; + findNext(current: SemanticSelectionRange, cursor: number): SemanticSelectionRange | null; +} + +interface SemanticSelectionRangeIndexNode { + from: number; + to: number; + maxRangeTo: number; + minRangeLength: number; + left?: SemanticSelectionRangeIndexNode; + right?: SemanticSelectionRangeIndexNode; +} + +function compareSemanticSelectionRanges(left: SemanticSelectionRange, right: SemanticSelectionRange, cursor: number): number { + const lengthDifference = left.to - left.from - (right.to - right.from); + if (lengthDifference !== 0) return lengthDifference; + const leftDistance = Math.abs(cursor - (left.from + left.to) / 2); + const rightDistance = Math.abs(cursor - (right.from + right.to) / 2); + return leftDistance - rightDistance || left.from - right.from || left.to - right.to; +} + +export function createSemanticSelectionRangeIndex(candidates: readonly SemanticSelectionRange[]): SemanticSelectionRangeIndex { + const unique = new Map(); + for (const range of candidates) { + if (!Number.isInteger(range.from) || !Number.isInteger(range.to)) continue; + if (range.from < 0 || range.from >= range.to) continue; + unique.set(`${range.from}:${range.to}`, range); + } + const ranges = [...unique.values()].sort((left, right) => left.from - right.from || left.to - right.to); + + const buildNode = (from: number, to: number): SemanticSelectionRangeIndexNode | undefined => { + if (from >= to) return undefined; + if (to - from === 1) { + const range = ranges[from]; + if (!range) return undefined; + return { from, to, maxRangeTo: range.to, minRangeLength: range.to - range.from }; + } + const middle = from + Math.floor((to - from) / 2); + const left = buildNode(from, middle); + const right = buildNode(middle, to); + return { + from, + to, + maxRangeTo: Math.max(left?.maxRangeTo ?? -1, right?.maxRangeTo ?? -1), + minRangeLength: Math.min(left?.minRangeLength ?? Number.POSITIVE_INFINITY, right?.minRangeLength ?? Number.POSITIVE_INFINITY), + left, + right, + }; + }; + const root = buildNode(0, ranges.length); + + const upperBound = (position: number): number => { + let low = 0; + let high = ranges.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if ((ranges[middle]?.from ?? Number.POSITIVE_INFINITY) <= position) low = middle + 1; + else high = middle; + } + return low; + }; + + const visitContaining = (current: SemanticSelectionRange, visit: (range: SemanticSelectionRange) => void) => { + const maxIndex = upperBound(current.from); + const visitNode = (node: SemanticSelectionRangeIndexNode | undefined) => { + if (!node || node.from >= maxIndex || node.maxRangeTo < current.to) return; + if (node.to - node.from === 1) { + const range = ranges[node.from]; + if (range && range.to >= current.to) visit(range); + return; + } + visitNode(node.left); + visitNode(node.right); + }; + visitNode(root); + }; + + return { + containing(current) { + const result: SemanticSelectionRange[] = []; + visitContaining(current, (range) => result.push(range)); + return result; + }, + findNext(current, cursor) { + const maxIndex = upperBound(current.from); + let best: SemanticSelectionRange | null = null; + const visitNode = (node: SemanticSelectionRangeIndexNode | undefined) => { + if (!node || node.from >= maxIndex || node.maxRangeTo < current.to) return; + if (best && node.minRangeLength > best.to - best.from) return; + if (node.to - node.from === 1) { + const range = ranges[node.from]; + if (!range || range.to < current.to || (range.from === current.from && range.to === current.to)) return; + if (!best || compareSemanticSelectionRanges(range, best, cursor) < 0) best = range; + return; + } + const first = (node.left?.minRangeLength ?? Number.POSITIVE_INFINITY) <= (node.right?.minRangeLength ?? Number.POSITIVE_INFINITY) ? node.left : node.right; + const second = first === node.left ? node.right : node.left; + visitNode(first); + visitNode(second); + }; + visitNode(root); + return best; + }, + }; +} + +export function chooseNextSemanticSelectionRange(current: SemanticSelectionRange, cursor: number, candidates: readonly SemanticSelectionRange[]): SemanticSelectionRange | null { + const unique = new Map(); + for (const range of candidates) { + if (!Number.isInteger(range.from) || !Number.isInteger(range.to)) continue; + if (range.from < 0 || range.from >= range.to) continue; + if (range.from > current.from || range.to < current.to) continue; + if (range.from === current.from && range.to === current.to) continue; + unique.set(`${range.from}:${range.to}`, range); + } + + return [...unique.values()].sort((left, right) => compareSemanticSelectionRanges(left, right, cursor))[0] ?? null; +} diff --git a/apps/desktop/src/lib/editor/shortcutRegistry.ts b/apps/desktop/src/lib/editor/shortcutRegistry.ts index 738ae4536..b12708d18 100644 --- a/apps/desktop/src/lib/editor/shortcutRegistry.ts +++ b/apps/desktop/src/lib/editor/shortcutRegistry.ts @@ -18,6 +18,7 @@ export type ShortcutActionId = | "undo" | "redo" | "selectAll" + | "extendSelection" | "uppercaseSelection" | "lowercaseSelection" | "exPasteSqlInCondition" @@ -185,6 +186,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ scope: "editor", defaultShortcut: "Mod+A", }, + { + id: "extendSelection", + labelKey: "settings.shortcutExtendSelection", + scope: "editor", + defaultShortcut: "Alt+W", + }, { id: "uppercaseSelection", labelKey: "settings.shortcutUppercaseSelection", diff --git a/apps/desktop/src/lib/editor/sqlSemanticSelectionRanges.ts b/apps/desktop/src/lib/editor/sqlSemanticSelectionRanges.ts new file mode 100644 index 000000000..f401fe01b --- /dev/null +++ b/apps/desktop/src/lib/editor/sqlSemanticSelectionRanges.ts @@ -0,0 +1,429 @@ +import type { DatabaseType } from "@/types/database"; +import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect"; +import { tokenizeSqlSemantic } from "@/lib/sql/semantic/tokens"; +import type { SqlSemanticToken } from "@/lib/sql/semantic/types"; +import { createSemanticSelectionRangeIndex, type SemanticSelectionContext, type SemanticSelectionRange, type SemanticSelectionRangeIndex } from "@/lib/editor/semanticSelectionRanges"; + +export interface SqlSemanticSelectionOptions { + databaseType?: DatabaseType; + dialect?: "mysql" | "postgres" | "sqlserver" | "clickhouse"; +} + +interface SqlSelectionToken { + kind: SqlSemanticToken["kind"]; + text: string; + normalized: string; + from: number; + to: number; + depth: number; + quote?: string; +} + +interface StatementWindow { + start: number; + end: number; + tokens: SqlSelectionToken[]; +} + +interface PreparedStatementWindow extends StatementWindow { + ranges?: SemanticSelectionRangeIndex; +} + +interface QueryBlockRange extends SemanticSelectionRange { + depth: number; +} + +export interface SqlSemanticSelectionAnalysis { + doc: string; + statements: readonly PreparedStatementWindow[]; +} + +const QUERY_CLAUSE_WORDS = new Set(["where", "having", "on", "using", "limit", "offset", "fetch", "returning", "qualify", "window"]); +const SET_OPERATOR_WORDS = new Set(["union", "intersect", "except", "minus"]); +const COMBINED_OPERATORS = new Set(["!=", "<>", "<=", ">=", "||", "::", "->", "->>"]); +const SQL_BINARY_PRECEDENCE: Record = { + or: 10, + and: 20, + "=": 30, + "!=": 30, + "<>": 30, + "<": 30, + "<=": 30, + ">": 30, + ">=": 30, + is: 30, + in: 30, + like: 30, + between: 30, + "||": 40, + "+": 50, + "-": 50, + "*": 60, + "/": 60, + "%": 60, +}; + +function addRange(ranges: SemanticSelectionRange[], from: number, to: number) { + if (from < to) ranges.push({ from, to }); +} + +function addTrimmedRange(input: string, ranges: SemanticSelectionRange[], from: number, to: number) { + while (from < to && /\s/.test(input[from] ?? "")) from += 1; + while (to > from && /\s/.test(input[to - 1] ?? "")) to -= 1; + addRange(ranges, from, to); +} + +function significantTokens(tokens: SqlSelectionToken[]): SqlSelectionToken[] { + return tokens.filter((token) => token.kind !== "comment"); +} + +function normalizeTokens(input: string, tokens: SqlSemanticToken[]): SqlSelectionToken[] { + const result: SqlSelectionToken[] = []; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!token) continue; + const next = tokens[index + 1]; + if (token.kind === "operator" && next?.kind === "operator" && token.span.end === next.span.start) { + const combined = `${token.text}${next.text}`; + const nextNext = tokens[index + 2]; + const triple = nextNext?.kind === "operator" && next.span.end === nextNext.span.start ? `${combined}${nextNext.text}` : ""; + if (triple && COMBINED_OPERATORS.has(triple)) { + result.push({ kind: "operator", text: triple, normalized: triple, from: token.span.start, to: nextNext.span.end, depth: token.depth }); + index += 2; + continue; + } + if (COMBINED_OPERATORS.has(combined)) { + result.push({ kind: "operator", text: combined, normalized: combined, from: token.span.start, to: next.span.end, depth: token.depth }); + index += 1; + continue; + } + } + result.push({ kind: token.kind, text: input.slice(token.span.start, token.span.end), normalized: token.normalized, from: token.span.start, to: token.span.end, depth: token.depth, quote: token.quote }); + } + return result; +} + +function validateTokens(input: string, tokens: SqlSelectionToken[]): boolean { + const stack: SqlSelectionToken[] = []; + for (const token of tokens) { + if (token.kind === "string") { + const quote = token.quote ?? token.text[0] ?? ""; + if (quote.startsWith("$") ? !token.text.endsWith(quote) : !token.text.endsWith(quote)) return false; + } + if (token.kind === "comment" && token.text.startsWith("/*") && !token.text.endsWith("*/")) return false; + if (token.text === "(") stack.push(token); + if (token.text === ")") { + if (stack.length === 0) return false; + stack.pop(); + } + } + if (stack.length > 0) return false; + return input.trim().length > 0; +} + +function statementWindows(input: string, tokens: SqlSelectionToken[]): StatementWindow[] { + const significant = significantTokens(tokens); + const boundaries = [-1, ...significant.map((token, index) => (token.text === ";" && token.depth === 0 ? index : -1)).filter((index) => index >= 0), significant.length]; + const statements: StatementWindow[] = []; + for (let boundaryIndex = 0; boundaryIndex < boundaries.length - 1; boundaryIndex += 1) { + const left = boundaries[boundaryIndex] ?? -1; + const right = boundaries[boundaryIndex + 1] ?? significant.length; + const first = significant[left + 1]; + const last = significant[right - 1]; + if (!first) continue; + const start = first.from; + const end = last?.text === ";" ? last.from : (last?.to ?? input.length); + statements.push({ start, end, tokens: significant.slice(left + 1, last?.text === ";" ? right - 1 : right) }); + } + return statements; +} + +function parenthesisMaps(tokens: SqlSelectionToken[]): { opening: Map; closing: Map } | null { + const stack: number[] = []; + const opening = new Map(); + const closing = new Map(); + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!token) continue; + if (token.text === "(") stack.push(index); + if (token.text === ")") { + const open = stack.pop(); + if (open == null) return null; + opening.set(open, index); + closing.set(index, open); + } + } + return stack.length === 0 ? { opening, closing } : null; +} + +function clauseStart(tokens: SqlSelectionToken[], index: number): number { + const token = tokens[index]; + if (!token || token.kind !== "word") return 0; + if ((token.normalized === "group" || token.normalized === "order") && tokens[index + 1]?.normalized === "by") return token.from; + return token.normalized === "from" || token.normalized === "select" ? token.from : token.from; +} + +function isClauseAt(tokens: SqlSelectionToken[], index: number): boolean { + const token = tokens[index]; + if (!token || token.kind !== "word") return false; + if ((token.normalized === "group" || token.normalized === "order") && tokens[index + 1]?.normalized === "by") return true; + return QUERY_CLAUSE_WORDS.has(token.normalized); +} + +function rangeEndForDepth(tokens: SqlSelectionToken[], startIndex: number, statementEnd: number): number { + const depth = tokens[startIndex]?.depth ?? 0; + for (let index = startIndex + 1; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token?.text === ")" && token.depth < depth) return token.from; + } + return statementEnd; +} + +function queryBlockEnd(tokens: SqlSelectionToken[], startIndex: number, statementEnd: number): number { + const depth = tokens[startIndex]?.depth ?? 0; + for (let index = startIndex + 1; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!token) continue; + if (token.text === ")" && token.depth < depth) return token.from; + if (token.depth === depth && token.kind === "word" && SET_OPERATOR_WORDS.has(token.normalized)) return token.from; + } + return statementEnd; +} + +function nextClauseOrSetBoundary(tokens: SqlSelectionToken[], startIndex: number, statementEnd: number): number { + const depth = tokens[startIndex]?.depth ?? 0; + for (let index = startIndex + 1; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!token) continue; + if (token.text === ")" && token.depth < depth) return token.from; + if (token.depth !== depth) continue; + if (isClauseAt(tokens, index) || (token.kind === "word" && SET_OPERATOR_WORDS.has(token.normalized))) return token.from; + } + return rangeEndForDepth(tokens, startIndex, statementEnd); +} + +function isExpressionBoundary(token: SqlSelectionToken | undefined): boolean { + if (!token) return true; + if (token.text === "," || token.text === ";") return true; + if (token.kind !== "word") return false; + return token.normalized === "select" || token.normalized === "from" || token.normalized === "join" || token.normalized === "group" || token.normalized === "order" || token.normalized === "as" || SET_OPERATOR_WORDS.has(token.normalized) || isClauseAt([token], 0); +} + +function isBetweenAnd(tokens: SqlSelectionToken[], direct: number[], operatorPosition: number): boolean { + for (let position = operatorPosition - 1; position >= 0; position -= 1) { + const token = tokens[direct[position] ?? -1]; + if (!token || isExpressionBoundary(token) || token.normalized === "and" || token.normalized === "or") return false; + if (token.kind === "word" && token.normalized === "between") return true; + } + return false; +} + +function rangeForDirectTokens(tokens: SqlSelectionToken[], direct: number[], from: number, to: number, maps: { opening: Map; closing: Map }): SemanticSelectionRange | null { + const firstIndex = direct[from]; + const lastIndex = direct[to - 1]; + const first = firstIndex == null ? undefined : tokens[firstIndex]; + const last = lastIndex == null ? undefined : tokens[lastIndex]; + if (!first || !last) return null; + const closingIndex = last.text === "(" ? maps.opening.get(lastIndex) : undefined; + return { from: first.from, to: closingIndex == null ? last.to : (tokens[closingIndex]?.to ?? last.to) }; +} + +function isUnarySign(tokens: SqlSelectionToken[], direct: number[], position: number): boolean { + const token = tokens[direct[position] ?? -1]; + if (!token || (token.normalized !== "+" && token.normalized !== "-")) return false; + if (position === 0) return true; + const previous = tokens[direct[position - 1] ?? -1]; + return previous != null && SQL_BINARY_PRECEDENCE[previous.normalized] != null; +} + +function operandEndForOperator(tokens: SqlSelectionToken[], direct: number[], operatorPosition: number): number { + const operator = tokens[direct[operatorPosition] ?? -1]; + const previous = tokens[direct[operatorPosition - 1] ?? -1]; + return previous?.normalized === "not" && (operator?.normalized === "in" || operator?.normalized === "like" || operator?.normalized === "between") ? operatorPosition - 1 : operatorPosition; +} + +function collectExpressionSection(tokens: SqlSelectionToken[], direct: number[], maps: { opening: Map; closing: Map }, ranges: SemanticSelectionRange[]) { + const operatorPositions: number[] = []; + for (let position = 0; position < direct.length; position += 1) { + const token = tokens[direct[position] ?? -1]; + if (!token || SQL_BINARY_PRECEDENCE[token.normalized] == null || isUnarySign(tokens, direct, position)) continue; + if (token.normalized !== "and" || !isBetweenAnd(tokens, direct, position)) operatorPositions.push(position); + } + if (operatorPositions.length === 0) return; + + const operands: SemanticSelectionRange[] = []; + let start = 0; + for (const operatorPosition of operatorPositions) { + const operand = rangeForDirectTokens(tokens, direct, start, operandEndForOperator(tokens, direct, operatorPosition), maps); + if (!operand) return; + operands.push(operand); + start = operatorPosition + 1; + } + const lastOperand = rangeForDirectTokens(tokens, direct, start, direct.length, maps); + if (!lastOperand || operands.length !== operatorPositions.length) return; + operands.push(lastOperand); + for (let index = 0; index < operands.length; index += 1) { + const previousOperatorPosition = operatorPositions[index - 1]; + const previousOperator = previousOperatorPosition == null ? undefined : tokens[direct[previousOperatorPosition] ?? -1]; + if (previousOperator?.normalized !== "between") { + const operand = operands[index]; + if (operand) addRange(ranges, operand.from, operand.to); + } + } + + const expressionStack: SemanticSelectionRange[] = [operands[0] as SemanticSelectionRange]; + const operatorStack: SqlSelectionToken[] = []; + const reduce = () => { + const right = expressionStack.pop(); + const left = expressionStack.pop(); + operatorStack.pop(); + if (!left || !right) return false; + const combined = { from: left.from, to: right.to }; + expressionStack.push(combined); + addRange(ranges, combined.from, combined.to); + return true; + }; + + for (let index = 0; index < operatorPositions.length; index += 1) { + const operator = tokens[direct[operatorPositions[index] ?? -1] ?? -1]; + const operand = operands[index + 1]; + if (!operator || !operand) return; + const precedence = SQL_BINARY_PRECEDENCE[operator.normalized] ?? 0; + while ((SQL_BINARY_PRECEDENCE[operatorStack[operatorStack.length - 1]?.normalized ?? ""] ?? -1) >= precedence) { + if (!reduce()) return; + } + operatorStack.push(operator); + expressionStack.push(operand); + } + while (operatorStack.length > 0) { + if (!reduce()) return; + } +} + +function collectExpressionRanges(tokens: SqlSelectionToken[], start: number, end: number, maps: { opening: Map; closing: Map }, ranges: SemanticSelectionRange[]) { + const direct: number[] = []; + for (let index = start; index < end; index += 1) { + const token = tokens[index]; + if (!token) continue; + if (token.text === "(") { + const close = maps.opening.get(index); + if (close == null || close >= end) return; + collectExpressionRanges(tokens, index + 1, close, maps, ranges); + direct.push(index); + index = close; + continue; + } + if (token.text === ")") continue; + direct.push(index); + } + + let sectionStart = 0; + for (let position = 0; position <= direct.length; position += 1) { + const token = position === direct.length ? undefined : tokens[direct[position] ?? -1]; + if (!isExpressionBoundary(token)) continue; + collectExpressionSection(tokens, direct.slice(sectionStart, position), maps, ranges); + sectionStart = position + 1; + } +} + +function collectSqlRanges(input: string, statement: StatementWindow, maps: { opening: Map; closing: Map }): SemanticSelectionRange[] { + const tokens = statement.tokens; + const ranges: SemanticSelectionRange[] = []; + const queryBlocks: QueryBlockRange[] = []; + const setExpressions: QueryBlockRange[] = []; + addRange(ranges, statement.start, statement.end); + for (const token of tokens) { + if (token.kind === "string") { + const quote = token.quote ?? token.text[0] ?? ""; + const quoteLength = quote.startsWith("$") ? quote.length : 1; + addRange(ranges, token.from + quoteLength, token.to - quoteLength); + addRange(ranges, token.from, token.to); + } + } + for (const [open, close] of maps.opening) { + const opening = tokens[open]; + const closing = tokens[close]; + if (!opening || !closing) continue; + addRange(ranges, opening.to, closing.from); + const functionName = tokens[open - 1]; + if (functionName?.kind !== "word") addRange(ranges, opening.from, closing.to); + } + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!token) continue; + if (token.kind === "word" && token.normalized === "select") { + const previousLength = ranges.length; + addTrimmedRange(input, ranges, token.from, queryBlockEnd(tokens, index, statement.end)); + const queryBlock = ranges.length > previousLength ? ranges[ranges.length - 1] : undefined; + if (queryBlock) queryBlocks.push({ ...queryBlock, depth: token.depth }); + } + if (isClauseAt(tokens, index)) addTrimmedRange(input, ranges, clauseStart(tokens, index), nextClauseOrSetBoundary(tokens, index, statement.end)); + if (token.kind === "word" && !isExpressionBoundary(token) && maps.opening.has(index + 1)) { + const close = maps.opening.get(index + 1); + const closing = close == null ? undefined : tokens[close]; + if (closing) addTrimmedRange(input, ranges, token.from, closing.to); + } + } + collectExpressionRanges(tokens, 0, tokens.length, maps, ranges); + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token?.kind === "word" && SET_OPERATOR_WORDS.has(token.normalized)) { + const left = [...queryBlocks, ...setExpressions].filter((range) => range.depth === token.depth && range.to <= token.from).sort((a, b) => b.to - a.to || a.from - b.from)[0]; + const right = queryBlocks.filter((range) => range.depth === token.depth && range.from >= token.to).sort((a, b) => a.from - b.from || a.to - b.to)[0]; + if (left && right) { + const expression = { from: left.from, to: right.to, depth: token.depth }; + addRange(ranges, expression.from, expression.to); + setExpressions.push(expression); + } + } + } + return ranges; +} + +export function analyzeSqlSemanticSelectionRanges(doc: string, options: SqlSemanticSelectionOptions = {}): SqlSemanticSelectionAnalysis { + const dialect = sqlSemanticDialectFor(options).id; + const rawTokens = tokenizeSqlSemantic(doc, dialect); + const validationTokens = rawTokens.map((token) => ({ kind: token.kind, text: token.text, normalized: token.normalized, from: token.span.start, to: token.span.end, depth: token.depth, quote: token.quote })); + const tokens = normalizeTokens(doc, rawTokens); + const statements: PreparedStatementWindow[] = statementWindows(doc, tokens).filter((statement) => { + const statementValidationTokens = validationTokens.filter((token) => token.from >= statement.start && token.to <= statement.end); + return validateTokens(doc.slice(statement.start, statement.end), statementValidationTokens); + }); + return { doc, statements }; +} + +function findStatement(context: SemanticSelectionContext, analysis: SqlSemanticSelectionAnalysis): PreparedStatementWindow | undefined { + let low = 0; + let high = analysis.statements.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if ((analysis.statements[middle]?.start ?? Number.POSITIVE_INFINITY) <= context.cursor) low = middle + 1; + else high = middle; + } + const statement = analysis.statements[low - 1]; + if (!statement || context.cursor < statement.start || context.cursor > statement.end) return undefined; + if (context.current.from < statement.start || context.current.to > statement.end) return undefined; + return statement; +} + +function statementRangeIndex(doc: string, statement: PreparedStatementWindow): SemanticSelectionRangeIndex | null { + if (statement.ranges) return statement.ranges; + const maps = parenthesisMaps(statement.tokens); + if (!maps) return null; + statement.ranges = createSemanticSelectionRangeIndex(collectSqlRanges(doc, statement, maps)); + return statement.ranges; +} + +export function sqlSemanticSelectionRanges(context: SemanticSelectionContext, options: SqlSemanticSelectionOptions = {}, analysis = analyzeSqlSemanticSelectionRanges(context.doc, options)): SemanticSelectionRange[] { + if (analysis.doc !== context.doc) analysis = analyzeSqlSemanticSelectionRanges(context.doc, options); + const statement = findStatement(context, analysis); + return statement ? (statementRangeIndex(context.doc, statement)?.containing(context.current) ?? []) : []; +} + +export function nextSqlSemanticSelectionRange(context: SemanticSelectionContext, options: SqlSemanticSelectionOptions = {}, analysis = analyzeSqlSemanticSelectionRanges(context.doc, options)): SemanticSelectionRange | null { + if (analysis.doc !== context.doc) analysis = analyzeSqlSemanticSelectionRanges(context.doc, options); + const statement = findStatement(context, analysis); + return statement ? (statementRangeIndex(context.doc, statement)?.findNext(context.current, context.cursor) ?? null) : null; +} diff --git a/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts b/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts index 72cbbdbee..c4f2ba26d 100644 --- a/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts +++ b/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts @@ -41,6 +41,10 @@ describe("EDITOR_SETTINGS_DRAFT_KEYS", () => { it("includes the saved SQL open target mode", () => { expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("savedSqlOpenTargetMode"); }); + + it("includes the regular expression match limit", () => { + expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("regexMaxMatchCount"); + }); }); describe("editorSettingsDraftFromSettings", () => { diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index 0b4739c20..d239303f0 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -34,6 +34,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "tableOpenPageSize", "infiniteScroll", "infiniteScrollMaxRows", + "regexMaxMatchCount", "autoCalculateTotalRows", "tableColumnTemplateFields", "shortcuts", diff --git a/apps/desktop/src/lib/sql/semantic/dialect.ts b/apps/desktop/src/lib/sql/semantic/dialect.ts index 658bd9a2f..b3e5e74a4 100644 --- a/apps/desktop/src/lib/sql/semantic/dialect.ts +++ b/apps/desktop/src/lib/sql/semantic/dialect.ts @@ -158,7 +158,7 @@ export function sqlReferenceAnalysisDialectFor(options: { databaseType?: Databas return options.fallbackDialect; } -export function sqlSemanticDialectFor(options: { databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver" }): SqlSemanticDialectAdapter { +export function sqlSemanticDialectFor(options: { databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver" | "clickhouse" }): SqlSemanticDialectAdapter { if (options.databaseType === "clickhouse") return SQL_SEMANTIC_DIALECTS.clickhouse; if (options.dialect && SQL_SEMANTIC_DIALECTS[options.dialect]) return SQL_SEMANTIC_DIALECTS[options.dialect]; switch (options.databaseType) { diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index 46a1a4666..dfaed967d 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -5,6 +5,13 @@ import { isProxy } from "vue"; import type { AiConfigItem } from "@/types/ai"; describe("normalizeEditorSettings", () => { + it("defaults and bounds the regular expression match limit", () => { + expect(normalizeEditorSettings({}).regexMaxMatchCount).toBe(1000); + expect(normalizeEditorSettings({ regexMaxMatchCount: 2500 }).regexMaxMatchCount).toBe(2500); + expect(normalizeEditorSettings({ regexMaxMatchCount: 99 }).regexMaxMatchCount).toBe(1000); + expect(normalizeEditorSettings({ regexMaxMatchCount: Number.POSITIVE_INFINITY }).regexMaxMatchCount).toBe(1000); + expect(normalizeEditorSettings({ regexMaxMatchCount: Number.NaN }).regexMaxMatchCount).toBe(1000); + }); it("uses aligned comments by default and preserves legacy comment visibility", () => { expect(normalizeEditorSettings({}).sidebarObjectInfoMode).toBe("comment-aligned"); expect(normalizeEditorSettings({ sidebarObjectInfoMode: "comment-aligned" }).sidebarObjectInfoMode).toBe("comment-aligned"); @@ -443,6 +450,25 @@ describe("settingsStore sidebar connection sort persistence", () => { }); }); +describe("settingsStore regular expression match limit persistence", () => { + beforeEach(() => { + vi.resetModules(); + setActivePinia(createPinia()); + }); + + it("normalizes and persists the configured match limit", async () => { + const saveEditorSettings = vi.fn().mockResolvedValue(undefined); + vi.doMock("@/lib/backend/api", () => ({ saveEditorSettings })); + + const { useSettingsStore } = await import("@/stores/settingsStore"); + const store = useSettingsStore(); + store.updateEditorSettings({ regexMaxMatchCount: 2500.4 }); + + expect(store.editorSettings.regexMaxMatchCount).toBe(2500); + expect(saveEditorSettings).toHaveBeenCalledWith(expect.objectContaining({ regexMaxMatchCount: 2500 })); + }); +}); + // --- activeModel lifecycle tests --- describe("settingsStore activeModel lifecycle", () => { diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index cbebdbc99..36d9fb4db 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -462,6 +462,7 @@ export interface EditorSettings { tableOpenPageSize: number; infiniteScroll: boolean; infiniteScrollMaxRows: number; + regexMaxMatchCount: number; autoCalculateTotalRows: boolean; mongoViewMode: "document" | "table"; showColumnCommentsInHeader: boolean; @@ -639,6 +640,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { tableOpenPageSize: 100, infiniteScroll: false, infiniteScrollMaxRows: 5000, + regexMaxMatchCount: 1000, autoCalculateTotalRows: false, mongoViewMode: "document", showColumnCommentsInHeader: true, @@ -947,6 +949,7 @@ export function normalizeEditorSettings(settings: Partial, exist tableOpenPageSize: normalizeResultPageSize(settings.tableOpenPageSize, DEFAULT_EDITOR_SETTINGS.tableOpenPageSize), infiniteScroll: settings.infiniteScroll ?? DEFAULT_EDITOR_SETTINGS.infiniteScroll, infiniteScrollMaxRows: typeof settings.infiniteScrollMaxRows === "number" && settings.infiniteScrollMaxRows >= 1000 && settings.infiniteScrollMaxRows <= 50000 ? Math.round(settings.infiniteScrollMaxRows) : DEFAULT_EDITOR_SETTINGS.infiniteScrollMaxRows, + regexMaxMatchCount: typeof settings.regexMaxMatchCount === "number" && Number.isFinite(settings.regexMaxMatchCount) && settings.regexMaxMatchCount >= 100 && settings.regexMaxMatchCount <= 10000 ? Math.round(settings.regexMaxMatchCount) : DEFAULT_EDITOR_SETTINGS.regexMaxMatchCount, autoCalculateTotalRows: settings.autoCalculateTotalRows ?? DEFAULT_EDITOR_SETTINGS.autoCalculateTotalRows, mongoViewMode: settings.mongoViewMode === "table" ? "table" : DEFAULT_EDITOR_SETTINGS.mongoViewMode, showColumnCommentsInHeader: settings.showColumnCommentsInHeader ?? DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader, @@ -1430,6 +1433,9 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.infiniteScroll !== undefined) editorSettings.value.infiniteScroll = partial.infiniteScroll; if (partial.infiniteScrollMaxRows !== undefined) editorSettings.value.infiniteScrollMaxRows = typeof partial.infiniteScrollMaxRows === "number" && partial.infiniteScrollMaxRows >= 1000 && partial.infiniteScrollMaxRows <= 50000 ? Math.round(partial.infiniteScrollMaxRows) : DEFAULT_EDITOR_SETTINGS.infiniteScrollMaxRows; + if (partial.regexMaxMatchCount !== undefined) + editorSettings.value.regexMaxMatchCount = + typeof partial.regexMaxMatchCount === "number" && Number.isFinite(partial.regexMaxMatchCount) && partial.regexMaxMatchCount >= 100 && partial.regexMaxMatchCount <= 10000 ? Math.round(partial.regexMaxMatchCount) : DEFAULT_EDITOR_SETTINGS.regexMaxMatchCount; if (partial.autoCalculateTotalRows !== undefined) editorSettings.value.autoCalculateTotalRows = partial.autoCalculateTotalRows === true; if (partial.mongoViewMode !== undefined) editorSettings.value.mongoViewMode = partial.mongoViewMode; if (partial.showColumnCommentsInHeader !== undefined) editorSettings.value.showColumnCommentsInHeader = partial.showColumnCommentsInHeader; diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index 93129a79e..9c2077a37 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -315,6 +315,7 @@ test("defaults shortcut settings", () => { assert.equal(settings.shortcuts.executeSql, "Mod+Enter"); assert.equal(settings.shortcuts.saveSql, "Mod+S"); + assert.equal(settings.shortcuts.extendSelection, "Alt+W"); assert.equal(settings.shortcuts.copyCurrentRow, "Mod+D"); assert.equal(settings.shortcuts.deleteCurrentRow, "Delete"); assert.equal(settings.shortcuts.newQuery, "Mod+T");