From bd3e58562848987a86260230140ecf18920bf550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Sun, 30 Aug 2026 00:47:57 +0800 Subject: [PATCH] feat(editor): batch-select and insert SQL completion columns --- .../src/components/editor/QueryEditor.vue | 293 +++++++++++++++++- .../__tests__/QueryEditorContextMenu.spec.ts | 30 ++ apps/desktop/src/i18n/locales/en.ts | 3 + apps/desktop/src/i18n/locales/es.ts | 3 + apps/desktop/src/i18n/locales/it.ts | 3 + apps/desktop/src/i18n/locales/ja.ts | 3 + apps/desktop/src/i18n/locales/ko.ts | 3 + apps/desktop/src/i18n/locales/pt-BR.ts | 3 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/i18n/locales/zh-TW.ts | 3 + .../editor/batchColumnSelection.spec.ts | 36 +++ .../queryEditorCompletionKeymap.spec.ts | 5 +- .../__tests__/sql/semantic/completion.spec.ts | 36 +++ .../src/lib/editor/batchColumnSelection.ts | 19 ++ apps/desktop/src/lib/sql/sqlCompletion.ts | 11 + 15 files changed, 440 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/editor/batchColumnSelection.spec.ts create mode 100644 apps/desktop/src/lib/editor/batchColumnSelection.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 281e75b4e4..48ec694d1c 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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; + completionOptions: Map; +} + +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, 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): 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): Array { 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(); 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; +} diff --git a/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts b/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts index 69c2eab6ae..6b5af30117 100644 --- a/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts @@ -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) }'); + }); +}); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 8b2fb17268..79000cc641 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1232,6 +1232,9 @@ export default { numericLiteral: "Numeric literal", booleanValue: "Boolean value", starExpansionColumns: "{count} columns", + insertSelectedColumns: "Insert selected columns ({count})", + insertSelectedColumnsDetail: "Choose columns with the checkboxes, then insert them together", + selectColumnsBeforeInsert: "Select at least one column first", tableAlias: "Table alias", functionDescriptions: { COUNT: "Returns the number of rows", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 600797f893..2b45d1d55d 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1210,6 +1210,9 @@ export default withEnglishFallback({ numericLiteral: "Literal numérico", booleanValue: "Valor booleano", starExpansionColumns: "{count} columnas", + insertSelectedColumns: "Insertar columnas seleccionadas ({count})", + insertSelectedColumnsDetail: "Selecciona columnas y, después, insértalas juntas", + selectColumnsBeforeInsert: "Selecciona al menos una columna primero", tableAlias: "Alias de tabla", functionDescriptions: { COUNT: "Devuelve el número de filas", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index c98133b4af..6e4a4c948c 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1208,6 +1208,9 @@ export default withEnglishFallback({ numericLiteral: "Costante numerica", booleanValue: "Valore booleano", starExpansionColumns: "{count} colonne", + insertSelectedColumns: "Inserisci colonne selezionate ({count})", + insertSelectedColumnsDetail: "Seleziona le colonne e poi inseriscile insieme", + selectColumnsBeforeInsert: "Seleziona prima almeno una colonna", tableAlias: "Alias della tabella", functionDescriptions: { COUNT: "Restituisce il numero di righe", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 223ba43e5f..7539c54c6f 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1228,6 +1228,9 @@ export default withEnglishFallback({ numericLiteral: "数値リテラル", booleanValue: "真偽値", starExpansionColumns: "{count}列", + insertSelectedColumns: "選択した列を挿入({count})", + insertSelectedColumnsDetail: "チェックした列をまとめて挿入します", + selectColumnsBeforeInsert: "少なくとも 1 列選択してください", tableAlias: "テーブルエイリアス", functionDescriptions: { COUNT: "行数を返す", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 1ac726be48..d74188afc3 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1119,6 +1119,9 @@ export default withEnglishFallback({ numericLiteral: "숫자 리터럴", booleanValue: "불리언 값", starExpansionColumns: "{count}개 컬럼", + insertSelectedColumns: "선택한 열 삽입 ({count})", + insertSelectedColumnsDetail: "열을 선택한 후 한 번에 삽입합니다", + selectColumnsBeforeInsert: "먼저 하나 이상의 열을 선택하세요", tableAlias: "테이블 별칭", functionDescriptions: { COUNT: "행 수를 반환합니다", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 3ae73e86d9..4037b2de29 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1209,6 +1209,9 @@ export default withEnglishFallback({ numericLiteral: "Literal numérico", booleanValue: "Valor booleano", starExpansionColumns: "{count} colunas", + insertSelectedColumns: "Inserir colunas selecionadas ({count})", + insertSelectedColumnsDetail: "Selecione as colunas e insira-as de uma vez", + selectColumnsBeforeInsert: "Selecione pelo menos uma coluna primeiro", tableAlias: "Alias da tabela", functionDescriptions: { COUNT: "Retorna o número de linhas", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index ed93eaeb9d..73fce8a45e 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1156,6 +1156,9 @@ export default withEnglishFallback({ numericLiteral: "数值字面量", booleanValue: "布尔值", starExpansionColumns: "{count} 列", + insertSelectedColumns: "插入已选字段({count})", + insertSelectedColumnsDetail: "勾选字段后一次性插入", + selectColumnsBeforeInsert: "请至少选择一个字段", tableAlias: "表别名", functionDescriptions: { COUNT: "返回行数", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index d6e8556419..fcc2587dd6 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1208,6 +1208,9 @@ export default withEnglishFallback({ numericLiteral: "數值常值", booleanValue: "布林值", starExpansionColumns: "{count} 欄", + insertSelectedColumns: "插入已選欄位({count})", + insertSelectedColumnsDetail: "勾選欄位後一次插入", + selectColumnsBeforeInsert: "請至少選擇一個欄位", tableAlias: "資料表別名", functionDescriptions: { COUNT: "回傳列數", diff --git a/apps/desktop/src/lib/__tests__/editor/batchColumnSelection.spec.ts b/apps/desktop/src/lib/__tests__/editor/batchColumnSelection.spec.ts new file mode 100644 index 0000000000..50f1cbb8b4 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/batchColumnSelection.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { batchColumnSelectionColumnList, batchColumnSelectionReplaceTo, shouldResolveSqlColumnCompletion } from "@/lib/editor/batchColumnSelection"; + +describe("batchColumnSelectionColumnList", () => { + it("keeps a typed qualifier on every projection after the first", () => { + expect(batchColumnSelectionColumnList(["method", "path", "remark"], "select", "ap")).toBe("method, ap.path, ap.remark"); + }); + + it("does not add a qualifier to INSERT target columns", () => { + expect(batchColumnSelectionColumnList(["id", "name"], "insert", "users")).toBe("id, name"); + }); +}); + +describe("batchColumnSelectionReplaceTo", () => { + it("consumes the auto-inserted INSERT closing parenthesis", () => { + expect(batchColumnSelectionReplaceTo({ to: 20, mode: "insert", nextCharacter: ")" })).toBe(21); + }); + + it("keeps the replacement boundary when INSERT has no closing parenthesis", () => { + expect(batchColumnSelectionReplaceTo({ to: 20, mode: "insert", nextCharacter: "" })).toBe(20); + }); + + it("continues consuming a matching closing identifier quote", () => { + expect(batchColumnSelectionReplaceTo({ to: 20, mode: "select", nextCharacter: '"', replaceClosingQuote: '"' })).toBe(21); + }); +}); + +describe("shouldResolveSqlColumnCompletion", () => { + it("loads fields after SELECT space when a FROM table is already known", () => { + expect(shouldResolveSqlColumnCompletion({ suggestColumns: true, hasReferencedTables: true, prefix: "", typedActivation: false, selectListColumnContext: true })).toBe(true); + }); + + it("keeps empty non-SELECT column contexts from fetching metadata", () => { + expect(shouldResolveSqlColumnCompletion({ suggestColumns: true, hasReferencedTables: true, prefix: "", typedActivation: false, selectListColumnContext: false })).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts index 47b5cc5d18..d332e1b76d 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts @@ -60,6 +60,7 @@ function createHarness(options: { acceptCompletion?: (view: MockView) => boolean; selectedCompletionIndex?: (state: MockState) => number | null; selectFirstCompletion?: (view: MockView) => boolean; + applySelectedBatchColumnSelection?: (view: MockView) => boolean; closeCompletion?: (view: MockView) => boolean; insertNewlineKeepIndent?: (view: MockView) => boolean; nextSnippetField?: (view: MockView) => boolean; @@ -99,6 +100,7 @@ function createHarness(options: { "codeMirrorSelectFirstCompletion", "acceptSelectedCompletionWithRetry", "acceptSelectedOrFirstCompletion", + "applySelectedBatchColumnSelection", "codeMirrorCloseCompletion", "codeMirrorInsertNewlineKeepIndent", "insertQueryEditorNewline", @@ -117,6 +119,7 @@ function createHarness(options: { options.selectFirstCompletion ?? (() => false), acceptSelectedCompletionWithRetry, acceptSelectedOrFirstCompletion, + options.applySelectedBatchColumnSelection ?? (() => false), options.closeCompletion ?? (() => false), options.insertNewlineKeepIndent ?? (() => false), (view: MockView, fallback: ((view: MockView) => boolean) | null | undefined) => fallback?.(view) ?? false, @@ -207,7 +210,7 @@ describe("QueryEditor completion Tab keymap", () => { }); it("keeps ASCII single-character filtering while allowing Han pinyin matches", () => { - const source = [extractFunction("completionItemsForBypassedFilter"), extractFunction("shouldBypassCompletionFilter"), extractFunction("buildCompletionResult")].join("\n"); + const source = [extractFunction("isBatchColumnSelectionAction"), extractFunction("completionItemsForBypassedFilter"), extractFunction("shouldBypassCompletionFilter"), extractFunction("buildCompletionResult")].join("\n"); const javascript = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.None, target: ts.ScriptTarget.ES2022 }, }).outputText; diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts index 600dc1c236..a5914fb92d 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts @@ -304,6 +304,34 @@ describe("semantic SQL completion candidates", () => { expect(columns.find((item) => item.label === "tl.villageId")).toMatchObject({ filterText: "villageId", apply: "tl.villageId" }); }); + it("marks SELECT projection columns as batch-selectable", () => { + const columnsByTable = new Map([["users", ["id", "name"].map((name) => ({ name, table: "users" }))]]); + + const { items } = semanticCompletion("SELECT | FROM users", { columnsByTable }); + + expect(items.filter((item) => item.type === "column")).toEqual(expect.arrayContaining([expect.objectContaining({ label: "id", apply: "id", batchSelectionMode: "select" }), expect.objectContaining({ label: "name", apply: "name", batchSelectionMode: "select" })])); + }); + + it("retains a typed table alias for batch-selected projection columns", () => { + const columnsByTable = new Map([["users", ["id", "name"].map((name) => ({ name, table: "users" }))]]); + + const { items } = semanticCompletion("SELECT u.| FROM users u", { columnsByTable }); + + expect(items.filter((item) => item.type === "column")).toEqual( + expect.arrayContaining([expect.objectContaining({ label: "id", apply: "id", batchSelectionMode: "select", batchSelectionQualifier: "u" }), expect.objectContaining({ label: "name", apply: "name", batchSelectionMode: "select", batchSelectionQualifier: "u" })]), + ); + }); + + it("retains a typed table name when the referenced table also has an alias", () => { + const columnsByTable = new Map([["users", ["id", "name"].map((name) => ({ name, table: "users" }))]]); + + const { items } = semanticCompletion("SELECT users.| FROM users u", { columnsByTable }); + + expect(items.filter((item) => item.type === "column")).toEqual( + expect.arrayContaining([expect.objectContaining({ label: "id", apply: "id", batchSelectionMode: "select", batchSelectionQualifier: "users" }), expect.objectContaining({ label: "name", apply: "name", batchSelectionMode: "select", batchSelectionQualifier: "users" })]), + ); + }); + it("completes columns for aliases in comma-separated table lists", () => { const columnsByTable = new Map([ ["table_a", ["id", "name"].map((name) => ({ name, table: "table_a" }))], @@ -472,6 +500,14 @@ WHERE a.id = b.fk_kpi_set_score_id`, expect(allColumns?.detail).toBe("3 columns: id, name, email) VALUES (value, value, value)"); }); + it("marks INSERT target columns as batch-selectable", () => { + const columnsByTable = new Map([["users", ["id", "name"].map((name) => ({ name, table: "users" }))]]); + + const { items } = semanticCompletion("INSERT INTO users (|", { columnsByTable }); + + expect(items.filter((item) => item.type === "column")).toEqual(expect.arrayContaining([expect.objectContaining({ label: "id", apply: "id", batchSelectionMode: "insert" }), expect.objectContaining({ label: "name", apply: "name", batchSelectionMode: "insert" })])); + }); + it("uses the configured keyword case for INSERT all-column snippets", () => { const columnsByTable = new Map([["users", ["id", "name"].map((name) => ({ name, table: "users" }))]]); diff --git a/apps/desktop/src/lib/editor/batchColumnSelection.ts b/apps/desktop/src/lib/editor/batchColumnSelection.ts new file mode 100644 index 0000000000..2bcf7b985e --- /dev/null +++ b/apps/desktop/src/lib/editor/batchColumnSelection.ts @@ -0,0 +1,19 @@ +export type BatchColumnSelectionMode = "select" | "insert"; + +export function batchColumnSelectionColumnList(candidates: string[], mode: BatchColumnSelectionMode, qualifier?: string): string { + return candidates.map((candidate, index) => (mode === "select" && qualifier && index > 0 ? `${qualifier}.${candidate}` : candidate)).join(", "); +} + +export function shouldResolveSqlColumnCompletion(options: { suggestColumns: boolean; hasReferencedTables: boolean; prefix: string; typedActivation: boolean; selectListColumnContext: boolean }): boolean { + return options.suggestColumns && options.hasReferencedTables && (options.prefix.length > 0 || options.typedActivation || options.selectListColumnContext); +} + +/** + * The INSERT batch action writes its own closing parenthesis before VALUES. + * Consume an existing one (normally inserted by CodeMirror's auto-close + * brackets extension) so the resulting statement has exactly one `)`. + */ +export function batchColumnSelectionReplaceTo(options: { to: number; mode: BatchColumnSelectionMode; nextCharacter: string; replaceClosingQuote?: string }): number { + const { to, mode, nextCharacter, replaceClosingQuote } = options; + return replaceClosingQuote === nextCharacter || (mode === "insert" && nextCharacter === ")") ? to + 1 : to; +} diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index e90a9b181f..c2d1d157e7 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -1291,6 +1291,10 @@ export interface SqlCompletionItem { boost: number; exactMatch?: boolean; dedupeKey?: string; + /** Enables the query editor's checkbox-based batch insertion for this column. */ + batchSelectionMode?: "select" | "insert"; + /** Qualifier to prepend to every batch-selected column after the first one. */ + batchSelectionQualifier?: string; } export function shouldChainSqlCompletionAfterAccept(item: { type?: string; apply?: string }): boolean { @@ -4505,6 +4509,8 @@ function buildColumnItems(context: SqlCompletionContext, columnsByTable: Map right.boost - left.boost || left.index - right.index) .slice(0, context.insertTable || !context.prefix ? 50 : context.qualifier ? 30 : 20); + const batchSelectionMode = context.insertTable ? "insert" : context.statementKind === "select" && context.selectListColumnContext ? "select" : undefined; + return rankedColumns.map(({ column, boost }) => { return { label: column.displayLabel, @@ -4514,6 +4520,11 @@ function buildColumnItems(context: SqlCompletionContext, columnsByTable: Map quoteCompletionApplyIdentifier(part, dialect)).join(".") : undefined, }; }); }