feat(editor): add semantic selection and match-all cursors

This commit is contained in:
fuuulstack 2026-08-03 15:50:43 +08:00 committed by GitHub
parent 02bad5d754
commit ed26fc10f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1794 additions and 54 deletions

View File

@ -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<HTMLInputElement>();
const replaceInputRef = ref<HTMLInputElement>();
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<typeof setTimeout> | null = null;
let documentSearchUpdateTimer: ReturnType<typeof setTimeout> | 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<EditorSearchMatch> {
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({
.*
</button>
</div>
<span class="min-w-[3.4rem] shrink-0 text-center text-xs" :class="searchText && matchCount === 0 ? 'text-destructive' : 'text-muted-foreground'">
{{ searchText && matchCount > 0 ? `${currentMatchIndex}/${matchCount}${matchCountLimited ? "+" : ""}` : t("editor.search.noResults") }}
<span class="min-w-[3.4rem] shrink-0 text-center text-xs" :class="searchText && matchCount === 0 ? 'text-destructive' : 'text-muted-foreground'" aria-live="polite">
{{ selectionLimitReached ? t("editor.search.selectionLimitSummary", { limit: searchMatchLimit(), total: matchCount }) : searchText && matchCount > 0 ? `${currentMatchIndex}/${matchCount}` : t("editor.search.noResults") }}
</span>
<button
class="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
:disabled="!searchText || matchCount === 0"
:title="selectionLimitReached ? t('editor.search.selectionTruncated', { limit: searchMatchLimit(), total: matchCount }) : t('editor.search.selectAllLimit', { limit: searchMatchLimit() })"
:aria-label="selectionLimitReached ? t('editor.search.selectionTruncated', { limit: searchMatchLimit(), total: matchCount }) : t('editor.search.selectAllLimit', { limit: searchMatchLimit() })"
@click="selectAllMatches"
>
<TextSelect class="h-4 w-4" />
</button>
<span v-if="inSelectionScope" class="shrink-0 rounded bg-accent px-1 py-0.5 text-[10px] font-medium text-muted-foreground" :title="t('editor.search.inSelection')">
{{ t("editor.search.inSelection") }}
</span>

View File

@ -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<TableColumnTemplateGridRow[]>(tableColumnTemplateRowsFromSettings(settingsStore.editorSettings.tableColumnTemplateFields));
const editTableColumnTemplateDatabaseType = ref<DatabaseType>(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(() => {
<Switch id="editor-auto-close-brackets" v-model="editAutoCloseBrackets" class="mt-0.5" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="regex-max-match-count">{{ t("settings.regexMaxMatchCount") }}</Label>
<p class="text-xs text-muted-foreground">{{ t("settings.regexMaxMatchCountDescription") }}</p>
</div>
<Input
id="regex-max-match-count"
v-model.number="editRegexMaxMatchCount"
type="number"
inputmode="numeric"
:min="100"
:max="10000"
class="h-7 w-24 px-2 text-xs tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-insert-space-after-completion">{{ t("settings.insertSpaceAfterCompletion") }}</Label>

View File

@ -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()),

View File

@ -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]),

View File

@ -121,6 +121,7 @@ async function initDdlEditor(content: string) {
},
}),
basicSetup,
EditorState.allowMultipleSelections.of(true),
langSql.sql({ dialect }),
themeExt,
fontExt,

View File

@ -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 (100050000).",
regexMaxMatchCount: "Maximum select-all matches",
regexMaxMatchCountDescription: "Limits the number of selections created by Select All Matches; search counting remains complete (10010000).",
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",

View File

@ -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 (100050000).",
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 (10010000).",
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",

View File

@ -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 (100050000).",
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 (10010000).",
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",

View File

@ -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: "データエクスポートの行数制限",

View File

@ -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 (100050000).",
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 (10010000).",
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",

View File

@ -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: "无限滚动模式下最多加载的行数100050000。",
regexMaxMatchCount: "全部选中最大数量",
regexMaxMatchCountDescription: "限制“全部选中”一次创建的选区数量搜索匹配仍完整统计10010000。",
tableColumnTemplateFields: "新建表预设字段",
tableColumnTemplateFieldsDescription: "先选择数据库类型,再配置新建表时使用的预设字段类型。",
tableColumnTemplateAdd: "新增字段",
@ -5023,6 +5029,7 @@ export default withEnglishFallback({
shortcutUndo: "撤销",
shortcutRedo: "重做",
shortcutSelectAll: "全选",
shortcutExtendSelection: "扩展选择",
shortcutUppercaseSelection: "选中内容转为大写",
shortcutLowercaseSelection: "选中内容转为小写",
shortcutExPasteSqlInCondition: "ExPaste粘贴为 IN 条件",

View File

@ -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: "無限滾動模式下最多載入的筆數100050000。",
regexMaxMatchCount: "全部選取最大數量",
regexMaxMatchCountDescription: "限制「全部選取」一次建立的選取範圍數量搜尋符合結果仍會完整統計10010000。",
tableColumnTemplateFields: "新建表預設欄位",
tableColumnTemplateFieldsDescription: "先選擇資料庫類型,再設定新建表時使用的預設欄位類型。",
tableColumnTemplateAdd: "新增欄位",
@ -4213,6 +4219,7 @@ export default withEnglishFallback({
shortcutUndo: "復原",
shortcutRedo: "重做",
shortcutSelectAll: "全選",
shortcutExtendSelection: "擴展選取範圍",
shortcutCopyCurrentRow: "複製目前資料列",
shortcutDeleteCurrentRow: "刪除目前資料列",
shortcutNewQuery: "建立查詢",

View File

@ -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);

View File

@ -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)");
});
});

View File

@ -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<typeof EditorSelection.range>[] = [];
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<typeof EditorSelection.range>[] = [];
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);
});
});
});

View File

@ -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" });

View File

@ -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);

View File

@ -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<EditorSearchMatch>, 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<EditorSearchMatch>, 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;
}

View File

@ -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<Record<string, string>> = { "'": "'", '"': '"', "`": "`" };
const BRACKET_PAIRS: Readonly<Record<string, string>> = { "(": ")", "[": "]", "{": "}" };
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);
}

View File

@ -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;
}

View File

@ -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<string, SemanticSelectionRange>();
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<string, SemanticSelectionRange>();
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;
}

View File

@ -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",

View File

@ -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<string, number> = {
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<number, number>; closing: Map<number, number> } | null {
const stack: number[] = [];
const opening = new Map<number, number>();
const closing = new Map<number, number>();
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<number, number>; closing: Map<number, number> }): 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<number, number>; closing: Map<number, number> }, 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<number, number>; closing: Map<number, number> }, 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<number, number>; closing: Map<number, number> }): 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;
}

View File

@ -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", () => {

View File

@ -34,6 +34,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"tableOpenPageSize",
"infiniteScroll",
"infiniteScrollMaxRows",
"regexMaxMatchCount",
"autoCalculateTotalRows",
"tableColumnTemplateFields",
"shortcuts",

View File

@ -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) {

View File

@ -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", () => {

View File

@ -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<EditorSettings>, 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;

View File

@ -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");