feat(editor): batch-select and insert SQL completion columns

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-30 00:47:57 +08:00
1 parent 1637948023
commit bd3e585628
15 files changed
+440 -14

No files matched your search

@@ -2,7 +2,7 @@
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, shallowRef, computed, nextTick } from "vue";
import { AlignLeft, Camera, CaseLower, CaseUpper, ClipboardPaste, Code2, Download, FileCode, MessageSquareText, Minimize2, Pencil, PencilRuler, Play, Copy, List, Scissors, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import type { CompletionContext } from "@codemirror/autocomplete";
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
import { Transaction, StateEffect } from "@codemirror/state";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { search as cmSearch } from "@codemirror/search";
@@ -118,6 +118,7 @@ import { buildQueryEditorLineNumbersExtension, createQueryEditorLineNumberAlignm
import { searchKeymapWithoutModD } from "@/lib/editor/codemirrorSearchKeymap";
import { defaultKeymapForGlobalShortcuts } from "@/lib/editor/codemirrorDefaultKeymap";
import { appendSqlCompletionSpace } from "@/lib/editor/sqlCompletionInsertion";
import { batchColumnSelectionColumnList, batchColumnSelectionReplaceTo, shouldResolveSqlColumnCompletion } from "@/lib/editor/batchColumnSelection";
import { compareSqlCompletions, completionLabelPresentation } from "@/lib/editor/sqlCompletionPresentation";
import { clampEditorFontSize, createEditorWheelZoomGestureGuard, createEditorZoomCommitScheduler, fontSizeFromGestureScale, fontSizeFromWheelDelta } from "@/lib/editor/editorZoom";
import { enabledSqlShortcutActions, resolveSqlShortcutTemplate } from "@/lib/sql/sqlShortcutActions";
@@ -566,6 +567,8 @@ let codeMirrorSnippetCompletion: typeof import("@codemirror/autocomplete").snipp
let codeMirrorCompletionStatus: typeof import("@codemirror/autocomplete").completionStatus | null = null;
let codeMirrorAcceptCompletion: typeof import("@codemirror/autocomplete").acceptCompletion | null = null;
let codeMirrorSelectedCompletionIndex: typeof import("@codemirror/autocomplete").selectedCompletionIndex | null = null;
let codeMirrorSelectedCompletion: typeof import("@codemirror/autocomplete").selectedCompletion | null = null;
let codeMirrorMoveCompletionSelection: typeof import("@codemirror/autocomplete").moveCompletionSelection | null = null;
let codeMirrorSelectFirstCompletion: import("@codemirror/view").Command | null = null;
let codeMirrorStartCompletion: typeof import("@codemirror/autocomplete").startCompletion | null = null;
let codeMirrorCloseCompletion: typeof import("@codemirror/autocomplete").closeCompletion | null = null;
@@ -1972,6 +1975,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
if (replaceShortcutHandler(event)) return true;
return sqlShortcutDomHandler(event, view);
};
const moveCompletion = (view: EditorViewType, forward: boolean, by?: "page") => codeMirrorMoveCompletionSelection?.(forward, by)?.(view) ?? false;
return [
editorViewModule
? (Prec?.high(
@@ -1980,12 +1984,24 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
}),
) ?? [])
: [],
Prec?.highest(
codeMirrorKeymap.of([
{ key: "ArrowDown", run: (view) => moveCompletion(view, true) },
{ key: "ArrowUp", run: (view) => moveCompletion(view, false) },
{ key: "PageDown", run: (view) => moveCompletion(view, true, "page") },
{ key: "PageUp", run: (view) => moveCompletion(view, false, "page") },
]),
) ?? [],
Prec?.high(
codeMirrorKeymap.of([
{
key: "Enter",
run: handleEnter,
},
{
key: "Space",
run: toggleSelectedBatchColumnSelection,
},
...binding(shortcuts.find, openSearch),
...replaceShortcutBindings,
...executeInNewResultTabBindings,
@@ -2057,6 +2073,10 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
function handleEnter(view: EditorViewType): boolean {
clearPendingCompletionEnter();
if (applySelectedBatchColumnSelection(view)) return true;
// CodeMirror's default completion keymap is disabled so batch selection can
// take precedence. Preserve its normal single-item acceptance here.
if (codeMirrorAcceptCompletion?.(view)) return true;
if (settingsStore.editorSettings.selectFirstCompletionOnOpen && codeMirrorCompletionStatus) {
let cancelRetry: (() => void) | null = null;
const result = acceptSelectedCompletionWithRetry(view, {
@@ -2107,6 +2127,7 @@ function acceptCompletionOrNextSnippetField(view: EditorViewType): boolean {
// not word completion. A completion popup can still appear as a side effect
// of the indent edit itself, so it must never hijack this or a following Tab.
if (view.state.selection.ranges.every((range) => range.empty)) {
if (applySelectedBatchColumnSelection(view)) return true;
const completionStatus = codeMirrorCompletionStatus?.(view.state) ?? null;
if (completionStatus === "active" && acceptSelectedOrFirstCompletion(view, codeMirrorAcceptCompletion, codeMirrorSelectedCompletionIndex, codeMirrorSelectFirstCompletion)) return true;
if (completionStatus) return waitForCompletionTab(view);
@@ -3446,6 +3467,202 @@ let activeCompletionOrigin: SqlCompletionTriggerOrigin | null = null;
type QueryCompletionItem = SqlCompletionItem | ElasticsearchCompletionItem | RedisCompletionItem | MongoCompletionItem;
interface BatchColumnSelectionCandidate {
key: string;
apply: string;
}
interface BatchColumnSelectionSession {
key: string;
mode: "select" | "insert";
qualifier?: string;
document: string;
from: number;
to: number;
replaceClosingQuote?: SqlCompletionItem["replaceClosingQuote"];
candidates: BatchColumnSelectionCandidate[];
selectedKeys: Set<string>;
completionOptions: Map<string, QueryCompletionOption>;
}
interface BatchColumnSelectionActionItem {
label: string;
filterText?: string;
type: "text";
detail: string;
boost: number;
batchColumnSelectionAction: true;
sessionKey: string;
}
type QueryCompletionOption = Completion & {
dbxBatchColumnSelection?: { sessionKey: string; candidateKey: string };
};
let batchColumnSelectionSession: BatchColumnSelectionSession | null = null;
function isBatchColumnSelectionAction(item: QueryCompletionItem | BatchColumnSelectionActionItem): item is BatchColumnSelectionActionItem {
return "batchColumnSelectionAction" in item && item.batchColumnSelectionAction === true;
}
function batchColumnSelectionCandidateKey(item: SqlCompletionItem): string {
return `${item.label}\u0000${item.apply ?? item.label}`;
}
function prepareBatchColumnSelectionSession(items: SqlCompletionItem[], document: string, from: number, to: number): BatchColumnSelectionSession | null {
const selectableItems = items.filter((item) => item.type === "column" && item.batchSelectionMode && item.apply);
if (selectableItems.length === 0) {
batchColumnSelectionSession = null;
return null;
}
const mode = selectableItems[0]!.batchSelectionMode!;
const candidates = selectableItems.filter((item) => item.batchSelectionMode === mode).map((item) => ({ key: batchColumnSelectionCandidateKey(item), apply: item.apply! }));
const key = `${mode}\u0000${from}\u0000${to}\u0000${document}`;
if (!batchColumnSelectionSession || batchColumnSelectionSession.key !== key) {
batchColumnSelectionSession = {
key,
mode,
qualifier: selectableItems[0]!.batchSelectionQualifier,
document,
from,
to,
replaceClosingQuote: selectableItems[0]!.replaceClosingQuote,
candidates,
selectedKeys: new Set(),
completionOptions: new Map(),
};
return batchColumnSelectionSession;
}
batchColumnSelectionSession.candidates = candidates;
batchColumnSelectionSession.qualifier = selectableItems[0]!.batchSelectionQualifier;
const candidateKeys = new Set(candidates.map((candidate) => candidate.key));
batchColumnSelectionSession.selectedKeys = new Set([...batchColumnSelectionSession.selectedKeys].filter((candidateKey) => candidateKeys.has(candidateKey)));
batchColumnSelectionSession.completionOptions = new Map([...batchColumnSelectionSession.completionOptions].filter(([candidateKey]) => candidateKeys.has(candidateKey)));
return batchColumnSelectionSession;
}
function batchColumnSelectionMarkerForItem(item: QueryCompletionItem): QueryCompletionOption["dbxBatchColumnSelection"] | undefined {
if (!("batchSelectionMode" in item) || item.type !== "column" || !item.batchSelectionMode || !item.apply) return undefined;
const session = batchColumnSelectionSession;
if (!session || session.mode !== item.batchSelectionMode) return undefined;
const candidateKey = batchColumnSelectionCandidateKey(item);
if (!session.candidates.some((candidate) => candidate.key === candidateKey)) return undefined;
return { sessionKey: session.key, candidateKey };
}
function cacheBatchColumnSelectionOption(marker: QueryCompletionOption["dbxBatchColumnSelection"], option: QueryCompletionOption): QueryCompletionOption {
if (!marker || !batchColumnSelectionSession || batchColumnSelectionSession.key !== marker.sessionKey) return option;
const cached = batchColumnSelectionSession.completionOptions.get(marker.candidateKey);
if (cached) return cached;
batchColumnSelectionSession.completionOptions.set(marker.candidateKey, option);
return option;
}
function toggleBatchColumnSelection(view: EditorViewType, sessionKey: string, candidateKey: string) {
const session = batchColumnSelectionSession;
if (!session || session.key !== sessionKey || !session.candidates.some((candidate) => candidate.key === candidateKey)) return;
if (session.selectedKeys.has(candidateKey)) {
session.selectedKeys.delete(candidateKey);
} else {
session.selectedKeys.add(candidateKey);
}
// Reopen the list so the action row and all virtualized checkboxes reflect the new state.
window.setTimeout(() => codeMirrorStartCompletion?.(view), 0);
}
function toggleSelectedBatchColumnSelection(view: EditorViewType): boolean {
const completion = codeMirrorSelectedCompletion?.(view.state) as QueryCompletionOption | null | undefined;
const marker = completion?.dbxBatchColumnSelection;
if (!marker) return false;
toggleBatchColumnSelection(view, marker.sessionKey, marker.candidateKey);
return true;
}
function renderBatchColumnSelectionCheckbox(completion: Completion, _state: import("@codemirror/state").EditorState, currentView: EditorViewType): Node | null {
const marker = (completion as QueryCompletionOption).dbxBatchColumnSelection;
const session = batchColumnSelectionSession;
if (!marker || !session || session.key !== marker.sessionKey) return null;
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "cm-batch-column-selection-checkbox";
checkbox.checked = session.selectedKeys.has(marker.candidateKey);
checkbox.tabIndex = -1;
checkbox.setAttribute("aria-label", completion.displayLabel ?? completion.label);
checkbox.addEventListener("mousedown", (event) => {
// CodeMirror accepts the completion on a list-item mousedown. Keep this
// interaction local to the checkbox so a field can be toggled repeatedly.
event.preventDefault();
event.stopPropagation();
toggleBatchColumnSelection(currentView, marker.sessionKey, marker.candidateKey);
});
checkbox.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
});
return checkbox;
}
function applyBatchColumnSelection(view: EditorViewType, item: BatchColumnSelectionActionItem, from: number, to: number) {
const session = batchColumnSelectionSession;
if (!session || session.key !== item.sessionKey || session.document !== view.state.doc.toString()) return;
const selected = session.candidates.filter((candidate) => session.selectedKeys.has(candidate.key));
if (selected.length === 0) {
toast(t("editor.completion.selectColumnsBeforeInsert"), 3000);
return;
}
const replaceTo = batchColumnSelectionReplaceTo({
to,
mode: session.mode,
nextCharacter: view.state.sliceDoc(to, to + 1),
replaceClosingQuote: session.replaceClosingQuote,
});
const columns = batchColumnSelectionColumnList(
selected.map((candidate) => candidate.apply),
session.mode,
session.qualifier,
);
const insert = session.mode === "insert" ? `${columns}) ${settingsStore.editorSettings.sqlFormatter.keywordCase === "lower" ? "values" : "VALUES"} (${selected.map((_, index) => `\${${index + 1}:value}`).join(", ")})` : columns;
batchColumnSelectionSession = null;
markCompletionAccepted(item);
if (session.mode === "insert") {
const snippet = codeMirrorSnippetCompletion(insert, { label: item.label });
if (typeof snippet.apply === "function") {
snippet.apply(view, snippet, from, replaceTo);
return;
}
}
view.dispatch({
changes: { from, to: replaceTo, insert },
selection: { anchor: from + insert.length },
scrollIntoView: true,
});
}
function applySelectedBatchColumnSelection(view: EditorViewType): boolean {
const session = batchColumnSelectionSession;
if (!session || session.document !== view.state.doc.toString() || session.selectedKeys.size === 0) return false;
applyBatchColumnSelection(
view,
{
label: t("editor.completion.insertSelectedColumns", { count: session.selectedKeys.size }),
type: "text",
detail: t("editor.completion.insertSelectedColumnsDetail"),
boost: -1000,
batchColumnSelectionAction: true,
sessionKey: session.key,
},
session.from,
session.to,
);
return true;
}
function markTypedCompletionActivation() {
typedCompletionActivationUntil = Date.now() + 500;
}
@@ -3454,7 +3671,8 @@ function isTypedCompletionActivation(explicit: boolean) {
return explicit && typedCompletionActivationUntil >= Date.now();
}
function markCompletionAccepted(item: QueryCompletionItem) {
function markCompletionAccepted(item: QueryCompletionItem | BatchColumnSelectionActionItem) {
batchColumnSelectionSession = null;
const shouldContinueCompletion = shouldChainSqlCompletionAfterAccept(item) || (props.databaseType === "sqlserver" && item.type === "keyword" && item.label.toUpperCase() === "USE");
suppressNextSqlCompletionAutoStartUntil = shouldContinueCompletion ? 0 : Date.now() + 750;
completionEpoch++;
@@ -3469,7 +3687,7 @@ function consumeSqlCompletionAutoStartSuppression() {
return true;
}
function buildCompletionResult(items: QueryCompletionItem[], from: number, validFor?: RegExp, prefix?: string) {
function buildCompletionResult(items: Array<QueryCompletionItem | BatchColumnSelectionActionItem>, from: number, validFor?: RegExp, prefix?: string) {
if (items.length === 0) return null;
const bypassFilter = !!prefix && shouldBypassCompletionFilter(prefix, items);
const resultItems = bypassFilter && prefix ? completionItemsForBypassedFilter(prefix, items) : items;
@@ -3487,23 +3705,39 @@ function buildCompletionResult(items: QueryCompletionItem[], from: number, valid
function buildSqlCompletionResult(items: SqlCompletionItem[], completionContext: SqlCompletionContext, fullDoc: string, position: number) {
const replacement = prepareSqlCompletionReplacement(fullDoc, position, completionContext, items);
return buildCompletionResult(replacement.items, replacement.from, getSqlCompletionResultValidFor(fullDoc, position), completionContext.prefix);
const session = prepareBatchColumnSelectionSession(replacement.items, fullDoc, replacement.from, position);
const action: BatchColumnSelectionActionItem | undefined = session
? {
label: t("editor.completion.insertSelectedColumns", { count: session.selectedKeys.size }),
filterText: completionContext.prefix,
type: "text",
detail: t("editor.completion.insertSelectedColumnsDetail"),
// Keep ordinary single-field completion as the default Enter target.
boost: -1000,
batchColumnSelectionAction: true,
sessionKey: session.key,
}
: undefined;
return buildCompletionResult(action ? [...replacement.items, action] : replacement.items, replacement.from, getSqlCompletionResultValidFor(fullDoc, position), completionContext.prefix);
}
// CodeMirror's built-in matcher only matches single-character queries against
// the label start, and cannot match pinyin initials against Han labels. Our
// provider already filters and ranks items itself (substring + pinyin), so
// skip the second-stage filter exactly in the cases it would break.
function shouldBypassCompletionFilter(prefix: string, items: QueryCompletionItem[]): boolean {
function shouldBypassCompletionFilter(prefix: string, items: Array<QueryCompletionItem | BatchColumnSelectionActionItem>): boolean {
if (!prefix) return false;
if (/\p{Script=Han}/u.test(prefix)) return true;
return /^[a-z0-9]+$/i.test(prefix) && items.some((item) => /\p{Script=Han}/u.test(item.label));
}
function completionItemsForBypassedFilter(prefix: string, items: QueryCompletionItem[]): QueryCompletionItem[] {
function completionItemsForBypassedFilter(prefix: string, items: Array<QueryCompletionItem | BatchColumnSelectionActionItem>): Array<QueryCompletionItem | BatchColumnSelectionActionItem> {
if ([...prefix].length !== 1 || !/^[a-z0-9]$/i.test(prefix)) return items;
const normalized = prefix.toLowerCase();
return items.filter((item) => {
// The confirmation row must remain available while typing a Han/pinyin
// prefix, otherwise checked columns cannot be applied from that menu.
if (isBatchColumnSelectionAction(item)) return true;
const renderedLabel = "displayLabel" in item && typeof item.displayLabel === "string" ? item.displayLabel : item.label;
return /\p{Script=Han}/u.test(item.label) || renderedLabel.toLowerCase().startsWith(normalized);
});
@@ -3536,12 +3770,24 @@ function shouldInsertSqlCompletionSpace(): boolean {
return props.databaseType !== "mongodb" && props.databaseType !== "redis" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch" && props.databaseType !== "meilisearch" && props.databaseType !== "victoriametrics";
}
function completionOptionForItem(item: QueryCompletionItem) {
function completionOptionForItem(item: QueryCompletionItem | BatchColumnSelectionActionItem) {
const filterText = "filterText" in item && typeof item.filterText === "string" ? item.filterText : undefined;
const labelPresentation = completionLabelPresentation(item.label, filterText);
if (isBatchColumnSelectionAction(item)) {
return {
...labelPresentation,
type: item.type,
detail: item.detail,
boost: item.boost,
apply(view: EditorViewType, _completionItem: unknown, from: number, to: number) {
applyBatchColumnSelection(view, item, from, to);
},
};
}
const record = () => {
recordCompletionSelection(item.label, item.type);
};
const batchColumnSelection = batchColumnSelectionMarkerForItem(item);
if ((item.type === "snippet" || item.type === "function") && item.apply) {
const completion = codeMirrorSnippetCompletion(item.apply, {
...labelPresentation,
@@ -3551,8 +3797,9 @@ function completionOptionForItem(item: QueryCompletionItem) {
boost: item.boost,
});
const originalApply = completion.apply;
return {
return cacheBatchColumnSelectionOption(batchColumnSelection, {
...completion,
...(batchColumnSelection ? { dbxBatchColumnSelection: batchColumnSelection } : {}),
apply(view: EditorViewType, completionItem: unknown, from: number, to: number) {
record();
markCompletionAccepted(item);
@@ -3573,10 +3820,11 @@ function completionOptionForItem(item: QueryCompletionItem) {
}
}
},
};
});
}
return {
return cacheBatchColumnSelectionOption(batchColumnSelection, {
...labelPresentation,
...(batchColumnSelection ? { dbxBatchColumnSelection: batchColumnSelection } : {}),
type: item.type,
detail: item.detail,
info: item.info,
@@ -3599,7 +3847,7 @@ function completionOptionForItem(item: QueryCompletionItem) {
});
}
},
};
});
}
async function provideElasticsearchCompletions(currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean) {
@@ -3907,7 +4155,13 @@ async function provideSqlCompletions(context: CompletionContext) {
}
const tableNameCompletion = isTableNameCompletionContext(completionContext);
const shouldResolveColumnCompletion = completionContext.suggestColumns && completionContext.referencedTables.length > 0 && (completionContext.prefix.length > 0 || typedActivation);
const shouldResolveColumnCompletion = shouldResolveSqlColumnCompletion({
suggestColumns: completionContext.suggestColumns,
hasReferencedTables: completionContext.referencedTables.length > 0,
prefix: completionContext.prefix,
typedActivation,
selectListColumnContext: completionContext.selectListColumnContext,
});
const shouldResolveAsyncCompletion = tableNameCompletion || shouldResolveColumnCompletion;
const localResult = buildLocalSqlCompletionResult(completionContext, fullDoc, position, completionScope);
if (localResult) {
@@ -4818,7 +5072,7 @@ onMounted(async () => {
},
{ EditorState, EditorSelection, Compartment, Prec, RangeSet, StateEffect, StateField },
langSql,
{ autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField, closeCompletion, moveCompletionSelection, selectedCompletionIndex },
{ autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField, closeCompletion, moveCompletionSelection, selectedCompletion, selectedCompletionIndex },
{ copyLineDown, copyLineUp, deleteLine, indentLess, indentMore, insertNewlineKeepIndent, moveLineDown, moveLineUp, redo, selectAll, undo, toggleLineComment, toggleBlockComment, history, defaultKeymap, historyKeymap },
{ bracketMatching, foldGutter, indentOnInput, indentUnit, syntaxHighlighting, defaultHighlightStyle, foldKeymap, toggleFold, ensureSyntaxTree, highlightingFor, syntaxTree },
{ searchKeymap },
@@ -4855,7 +5109,9 @@ onMounted(async () => {
setSqlDiagnosticsEffect = StateEffect.define<SqlSemanticDiagnostic[]>();
codeMirrorCompletionStatus = completionStatus;
codeMirrorAcceptCompletion = acceptCompletion;
codeMirrorSelectedCompletion = selectedCompletion;
codeMirrorSelectedCompletionIndex = selectedCompletionIndex;
codeMirrorMoveCompletionSelection = moveCompletionSelection;
codeMirrorSelectFirstCompletion = moveCompletionSelection(true);
codeMirrorCloseCompletion = closeCompletion;
codeMirrorStartCompletion = startCompletion;
@@ -5090,8 +5346,10 @@ onMounted(async () => {
buildSqlCompletionExtension = () =>
autocompletion({
activateOnTyping: true,
defaultKeymap: false,
selectOnOpen: settingsStore.editorSettings.selectFirstCompletionOnOpen,
compareCompletions: (a, b) => compareSqlCompletions(a, b, settingsStore.editorSettings.sortCompletionColumnsAlphabetically),
addToOptions: [{ position: 10, render: renderBatchColumnSelectionCheckbox }],
override: [async (context: CompletionContext) => provideSqlCompletions(context)],
});
@@ -6632,4 +6890,13 @@ defineExpose({
text-overflow: ellipsis;
overflow: hidden;
}
.cm-batch-column-selection-checkbox {
width: 14px;
height: 14px;
margin: 0 2px 0 0;
accent-color: var(--primary);
cursor: pointer;
flex: 0 0 auto;
}
</style>
@@ -98,3 +98,33 @@ describe("QueryEditor context menu lifecycle", () => {
container.remove();
});
});
describe("QueryEditor batch column selection", () => {
it("keeps the confirmation action visible during pinyin filtering and supports keyboard toggling", () => {
const bypassFilterStart = source.indexOf("function completionItemsForBypassedFilter");
const bypassFilterEnd = source.indexOf("\n}\n\nfunction localCompletionDatabaseNames", bypassFilterStart);
const bypassFilterSource = source.slice(bypassFilterStart, bypassFilterEnd);
expect(bypassFilterStart).toBeGreaterThanOrEqual(0);
expect(bypassFilterEnd).toBeGreaterThan(bypassFilterStart);
expect(bypassFilterSource).toContain("if (isBatchColumnSelectionAction(item)) return true;");
expect(source).toContain('key: "Space"');
expect(source).toContain("run: toggleSelectedBatchColumnSelection");
expect(source).toContain("codeMirrorSelectedCompletion?.(view.state)");
});
it("applies checked columns directly through Enter and the completion Tab shortcut", () => {
const handleEnterStart = source.indexOf("function handleEnter");
const handleEnterEnd = source.indexOf("\n}\n\nfunction clearPendingCompletionEnter", handleEnterStart);
const tabStart = source.indexOf("function acceptCompletionOrNextSnippetField");
const tabEnd = source.indexOf("\n}\n\nfunction clearPendingCompletionTab", tabStart);
expect(source).toContain("function applySelectedBatchColumnSelection");
expect(source.slice(handleEnterStart, handleEnterEnd)).toContain("if (applySelectedBatchColumnSelection(view)) return true;");
expect(source.slice(handleEnterStart, handleEnterEnd)).toContain("if (codeMirrorAcceptCompletion?.(view)) return true;");
expect(source.slice(tabStart, tabEnd)).toContain("if (applySelectedBatchColumnSelection(view)) return true;");
expect(source).toContain("defaultKeymap: false");
expect(source).toContain('{ key: "ArrowDown", run: (view) => moveCompletion(view, true) }');
expect(source).toContain('{ key: "ArrowUp", run: (view) => moveCompletion(view, false) }');
});
});