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

This commit is contained in:
fuuulstack authored and GitHub committed 2026-08-03 15:50:43 +08:00
1 parent 02bad5d754
commit ed26fc10f5
29 files changed
+1794 -54

No files matched your search

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