feat(editor): add semantic selection and match-all cursors
This commit is contained in:
parent
02bad5d754
commit
ed26fc10f5
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ async function initDdlEditor(content: string) {
|
|||
},
|
||||
}),
|
||||
basicSetup,
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
langSql.sql({ dialect }),
|
||||
themeExt,
|
||||
fontExt,
|
||||
|
|
|
|||
|
|
@ -885,6 +885,10 @@ export default {
|
|||
expandReplace: "Expand replace",
|
||||
prevMatch: "Previous (Shift+Enter)",
|
||||
nextMatch: "Next (Enter)",
|
||||
selectAll: "Select all matches",
|
||||
selectAllLimit: "Select all matches (up to {limit})",
|
||||
selectionTruncated: "Found {total} matches; selected the first {limit}.",
|
||||
selectionLimitSummary: "Selected {limit}/{total}",
|
||||
close: "Close (Esc)",
|
||||
noResults: "No results",
|
||||
inSelection: "In selection",
|
||||
|
|
@ -4787,6 +4791,8 @@ export default {
|
|||
infiniteScrollDescription: "Automatically load the next page of data when scrolling to the bottom of the table.",
|
||||
infiniteScrollMaxRows: "Infinite scroll max rows",
|
||||
infiniteScrollMaxRowsDescription: "Maximum number of rows to load in infinite scroll mode (1000–50000).",
|
||||
regexMaxMatchCount: "Maximum select-all matches",
|
||||
regexMaxMatchCountDescription: "Limits the number of selections created by Select All Matches; search counting remains complete (100–10000).",
|
||||
tableColumnTemplateFields: "New Table Preset Fields",
|
||||
tableColumnTemplateFieldsDescription: "Choose a database type, then configure the preset field types used when creating new tables.",
|
||||
tableColumnTemplateAdd: "Add Field",
|
||||
|
|
@ -5024,6 +5030,7 @@ export default {
|
|||
shortcutUndo: "Undo",
|
||||
shortcutRedo: "Redo",
|
||||
shortcutSelectAll: "Select all",
|
||||
shortcutExtendSelection: "Extend selection",
|
||||
shortcutCopyCurrentRow: "Copy current data row",
|
||||
shortcutDeleteCurrentRow: "Delete current data row",
|
||||
shortcutNewQuery: "New query",
|
||||
|
|
|
|||
|
|
@ -851,6 +851,10 @@ export default withEnglishFallback({
|
|||
expandReplace: "Expandir reemplazo",
|
||||
prevMatch: "Anterior (Shift+Enter)",
|
||||
nextMatch: "Siguiente (Enter)",
|
||||
selectAll: "Seleccionar todas las coincidencias",
|
||||
selectAllLimit: "Seleccionar todas las coincidencias (hasta {limit})",
|
||||
selectionTruncated: "Se encontraron {total} coincidencias; se seleccionaron las primeras {limit}.",
|
||||
selectionLimitSummary: "Seleccionadas {limit}/{total}",
|
||||
close: "Cerrar (Esc)",
|
||||
noResults: "Sin resultados",
|
||||
inSelection: "En la selección",
|
||||
|
|
@ -4567,6 +4571,8 @@ export default withEnglishFallback({
|
|||
infiniteScrollDescription: "Carga automáticamente la siguiente página de datos al desplazarse hasta el final de la tabla.",
|
||||
infiniteScrollMaxRows: "Máximo de filas en desplazamiento infinito",
|
||||
infiniteScrollMaxRowsDescription: "Número máximo de filas a cargar en modo desplazamiento infinito (1000–50000).",
|
||||
regexMaxMatchCount: "Máximo de coincidencias al seleccionar todas",
|
||||
regexMaxMatchCountDescription: "Limita el número de selecciones creadas por Seleccionar todas las coincidencias; el conteo de búsqueda permanece completo (100–10000).",
|
||||
tableColumnTemplateFields: "Campos predefinidos para tablas nuevas",
|
||||
tableColumnTemplateFieldsDescription: "Elige un tipo de base de datos y configura los tipos de campo predefinidos usados al crear tablas nuevas.",
|
||||
tableColumnTemplateAdd: "Agregar campo",
|
||||
|
|
@ -4761,6 +4767,7 @@ export default withEnglishFallback({
|
|||
shortcutUndo: "Deshacer",
|
||||
shortcutRedo: "Rehacer",
|
||||
shortcutSelectAll: "Seleccionar todo",
|
||||
shortcutExtendSelection: "Ampliar selección",
|
||||
shortcutCopyCurrentRow: "Copiar fila de datos actual",
|
||||
shortcutDeleteCurrentRow: "Eliminar fila de datos actual",
|
||||
shortcutNewQuery: "Nueva consulta",
|
||||
|
|
|
|||
|
|
@ -849,6 +849,10 @@ export default withEnglishFallback({
|
|||
expandReplace: "Espandi sostituzione",
|
||||
prevMatch: "Precedente (Shift+Enter)",
|
||||
nextMatch: "Successivo (Enter)",
|
||||
selectAll: "Seleziona tutte le corrispondenze",
|
||||
selectAllLimit: "Seleziona tutte le corrispondenze (fino a {limit})",
|
||||
selectionTruncated: "Trovate {total} corrispondenze; sono selezionate le prime {limit}.",
|
||||
selectionLimitSummary: "Selezionate {limit}/{total}",
|
||||
close: "Chiudi (Esc)",
|
||||
noResults: "Nessun risultato",
|
||||
inSelection: "Nella selezione",
|
||||
|
|
@ -4567,6 +4571,8 @@ export default withEnglishFallback({
|
|||
infiniteScrollDescription: "Carica automaticamente la pagina successiva dei dati quando scorri fino in fondo alla tabella.",
|
||||
infiniteScrollMaxRows: "Righe max a scorrimento infinito",
|
||||
infiniteScrollMaxRowsDescription: "Numero massimo di righe da caricare in modalità scorrimento infinito (1000–50000).",
|
||||
regexMaxMatchCount: "Numero massimo di corrispondenze per Seleziona tutte",
|
||||
regexMaxMatchCountDescription: "Limita il numero di selezioni create da Seleziona tutte le corrispondenze; il conteggio della ricerca rimane completo (100–10000).",
|
||||
tableColumnTemplateFields: "Campi predefiniti per nuove tabelle",
|
||||
tableColumnTemplateFieldsDescription: "Scegli un tipo di database e configura i tipi dei campi predefiniti usati durante la creazione di nuove tabelle.",
|
||||
tableColumnTemplateAdd: "Aggiungi campo",
|
||||
|
|
@ -4761,6 +4767,7 @@ export default withEnglishFallback({
|
|||
shortcutUndo: "Annulla",
|
||||
shortcutRedo: "Ripeti",
|
||||
shortcutSelectAll: "Seleziona tutto",
|
||||
shortcutExtendSelection: "Estendi selezione",
|
||||
shortcutCopyCurrentRow: "Copia riga dati corrente",
|
||||
shortcutDeleteCurrentRow: "Elimina riga dati corrente",
|
||||
shortcutNewQuery: "Nuova query",
|
||||
|
|
|
|||
|
|
@ -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: "データエクスポートの行数制限",
|
||||
|
|
|
|||
|
|
@ -850,6 +850,10 @@ export default withEnglishFallback({
|
|||
expandReplace: "Expandir substituição",
|
||||
prevMatch: "Anterior (Shift+Enter)",
|
||||
nextMatch: "Próximo (Enter)",
|
||||
selectAll: "Selecionar todas as correspondências",
|
||||
selectAllLimit: "Selecionar todas as correspondências (até {limit})",
|
||||
selectionTruncated: "Foram encontradas {total} correspondências; as primeiras {limit} foram selecionadas.",
|
||||
selectionLimitSummary: "Selecionadas {limit}/{total}",
|
||||
close: "Fechar (Esc)",
|
||||
noResults: "Nenhum resultado",
|
||||
inSelection: "Na seleção",
|
||||
|
|
@ -4569,6 +4573,8 @@ export default withEnglishFallback({
|
|||
infiniteScrollDescription: "Carregar automaticamente a próxima página de dados ao rolar até o final da tabela.",
|
||||
infiniteScrollMaxRows: "Máximo de linhas em rolagem infinita",
|
||||
infiniteScrollMaxRowsDescription: "Número máximo de linhas a carregar no modo de rolagem infinita (1000–50000).",
|
||||
regexMaxMatchCount: "Máximo de correspondências para selecionar todas",
|
||||
regexMaxMatchCountDescription: "Limita o número de seleções criadas por Selecionar todas as correspondências; a contagem da pesquisa permanece completa (100–10000).",
|
||||
tableColumnTemplateFields: "Campos predefinidos para novas tabelas",
|
||||
tableColumnTemplateFieldsDescription: "Escolha um tipo de banco de dados e configure os tipos dos campos predefinidos usados ao criar novas tabelas.",
|
||||
tableColumnTemplateAdd: "Adicionar campo",
|
||||
|
|
@ -4763,6 +4769,7 @@ export default withEnglishFallback({
|
|||
shortcutUndo: "Desfazer",
|
||||
shortcutRedo: "Refazer",
|
||||
shortcutSelectAll: "Selecionar tudo",
|
||||
shortcutExtendSelection: "Expandir seleção",
|
||||
shortcutCopyCurrentRow: "Copiar linha de dados atual",
|
||||
shortcutDeleteCurrentRow: "Excluir linha de dados atual",
|
||||
shortcutNewQuery: "Nova consulta",
|
||||
|
|
|
|||
|
|
@ -886,6 +886,10 @@ export default withEnglishFallback({
|
|||
expandReplace: "展开替换",
|
||||
prevMatch: "上一个 (Shift+Enter)",
|
||||
nextMatch: "下一个 (Enter)",
|
||||
selectAll: "全部选中",
|
||||
selectAllLimit: "全部选中(最多 {limit} 项)",
|
||||
selectionTruncated: "共找到 {total} 项,仅选中前 {limit} 项。",
|
||||
selectionLimitSummary: "已选 {limit}/{total}",
|
||||
close: "关闭 (Esc)",
|
||||
noResults: "无结果",
|
||||
inSelection: "选区内",
|
||||
|
|
@ -4787,6 +4791,8 @@ export default withEnglishFallback({
|
|||
infiniteScrollDescription: "滚动到表格底部时自动加载下一页数据,无需手动翻页。",
|
||||
infiniteScrollMaxRows: "无限滚动最大行数",
|
||||
infiniteScrollMaxRowsDescription: "无限滚动模式下最多加载的行数(1000–50000)。",
|
||||
regexMaxMatchCount: "全部选中最大数量",
|
||||
regexMaxMatchCountDescription: "限制“全部选中”一次创建的选区数量;搜索匹配仍完整统计(100–10000)。",
|
||||
tableColumnTemplateFields: "新建表预设字段",
|
||||
tableColumnTemplateFieldsDescription: "先选择数据库类型,再配置新建表时使用的预设字段类型。",
|
||||
tableColumnTemplateAdd: "新增字段",
|
||||
|
|
@ -5023,6 +5029,7 @@ export default withEnglishFallback({
|
|||
shortcutUndo: "撤销",
|
||||
shortcutRedo: "重做",
|
||||
shortcutSelectAll: "全选",
|
||||
shortcutExtendSelection: "扩展选择",
|
||||
shortcutUppercaseSelection: "选中内容转为大写",
|
||||
shortcutLowercaseSelection: "选中内容转为小写",
|
||||
shortcutExPasteSqlInCondition: "ExPaste:粘贴为 IN 条件",
|
||||
|
|
|
|||
|
|
@ -849,6 +849,10 @@ export default withEnglishFallback({
|
|||
expandReplace: "展開取代",
|
||||
prevMatch: "上一個 (Shift+Enter)",
|
||||
nextMatch: "下一個 (Enter)",
|
||||
selectAll: "選取所有符合項目",
|
||||
selectAllLimit: "選取所有符合項目(最多 {limit} 項)",
|
||||
selectionTruncated: "共找到 {total} 項符合結果,僅選取前 {limit} 項。",
|
||||
selectionLimitSummary: "已選取 {limit}/{total}",
|
||||
close: "關閉 (Esc)",
|
||||
noResults: "無結果",
|
||||
inSelection: "選取區內",
|
||||
|
|
@ -4030,6 +4034,8 @@ export default withEnglishFallback({
|
|||
infiniteScrollDescription: "滾動到表格底部時自動載入下一頁資料,無需手動翻頁。",
|
||||
infiniteScrollMaxRows: "無限滾動最大筆數",
|
||||
infiniteScrollMaxRowsDescription: "無限滾動模式下最多載入的筆數(1000–50000)。",
|
||||
regexMaxMatchCount: "全部選取最大數量",
|
||||
regexMaxMatchCountDescription: "限制「全部選取」一次建立的選取範圍數量;搜尋符合結果仍會完整統計(100–10000)。",
|
||||
tableColumnTemplateFields: "新建表預設欄位",
|
||||
tableColumnTemplateFieldsDescription: "先選擇資料庫類型,再設定新建表時使用的預設欄位類型。",
|
||||
tableColumnTemplateAdd: "新增欄位",
|
||||
|
|
@ -4213,6 +4219,7 @@ export default withEnglishFallback({
|
|||
shortcutUndo: "復原",
|
||||
shortcutRedo: "重做",
|
||||
shortcutSelectAll: "全選",
|
||||
shortcutExtendSelection: "擴展選取範圍",
|
||||
shortcutCopyCurrentRow: "複製目前資料列",
|
||||
shortcutDeleteCurrentRow: "刪除目前資料列",
|
||||
shortcutNewQuery: "建立查詢",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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" });
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
|
|||
"tableOpenPageSize",
|
||||
"infiniteScroll",
|
||||
"infiniteScrollMaxRows",
|
||||
"regexMaxMatchCount",
|
||||
"autoCalculateTotalRows",
|
||||
"tableColumnTemplateFields",
|
||||
"shortcuts",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue