From 5b85f9eae8bc0a4a2e1f09023028143d9a373d28 Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Sat, 8 Aug 2026 12:00:50 +0800 Subject: [PATCH] feat(completion): configurable SQL completion trigger modes --- .../editor/EditorSettingsDialog.vue | 28 +++ .../src/components/editor/QueryEditor.vue | 161 ++++++++++++++++-- apps/desktop/src/i18n/locales/en.ts | 5 + apps/desktop/src/i18n/locales/es.ts | 5 + apps/desktop/src/i18n/locales/it.ts | 5 + apps/desktop/src/i18n/locales/ja.ts | 5 + apps/desktop/src/i18n/locales/ko.ts | 5 + apps/desktop/src/i18n/locales/pt-BR.ts | 5 + apps/desktop/src/i18n/locales/zh-CN.ts | 5 + apps/desktop/src/i18n/locales/zh-TW.ts | 5 + .../sql/sqlCompletion.context.spec.ts | 107 ++++++++++++ .../__tests__/editorSettingsDraft.spec.ts | 46 +++++ .../settings/__tests__/settingsSearch.spec.ts | 1 + .../src/lib/settings/editorSettingsDraft.ts | 3 + .../src/lib/settings/settingsSearch.ts | 1 + .../src/lib/sql/sqlCompletionTriggerPolicy.ts | 61 +++++++ .../stores/__tests__/settingsStore.spec.ts | 20 +++ apps/desktop/src/stores/settingsStore.ts | 7 +- 18 files changed, 457 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/lib/sql/sqlCompletionTriggerPolicy.ts diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 16a2d38c0..6b2a388ca 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -46,6 +46,7 @@ import { type CustomThemeColors, type CustomTheme, type ClickTableNavigationTarget, + type SqlCompletionTriggerMode, } from "@/stores/settingsStore"; import { createRunStatementButtonDom, loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes"; import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering"; @@ -277,6 +278,7 @@ const editShowCurrentStatementFrame = ref(settingsStore.editorSettings.showCurre const editShowInsertValueHints = ref(settingsStore.editorSettings.showInsertValueHints); const editAutoAliasTables = ref(settingsStore.editorSettings.autoAliasTables); const editInsertSpaceAfterCompletion = ref(settingsStore.editorSettings.insertSpaceAfterCompletion); +const editCompletionTriggerMode = ref(settingsStore.editorSettings.completionTriggerMode); const editWordWrap = ref(settingsStore.editorSettings.wordWrap); const editVimModeEnabled = ref(settingsStore.editorSettings.vimModeEnabled); const editAutoCloseBrackets = ref(settingsStore.editorSettings.autoCloseBrackets); @@ -445,6 +447,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { showInsertValueHints: editShowInsertValueHints.value, autoAliasTables: editAutoAliasTables.value, insertSpaceAfterCompletion: editInsertSpaceAfterCompletion.value, + completionTriggerMode: editCompletionTriggerMode.value, wordWrap: editWordWrap.value, vimModeEnabled: editVimModeEnabled.value, autoCloseBrackets: editAutoCloseBrackets.value, @@ -712,6 +715,7 @@ function syncEditorSettingsDraftFromStore() { editShowInsertValueHints.value = settingsStore.editorSettings.showInsertValueHints; editAutoAliasTables.value = settingsStore.editorSettings.autoAliasTables; editInsertSpaceAfterCompletion.value = settingsStore.editorSettings.insertSpaceAfterCompletion; + editCompletionTriggerMode.value = settingsStore.editorSettings.completionTriggerMode; editWordWrap.value = settingsStore.editorSettings.wordWrap; editVimModeEnabled.value = settingsStore.editorSettings.vimModeEnabled; editAutoCloseBrackets.value = settingsStore.editorSettings.autoCloseBrackets; @@ -915,6 +919,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editShowInsertValueHints.value = DEFAULT_EDITOR_SETTINGS.showInsertValueHints; editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables; editInsertSpaceAfterCompletion.value = DEFAULT_EDITOR_SETTINGS.insertSpaceAfterCompletion; + editCompletionTriggerMode.value = DEFAULT_EDITOR_SETTINGS.completionTriggerMode; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled; editAutoCloseBrackets.value = DEFAULT_EDITOR_SETTINGS.autoCloseBrackets; @@ -1193,6 +1198,12 @@ function onExecuteModeChange(v: any) { if (v === "all" || v === "current") editExecuteMode.value = v; } +function onCompletionTriggerModeChange(v: any) { + if (v === "manual" || v === "require-prefix" || v === "positional") { + editCompletionTriggerMode.value = v; + } +} + function onSqlSemanticDiagnosticsEnabledChange(value: boolean) { editSqlSemanticDiagnosticsEnabled.value = value; editSqlSemanticDiagnosticsMode.value = value ? "enabled" : "disabled"; @@ -3722,6 +3733,23 @@ onUnmounted(() => { + +
+ + +

+ {{ t("settings.completionTriggerModeDescription") }} +

+
diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 04aa7e410..897034c44 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -45,6 +45,7 @@ import { shouldChainSqlCompletionAfterAccept, extractCteDefinitions, } from "@/lib/sql/sqlCompletion"; +import { originForSqlCompletionProvider, originForTypedSqlCompletionStart, shouldAllowSqlCompletionTrigger, type SqlCompletionTriggerFacts, type SqlCompletionTriggerOrigin } from "@/lib/sql/sqlCompletionTriggerPolicy"; import { sqlCompletionContextFromSemantic, sqlSemanticSelectStarIsOnlyProjection, sqlSemanticSelectStarQualifierSql, sqlSemanticSelectStarTableSource } from "@/lib/sql/semantic/completion"; import { buildSqlSemanticModel } from "@/lib/sql/semantic/model"; import { mergeSqlSemanticReferenceAnalysis, resolveSqlSemanticNavigationTarget } from "@/lib/sql/semantic/references"; @@ -403,6 +404,7 @@ 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 codeMirrorStartCompletion: typeof import("@codemirror/autocomplete").startCompletion | null = null; +let codeMirrorCloseCompletion: typeof import("@codemirror/autocomplete").closeCompletion | null = null; let codeMirrorInsertCompletionText: typeof import("@codemirror/autocomplete").insertCompletionText | null = null; let codeMirrorNextSnippetField: typeof import("@codemirror/autocomplete").nextSnippetField | null = null; let codeMirrorIndentMore: typeof import("@codemirror/commands").indentMore | null = null; @@ -2665,6 +2667,7 @@ let completionEpoch = 0; let completionDebounceTimer: ReturnType | null = null; let typedCompletionActivationUntil = 0; let suppressNextSqlCompletionAutoStartUntil = 0; +let activeCompletionOrigin: SqlCompletionTriggerOrigin | null = null; type QueryCompletionItem = SqlCompletionItem | ElasticsearchCompletionItem | RedisCompletionItem | MongoCompletionItem; @@ -2939,13 +2942,56 @@ async function provideSqlCompletions(context: CompletionContext) { const epoch = ++completionEpoch; try { + // 1. Suppressed context (comment / string literal) rejects everything, including explicit. if (isSqlCompletionSuppressedContext(fullDoc, position)) return null; + + // 2. Determine completion origin (session-level marker). + activeCompletionOrigin = originForSqlCompletionProvider(activeCompletionOrigin, context.explicit); + const origin = activeCompletionOrigin; + + // 3. Explicit (manual shortcut) -> always proceed. No mode gating. + // 4. For typing sessions, apply mode gating with lazy fact computation. const useDatabaseCompletion = resolveSqlServerUseDatabaseCompletion({ sql: fullDoc, cursor: position, databaseType: props.databaseType, }); - if (!explicit && !useDatabaseCompletion && !shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions())) return null; + const useDatabasePrefix = useDatabaseCompletion?.prefix ?? null; + + if (origin !== "explicit") { + const mode = settingsStore.editorSettings.completionTriggerMode; + + // manual: never auto-open. Return before computing any context. + if (mode === "manual") return null; + + // require-prefix: only compute local facts (no positionalEligible). + if (mode === "require-prefix") { + const ctx = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions()); + const prevChar = fullDoc[position - 1] ?? ""; + const facts: SqlCompletionTriggerFacts = { + origin, + hasIdentifierPrefix: ctx.prefix.length > 0, + qualifierTriggered: prevChar === "." && ctx.qualifier != null, + useDatabasePrefix, + }; + if (!shouldAllowSqlCompletionTrigger(mode, facts)) return null; + } + + // positional: compute positionalEligible (lazy). + if (mode === "positional") { + const ctx = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions()); + const prevChar = fullDoc[position - 1] ?? ""; + const positionalEligible = shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions()); + const facts: SqlCompletionTriggerFacts = { + origin, + hasIdentifierPrefix: ctx.prefix.length > 0, + qualifierTriggered: prevChar === "." && ctx.qualifier != null, + useDatabasePrefix, + positionalEligible, + }; + if (!shouldAllowSqlCompletionTrigger(mode, facts)) return null; + } + } if (useDatabaseCompletion) { const currentDatabase = props.database ?? ""; @@ -3111,6 +3157,7 @@ function scheduleSqlCompletionStart(currentView: EditorViewType, delayMs = 0) { window.setTimeout(() => { if (!codeMirrorStartCompletion || isEditorComposing(currentView)) return; markTypedCompletionActivation(); + activeCompletionOrigin = originForTypedSqlCompletionStart(activeCompletionOrigin); codeMirrorStartCompletion(currentView); }, delayMs); } @@ -3129,34 +3176,83 @@ function flushImeComposition() { if (editorIsActive) emitEditorSelection(latestSelection); const fullDoc = currentView.state.doc.toString(); const position = currentView.state.selection.main.head; - if (resolveSqlServerUseDatabaseCompletion({ sql: fullDoc, cursor: position, databaseType: props.databaseType }) || shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions())) { + if (shouldTriggerSqlCompletionForPosition(fullDoc, position)) { scheduleSqlCompletionStart(currentView); } } +/** + * Returns true when the current SQL position should trigger completion under the active trigger mode. + * Used by flushImeComposition and shouldStartSqlCompletionAfterInput. + */ +function shouldTriggerSqlCompletionForPosition(fullDoc: string, position: number): boolean { + if (isSqlCompletionSuppressedContext(fullDoc, position)) return false; + const mode = settingsStore.editorSettings.completionTriggerMode; + if (mode === "manual") return false; + + const useDatabaseCompletion = resolveSqlServerUseDatabaseCompletion({ + sql: fullDoc, + cursor: position, + databaseType: props.databaseType, + }); + const useDatabasePrefix = useDatabaseCompletion?.prefix ?? null; + + if (mode === "require-prefix") { + const ctx = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions()); + const prevChar = fullDoc[position - 1] ?? ""; + const facts: SqlCompletionTriggerFacts = { + origin: "typing", + hasIdentifierPrefix: ctx.prefix.length > 0, + qualifierTriggered: prevChar === "." && ctx.qualifier != null, + useDatabasePrefix, + }; + return shouldAllowSqlCompletionTrigger(mode, facts); + } + + // positional + const ctx = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions()); + const prevChar = fullDoc[position - 1] ?? ""; + const positionalEligible = shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions()); + const facts: SqlCompletionTriggerFacts = { + origin: "typing", + hasIdentifierPrefix: ctx.prefix.length > 0, + qualifierTriggered: prevChar === "." && ctx.qualifier != null, + useDatabasePrefix, + positionalEligible, + }; + return shouldAllowSqlCompletionTrigger(mode, facts); +} + function shouldStartSqlCompletionAfterInput(insertedText: string, removedText: string, currentView: EditorViewType): boolean { const position = currentView.state.selection.main.head; const fullDoc = currentView.state.doc.toString(); - if (resolveSqlServerUseDatabaseCompletion({ sql: fullDoc, cursor: position, databaseType: props.databaseType })) return true; + + // Non-SQL providers: keep existing behavior (trigger mode policy does not apply). if (props.databaseType === "mongodb") { return !!(insertedText || removedText) && shouldAutoOpenMongoCompletion(fullDoc, position); } if (props.databaseType === "victoriametrics") return false; - if (!insertedText && removedText) { + if (props.databaseType === "redis" || props.databaseType === "elasticsearch" || props.databaseType === "easysearch") { + // Preserve old character-based checks for non-SQL providers. + if (!insertedText && removedText) { + const completionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions()); + return isTableNameCompletionContext(completionContext) && shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions()); + } + if (insertedText.endsWith(".")) return true; + if (/[,(]$/.test(insertedText)) { + const completionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions()); + return !!completionContext.insertTable; + } + if (/\s$/.test(insertedText)) { + return shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions()); + } + if (!/[\w$@]$/.test(insertedText)) return false; const completionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions()); - return isTableNameCompletionContext(completionContext) && shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions()); + return isTableNameCompletionContext(completionContext) || shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions()); } - if (insertedText.endsWith(".")) return true; - if (/[,(]$/.test(insertedText)) { - const completionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions()); - return !!completionContext.insertTable; - } - if (/\s$/.test(insertedText)) { - return shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions()); - } - if (!/[\w$@]$/.test(insertedText)) return false; - const completionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions()); - return isTableNameCompletionContext(completionContext) || shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions()); + + // SQL providers: use unified trigger mode policy. + return shouldTriggerSqlCompletionForPosition(fullDoc, position); } function buildLocalSqlCompletionResult(completionContext: ReturnType, fullDoc: string, position: number, scope: CompletionMetadataScope) { @@ -3831,7 +3927,7 @@ onMounted(async () => { { EditorView, keymap, rectangularSelection, hoverTooltip, showTooltip, closeHoverTooltips, Decoration, tooltips, gutter, GutterMarker, lineNumberMarkers, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, crosshairCursor, scrollPastEnd, ViewPlugin }, { EditorState, EditorSelection, Compartment, Prec, RangeSet, StateEffect, StateField }, langSql, - { autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField }, + { autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField, closeCompletion }, { copyLineDown, copyLineUp, deleteLine, indentLess, indentMore, insertNewlineKeepIndent, moveLineDown, moveLineUp, redo, selectAll, undo, toggleLineComment, history, defaultKeymap, historyKeymap }, { bracketMatching, foldGutter, indentOnInput, indentUnit, syntaxHighlighting, defaultHighlightStyle, foldKeymap, toggleFold, ensureSyntaxTree }, { searchKeymap }, @@ -3865,6 +3961,7 @@ onMounted(async () => { setSqlDiagnosticsEffect = StateEffect.define(); codeMirrorCompletionStatus = completionStatus; codeMirrorAcceptCompletion = acceptCompletion; + codeMirrorCloseCompletion = closeCompletion; codeMirrorStartCompletion = startCompletion; codeMirrorInsertCompletionText = insertCompletionText; codeMirrorNextSnippetField = nextSnippetField; @@ -4398,6 +4495,13 @@ onMounted(async () => { latestSelection = readEditorSelection(update.view); if (editorIsActive) emitEditorSelection(latestSelection); } + // Clear activeCompletionOrigin when the completion session ends. + if (codeMirrorCompletionStatus) { + const status = codeMirrorCompletionStatus(update.state) ?? null; + if (status === null) { + activeCompletionOrigin = null; + } + } }), fontThemeComp.of( editorFontTheme(EditorView, liveFontSize.value, initialSettings.fontFamily, { @@ -4749,6 +4853,29 @@ onMounted(async () => { }); }); +// When completionTriggerMode changes, close any open typing session +// that would no longer be allowed under the new mode. +watch( + () => settingsStore.editorSettings.completionTriggerMode, + (newMode) => { + if (!view.value || !codeMirrorCompletionStatus || !codeMirrorCloseCompletion) return; + const status = codeMirrorCompletionStatus(view.value.state); + if (!status || activeCompletionOrigin !== "typing") return; + // If switching to manual, close all typing sessions. + if (newMode === "manual") { + codeMirrorCloseCompletion(view.value); + return; + } + // For other mode changes, re-evaluate the policy. + // If the current position would not trigger under the new mode, close. + const fullDoc = view.value.state.doc.toString(); + const position = view.value.state.selection.main.head; + if (!shouldTriggerSqlCompletionForPosition(fullDoc, position)) { + codeMirrorCloseCompletion(view.value); + } + }, +); + watch( () => props.modelValue, (val) => { diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index dab08b121..35e2c231e 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -5335,6 +5335,11 @@ export default { autoCloseBracketsDescription: "Automatically insert closing brackets and quotes when typing an opening one", insertSpaceAfterCompletion: "Insert a space after completion", insertSpaceAfterCompletionDescription: "Append a space after accepting a keyword, table, or column completion when the next character allows it", + completionTriggerMode: "Auto-completion trigger mode", + completionTriggerModeDescription: "Controls when the SQL completion popup opens automatically. Manual requires a shortcut key; Typed requires a typed identifier prefix; Smart preserves the current behavior.", + completionTriggerModeManual: "Manual only", + completionTriggerModeRequirePrefix: "Typed identifier prefix", + completionTriggerModePositional: "Smart position", sqlSemanticDiagnosticsEnabled: "SQL semantic diagnostics", sqlSemanticDiagnosticsEnabledDescription: "When enabled, the editor reports semantic issues such as unknown tables and columns. Disable it to reduce parsing and metadata checks for large SQL.", confirmDangerousSqlExecution: "Confirm before dangerous operations", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 6e5d83ced..2bc3e6edd 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -5064,6 +5064,11 @@ export default withEnglishFallback({ vimModeDescription: "Usar edición modal estilo Vim en el editor SQL", autoCloseBrackets: "Cerrar paréntesis automáticamente", autoCloseBracketsDescription: "Insertar automáticamente paréntesis y comillas de cierre al escribir los de apertura", + completionTriggerMode: "Modo de activación de autocompletado", + completionTriggerModeDescription: "Controla cuándo se abre automáticamente la ventana de autocompletado SQL. Manual requiere una tecla de acceso rápido; Prefijo escrito requiere un identificador escrito; Inteligente conserva el comportamiento actual.", + completionTriggerModeManual: "Solo manual", + completionTriggerModeRequirePrefix: "Prefijo de identificador escrito", + completionTriggerModePositional: "Posición inteligente", sqlSemanticDiagnosticsEnabled: "Diagnóstico semántico de SQL", sqlSemanticDiagnosticsEnabledDescription: "Al activarse, el editor informa de problemas semánticos como tablas y columnas desconocidas. Desactívalo para reducir el análisis y las comprobaciones de metadatos en SQL grandes.", confirmDangerousSqlExecution: "Confirmar antes de operaciones peligrosas", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index e856e621e..60faa728e 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -5064,6 +5064,11 @@ export default withEnglishFallback({ vimModeDescription: "Usa la modifica modale in stile Vim nell'editor SQL", autoCloseBrackets: "Chiusura automatica parentesi", autoCloseBracketsDescription: "Inserisci automaticamente parentesi e virgolette di chiusura quando digiti quelle di apertura", + completionTriggerMode: "Modalità di attivazione autocompletamento", + completionTriggerModeDescription: "Controlla quando si apre automaticamente il popup di autocompletamento SQL. Manuale richiede un tasto di scelta rapida; Prefisso digitato richiede un identificatore digitato; Intelligente mantiene il comportamento attuale.", + completionTriggerModeManual: "Solo manuale", + completionTriggerModeRequirePrefix: "Prefisso identificatore digitato", + completionTriggerModePositional: "Posizione intelligente", sqlSemanticDiagnosticsEnabled: "Diagnostica semantica SQL", sqlSemanticDiagnosticsEnabledDescription: "Se abilitata, l'editor segnala problemi semantici come tabelle e colonne sconosciute. Disabilitala per ridurre l'analisi e i controlli dei metadati per file SQL di grandi dimensioni.", confirmDangerousSqlExecution: "Conferma prima delle operazioni pericolose", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index f048cede2..dea7f6e0d 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -5438,6 +5438,11 @@ export default withEnglishFallback({ syncSnippetGuide: "設定ガイド", insertSpaceAfterCompletion: "補全後に自動でスペースを挿入", insertSpaceAfterCompletionDescription: "キーワード、テーブル名、列名の補全確定時、次の文字が許容する場合に自動でスペースを補填します", + completionTriggerMode: "自動補完トリガーモード", + completionTriggerModeDescription: "SQL補完ポップアップが自動的に開くタイミングを制御します。手動はショートカットキーが必要; 識別子入力後は1文字以上の入力が必要; スマート位置は現在の動作を維持します。", + completionTriggerModeManual: "手動のみ", + completionTriggerModeRequirePrefix: "識別子入力後", + completionTriggerModePositional: "スマート位置", }, driverStore: { jreDirRemoveFailed: "古い JRE ディレクトリを削除できませんでした: {path}(元のエラー: {error})", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 9611bd97d..355b47df4 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -4839,6 +4839,11 @@ export default withEnglishFallback({ autoCloseBracketsDescription: "여는 괄호나 따옴표를 입력할 때 닫는 괄호와 따옴표를 자동으로 삽입합니다", insertSpaceAfterCompletion: "완성 후 공백 삽입", insertSpaceAfterCompletionDescription: "다음 문자가 허용할 때 키워드, 테이블 또는 컬럼 완성을 수락한 후 공백을 추가합니다", + completionTriggerMode: "자동 완성 트리거 모드", + completionTriggerModeDescription: "SQL 완성 팝업이 자동으로 열리는 시기를 제어합니다. 수동은 단축키 필요; 입력 식별자는 하나 이상의 문자 입력 필요; 스마트 위치는 현재 동작 유지.", + completionTriggerModeManual: "수동만", + completionTriggerModeRequirePrefix: "식별자 입력 후", + completionTriggerModePositional: "스마트 위치", sqlSemanticDiagnosticsEnabled: "SQL 의미 진단", sqlSemanticDiagnosticsEnabledDescription: "활성화하면 편집기가 알 수 없는 테이블과 컬럼 같은 의미 문제를 보고합니다. 대규모 SQL의 파싱과 메타데이터 검사를 줄이려면 비활성화하세요.", confirmDangerousSqlExecution: "위험한 작업 전에 확인", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index c03a48e59..21f25bf87 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -5066,6 +5066,11 @@ export default withEnglishFallback({ vimModeDescription: "Usar edição modal no estilo Vim no editor SQL", autoCloseBrackets: "Fechar parênteses automaticamente", autoCloseBracketsDescription: "Inserir automaticamente parênteses e aspas de fechamento ao digitar os de abertura", + completionTriggerMode: "Modo de acionamento do autocompletar", + completionTriggerModeDescription: "Controla quando o popup de autocompletar SQL abre automaticamente. Manual requer uma tecla de atalho; Prefixo digitado requer um identificador digitado; Inteligente mantém o comportamento atual.", + completionTriggerModeManual: "Apenas manual", + completionTriggerModeRequirePrefix: "Prefixo de identificador digitado", + completionTriggerModePositional: "Posição inteligente", sqlSemanticDiagnosticsEnabled: "Diagnóstico semântico de SQL", sqlSemanticDiagnosticsEnabledDescription: "Quando ativado, o editor relata problemas semânticos como tabelas e colunas desconhecidas. Desative para reduzir a análise e verificações de metadados em SQL grande.", confirmDangerousSqlExecution: "Confirmar antes de operações perigosas", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 0ec0b6034..699fa2c84 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -5331,6 +5331,11 @@ export default withEnglishFallback({ autoCloseBracketsDescription: "输入左括号或左引号时自动补全对应的右括号或右引号", insertSpaceAfterCompletion: "补全后自动添加空格", insertSpaceAfterCompletionDescription: "选择关键字、表名或列名补全后,在后续字符允许时自动追加空格", + completionTriggerMode: "自动补全触发方式", + completionTriggerModeDescription: "控制 SQL 补全弹窗何时自动打开。仅手动提示需快捷键;输入标识符后提示需输入至少一个字符;智能位置提示保持当前行为。", + completionTriggerModeManual: "仅手动提示", + completionTriggerModeRequirePrefix: "输入标识符后提示", + completionTriggerModePositional: "智能位置提示", sqlSemanticDiagnosticsEnabled: "SQL 语义诊断", sqlSemanticDiagnosticsEnabledDescription: "开启后,编辑器会提示未知表、字段等语义问题;关闭可减少 SQL 解析和元数据检查的性能开销。", confirmDangerousSqlExecution: "执行危险操作前弹出确认", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 9b7122a17..f34086162 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -4510,6 +4510,11 @@ export default withEnglishFallback({ vimModeDescription: "在 SQL 編輯器中使用 Vim 風格的模態編輯", autoCloseBrackets: "自動成對補全", autoCloseBracketsDescription: "輸入左括號或左引號時自動補全對應的右括號或右引號", + completionTriggerMode: "自動補全觸發方式", + completionTriggerModeDescription: "控制 SQL 補全彈窗何時自動開啟。僅手動提示需快捷鍵;輸入識別碼後提示需輸入至少一個字元;智慧位置提示保持目前行為。", + completionTriggerModeManual: "僅手動提示", + completionTriggerModeRequirePrefix: "輸入識別碼後提示", + completionTriggerModePositional: "智慧位置提示", sqlSemanticDiagnosticsEnabled: "SQL 語意診斷", sqlSemanticDiagnosticsEnabledDescription: "開啟後,編輯器會提示未知資料表、欄位等語意問題;關閉可減少 SQL 解析和中繼資料檢查的效能負擔。", confirmDangerousSqlExecution: "執行危險操作前彈出確認", diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts index 93a667236..9b69010b9 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.context.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildSelectStarExpansion, buildSqlCompletionItems, getSqlCompletionContext, selectStarResultColumnsMatch, shouldAutoOpenSqlCompletion } from "@/lib/sql/sqlCompletion"; import { sqlCompletionContextFromSemantic } from "@/lib/sql/semantic/completion"; import { buildSqlSemanticModel } from "@/lib/sql/semantic/model"; +import { originForSqlCompletionProvider, originForTypedSqlCompletionStart, shouldAllowSqlCompletionTrigger, type SqlCompletionTriggerFacts } from "@/lib/sql/sqlCompletionTriggerPolicy"; describe("sqlCompletion keyword snippets", () => { it("auto-opens and suggests SELECT when typing sel", () => { @@ -700,3 +701,109 @@ describe("sqlCompletion scoped metadata ranking", () => { expect(sqlServerItems).toEqual([expect.objectContaining({ label: "Orders", apply: "Orders" })]); }); }); + +describe("shouldAllowSqlCompletionTrigger", () => { + const typingFacts = (overrides: Partial = {}): SqlCompletionTriggerFacts => ({ + origin: "typing", + hasIdentifierPrefix: false, + qualifierTriggered: false, + useDatabasePrefix: null, + ...overrides, + }); + + const explicitFacts = (overrides: Partial = {}): SqlCompletionTriggerFacts => ({ + origin: "explicit", + hasIdentifierPrefix: false, + qualifierTriggered: false, + useDatabasePrefix: null, + ...overrides, + }); + + describe("explicit", () => { + it("allows explicit completion in any mode", () => { + expect(shouldAllowSqlCompletionTrigger("manual", explicitFacts())).toBe(true); + expect(shouldAllowSqlCompletionTrigger("require-prefix", explicitFacts())).toBe(true); + expect(shouldAllowSqlCompletionTrigger("positional", explicitFacts())).toBe(true); + }); + }); + + describe("manual", () => { + it("rejects all typing completions", () => { + expect(shouldAllowSqlCompletionTrigger("manual", typingFacts())).toBe(false); + expect(shouldAllowSqlCompletionTrigger("manual", typingFacts({ hasIdentifierPrefix: true }))).toBe(false); + expect(shouldAllowSqlCompletionTrigger("manual", typingFacts({ qualifierTriggered: true }))).toBe(false); + expect(shouldAllowSqlCompletionTrigger("manual", typingFacts({ useDatabasePrefix: "m" }))).toBe(false); + expect(shouldAllowSqlCompletionTrigger("manual", typingFacts({ positionalEligible: true }))).toBe(false); + }); + }); + + describe("require-prefix", () => { + it("allows when identifier prefix is non-empty", () => { + expect(shouldAllowSqlCompletionTrigger("require-prefix", typingFacts({ hasIdentifierPrefix: true }))).toBe(true); + }); + + it("allows when qualifier is triggered (dot with qualifier)", () => { + expect(shouldAllowSqlCompletionTrigger("require-prefix", typingFacts({ qualifierTriggered: true }))).toBe(true); + }); + + it("allows when useDatabasePrefix is non-empty", () => { + expect(shouldAllowSqlCompletionTrigger("require-prefix", typingFacts({ useDatabasePrefix: "m" }))).toBe(true); + expect(shouldAllowSqlCompletionTrigger("require-prefix", typingFacts({ useDatabasePrefix: "Bar" }))).toBe(true); + }); + + it("rejects empty prefix, no qualifier, no useDatabasePrefix", () => { + expect(shouldAllowSqlCompletionTrigger("require-prefix", typingFacts())).toBe(false); + }); + + it("rejects empty useDatabasePrefix (USE without prefix)", () => { + expect(shouldAllowSqlCompletionTrigger("require-prefix", typingFacts({ useDatabasePrefix: "" }))).toBe(false); + }); + + it("does not use positionalEligible", () => { + // Even if positionalEligible is true, require-prefix ignores it. + expect(shouldAllowSqlCompletionTrigger("require-prefix", typingFacts({ positionalEligible: true }))).toBe(false); + }); + }); + + describe("positional", () => { + it("allows when positionalEligible is true", () => { + expect(shouldAllowSqlCompletionTrigger("positional", typingFacts({ positionalEligible: true }))).toBe(true); + }); + + it("allows when useDatabasePrefix is set (even empty)", () => { + expect(shouldAllowSqlCompletionTrigger("positional", typingFacts({ useDatabasePrefix: "" }))).toBe(true); + expect(shouldAllowSqlCompletionTrigger("positional", typingFacts({ useDatabasePrefix: "m" }))).toBe(true); + }); + + it("rejects when positionalEligible is false and no useDatabasePrefix", () => { + expect(shouldAllowSqlCompletionTrigger("positional", typingFacts({ positionalEligible: false }))).toBe(false); + }); + + it("rejects when positionalEligible is undefined and no useDatabasePrefix", () => { + expect(shouldAllowSqlCompletionTrigger("positional", typingFacts())).toBe(false); + }); + }); +}); + +describe("originForTypedSqlCompletionStart", () => { + it("starts a new automatic session as typing", () => { + expect(originForTypedSqlCompletionStart(null)).toBe("typing"); + }); + + it("preserves the origin of an active completion session", () => { + expect(originForTypedSqlCompletionStart("typing")).toBe("typing"); + expect(originForTypedSqlCompletionStart("explicit")).toBe("explicit"); + }); +}); + +describe("originForSqlCompletionProvider", () => { + it("classifies an unmarked provider call from CodeMirror", () => { + expect(originForSqlCompletionProvider(null, false)).toBe("typing"); + expect(originForSqlCompletionProvider(null, true)).toBe("explicit"); + }); + + it("preserves the active session independently of the current provider flag", () => { + expect(originForSqlCompletionProvider("typing", true)).toBe("typing"); + expect(originForSqlCompletionProvider("explicit", false)).toBe("explicit"); + }); +}); diff --git a/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts b/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts index cbc9c28e0..e468528ec 100644 --- a/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts +++ b/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts @@ -49,6 +49,10 @@ describe("EDITOR_SETTINGS_DRAFT_KEYS", () => { it("includes the data-tab reuse mode", () => { expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("dataTabReuseMode"); }); + + it("includes completionTriggerMode", () => { + expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("completionTriggerMode"); + }); }); describe("editorSettingsDraftFromSettings", () => { @@ -71,6 +75,16 @@ describe("editorSettingsDraftFromSettings", () => { it("maps the saved SQL open target mode", () => { expect(editorSettingsDraftFromSettings(makeSettings({ savedSqlOpenTargetMode: "current" })).savedSqlOpenTargetMode).toBe("current"); }); + + it("maps completionTriggerMode from settings", () => { + const draft = editorSettingsDraftFromSettings(makeSettings({ completionTriggerMode: "require-prefix" } as Partial)); + expect(draft.completionTriggerMode).toBe("require-prefix"); + }); + + it("normalizes invalid completionTriggerMode to positional", () => { + const draft = editorSettingsDraftFromSettings(makeSettings({ completionTriggerMode: "always" as unknown } as Partial)); + expect(draft.completionTriggerMode).toBe("positional"); + }); }); describe("normalizeTableOpenPageSizeDraft", () => { @@ -127,6 +141,21 @@ describe("editorSettingsDraftChanged", () => { draft.dataTabReuseMode = "active-tab"; expect(editorSettingsDraftChanged(draft, base)).toBe(true); }); + + it("detects completionTriggerMode change", () => { + const settings = makeSettings({ completionTriggerMode: "positional" } as Partial); + const draft = editorSettingsDraftFromSettings(settings); + const base = editorSettingsDraftFromSettings(settings); + draft.completionTriggerMode = "manual"; + expect(editorSettingsDraftChanged(draft, base)).toBe(true); + }); + + it("detects no change when completionTriggerMode matches", () => { + const settings = makeSettings({ completionTriggerMode: "require-prefix" } as Partial); + const draft = editorSettingsDraftFromSettings(settings); + const base = editorSettingsDraftFromSettings(settings); + expect(editorSettingsDraftChanged(draft, base)).toBe(false); + }); }); describe("editorSettingsPatchFromDraft", () => { @@ -170,6 +199,23 @@ describe("editorSettingsPatchFromDraft", () => { draft.dataTabReuseMode = "always-new"; expect(editorSettingsPatchFromDraft(draft, base).dataTabReuseMode).toBe("always-new"); }); + + it("includes completionTriggerMode in patch when changed", () => { + const settings = makeSettings({ completionTriggerMode: "positional" } as Partial); + const draft = editorSettingsDraftFromSettings(settings); + const base = editorSettingsDraftFromSettings(settings); + draft.completionTriggerMode = "manual"; + const patch = editorSettingsPatchFromDraft(draft, base); + expect(patch.completionTriggerMode).toBe("manual"); + }); + + it("omits completionTriggerMode when unchanged", () => { + const settings = makeSettings({ completionTriggerMode: "require-prefix" } as Partial); + const draft = editorSettingsDraftFromSettings(settings); + const base = editorSettingsDraftFromSettings(settings); + const patch = editorSettingsPatchFromDraft(draft, base); + expect(patch.completionTriggerMode).toBeUndefined(); + }); }); describe("EDITOR_SETTINGS_DRAFT_KEYS - tabLayout", () => { diff --git a/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts b/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts index f2ee2917b..9fb96a082 100644 --- a/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts +++ b/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts @@ -137,6 +137,7 @@ describe("settings search", () => { { titleKey: "toolbar.theme", category: "appearance", targetId: "appearance" }, { titleKey: "settings.sidebarObjectInfoMode", category: "navigation", targetId: "navigation" }, { titleKey: "settings.insertSpaceAfterCompletion", category: "editor", targetId: "editor" }, + { titleKey: "settings.completionTriggerMode", category: "editor", targetId: "editor" }, { titleKey: "settings.autoAliasTables", category: "editor", targetId: "editor" }, { titleKey: "settings.clickTableNavigationTarget", category: "editor", targetId: "editor" }, { titleKey: "settings.sqlFormatterKeywordCase", category: "formatter", targetId: "formatter" }, diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index 4d36d203a..add5df8a9 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -1,5 +1,6 @@ import type { EditorSettings } from "@/stores/settingsStore"; import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize"; +import { normalizeCompletionTriggerMode } from "@/lib/sql/sqlCompletionTriggerPolicy"; export const EDITOR_SETTINGS_DRAFT_KEYS = [ "fontFamily", @@ -66,6 +67,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "sqlVariableSyntaxOverrides", "continueOnErrorOnBatch", "clickTableNavigationTarget", + "completionTriggerMode", ] as const satisfies readonly (keyof EditorSettings)[]; export type EditorSettingsDraftKey = (typeof EDITOR_SETTINGS_DRAFT_KEYS)[number]; @@ -83,6 +85,7 @@ export function normalizeTableOpenPageSizeDraft(value: unknown): number { function normalizedDraftValue(key: EditorSettingsDraftKey, value: unknown): unknown { if (key === "tableOpenPageSize") return normalizeTableOpenPageSizeDraft(value); + if (key === "completionTriggerMode") return normalizeCompletionTriggerMode(value); return value; } diff --git a/apps/desktop/src/lib/settings/settingsSearch.ts b/apps/desktop/src/lib/settings/settingsSearch.ts index f77132484..e7b49ac91 100644 --- a/apps/desktop/src/lib/settings/settingsSearch.ts +++ b/apps/desktop/src/lib/settings/settingsSearch.ts @@ -114,6 +114,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] = { id: "editor-vim", category: "editor", titleKey: "settings.vimMode", descriptionKey: "settings.vimModeDescription", targetId: "editor" }, { id: "editor-brackets", category: "editor", titleKey: "settings.autoCloseBrackets", descriptionKey: "settings.autoCloseBracketsDescription", targetId: "editor" }, { id: "editor-completion-spacing", category: "editor", titleKey: "settings.insertSpaceAfterCompletion", descriptionKey: "settings.insertSpaceAfterCompletionDescription", targetId: "editor" }, + { id: "editor-completion-trigger-mode", category: "editor", titleKey: "settings.completionTriggerMode", descriptionKey: "settings.completionTriggerModeDescription", targetId: "editor" }, { id: "editor-auto-alias", category: "editor", titleKey: "settings.autoAliasTables", descriptionKey: "settings.autoAliasTablesDescription", targetId: "editor" }, { id: "editor-unsaved-close", category: "editor", titleKey: "settings.confirmUnsavedSqlClose", descriptionKey: "settings.confirmUnsavedSqlCloseDescription", targetId: "editor" }, { id: "editor-prefill-query", category: "editor", titleKey: "settings.prefillNewQueryWithSelect", descriptionKey: "settings.prefillNewQueryWithSelectDescription", targetId: "editor" }, diff --git a/apps/desktop/src/lib/sql/sqlCompletionTriggerPolicy.ts b/apps/desktop/src/lib/sql/sqlCompletionTriggerPolicy.ts new file mode 100644 index 000000000..eef4c21de --- /dev/null +++ b/apps/desktop/src/lib/sql/sqlCompletionTriggerPolicy.ts @@ -0,0 +1,61 @@ +export type SqlCompletionTriggerMode = "manual" | "require-prefix" | "positional"; +export type SqlCompletionTriggerOrigin = "typing" | "explicit"; + +export const SQL_COMPLETION_TRIGGER_MODES: readonly SqlCompletionTriggerMode[] = ["manual", "require-prefix", "positional"]; + +export function originForTypedSqlCompletionStart(activeOrigin: SqlCompletionTriggerOrigin | null): SqlCompletionTriggerOrigin { + // Programmatic refreshes can run while a shortcut-opened session is active. + // Preserve that explicit origin so typing and metadata refreshes do not make + // the session subject to automatic-trigger mode gates. + return activeOrigin ?? "typing"; +} + +export function originForSqlCompletionProvider(activeOrigin: SqlCompletionTriggerOrigin | null, explicit: boolean): SqlCompletionTriggerOrigin { + // Programmatic starts set their origin before calling CodeMirror, while + // activate-on-typing calls the provider with explicit=false. Therefore an + // otherwise unmarked explicit call is always the manual shortcut session. + return activeOrigin ?? (explicit ? "explicit" : "typing"); +} + +export function normalizeCompletionTriggerMode(value: unknown): SqlCompletionTriggerMode { + if (typeof value === "string" && (value === "manual" || value === "require-prefix" || value === "positional")) { + return value; + } + return "positional"; +} + +export interface SqlCompletionTriggerFacts { + /** typing (DBX programmatic start / implicit) vs explicit (manual completion shortcut) */ + origin: SqlCompletionTriggerOrigin; + /** completion context context.prefix.length > 0 (not trailing '.') */ + hasIdentifierPrefix: boolean; + /** previousChar === "." && context.qualifier != null */ + qualifierTriggered: boolean; + /** resolveSqlServerUseDatabaseCompletion().prefix ('m' / 'Bar' / '') */ + useDatabasePrefix: string | null; + /** Only computed for positional mode: existing shouldAutoOpenSqlCompletion(sql, cursor, options) result */ + positionalEligible?: boolean; +} + +/** + * Pure trigger policy decision (the single gate, without sql/position itself). + * + * Decision rules (priority order): + * 1. suppressed context (comment / string literal) -> reject first (including explicit) + * 2. explicit -> allow directly (manual shortcut available in any mode) + * 3. manual -> false (return before any semantic model / metadata read) + * 4. require-prefix -> hasIdentifierPrefix || qualifierTriggered || (useDatabasePrefix != null && useDatabasePrefix.length > 0) + * 5. positional -> positionalEligible || useDatabasePrefix != null + */ +export function shouldAllowSqlCompletionTrigger(mode: SqlCompletionTriggerMode, facts: SqlCompletionTriggerFacts): boolean { + // Suppressed context (comment / string literal) is checked by the caller before + // constructing facts, so this function treats the caller as already gate-kept. + // However, for safety, explicit still bypasses mode checks. + if (facts.origin === "explicit") return true; + if (mode === "manual") return false; + if (mode === "require-prefix") { + return facts.hasIdentifierPrefix || facts.qualifierTriggered || (facts.useDatabasePrefix != null && facts.useDatabasePrefix.length > 0); + } + // positional + return (facts.positionalEligible ?? false) || facts.useDatabasePrefix != null; +} diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index 8eaae3ad6..dc8af0506 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -351,6 +351,26 @@ describe("normalizeEditorSettings - clickTableNavigationTarget", () => { }); }); +describe("normalizeEditorSettings - completionTriggerMode", () => { + it("defaults completionTriggerMode to positional", () => { + expect(normalizeEditorSettings({}).completionTriggerMode).toBe("positional"); + }); + + it("preserves the three valid modes", () => { + expect(normalizeEditorSettings({ completionTriggerMode: "manual" }).completionTriggerMode).toBe("manual"); + expect(normalizeEditorSettings({ completionTriggerMode: "require-prefix" }).completionTriggerMode).toBe("require-prefix"); + expect(normalizeEditorSettings({ completionTriggerMode: "positional" }).completionTriggerMode).toBe("positional"); + }); + + it("normalizes invalid values to positional", () => { + expect(normalizeEditorSettings({ completionTriggerMode: "always" } as any).completionTriggerMode).toBe("positional"); + expect(normalizeEditorSettings({ completionTriggerMode: "" } as any).completionTriggerMode).toBe("positional"); + expect(normalizeEditorSettings({ completionTriggerMode: undefined } as any).completionTriggerMode).toBe("positional"); + expect(normalizeEditorSettings({ completionTriggerMode: null } as any).completionTriggerMode).toBe("positional"); + expect(normalizeEditorSettings({ completionTriggerMode: 123 } as any).completionTriggerMode).toBe("positional"); + }); +}); + describe("normalizeEditorSettings - tabLayout", () => { it("defaults tabLayout to scroll", () => { expect(normalizeEditorSettings({}).tabLayout).toBe("scroll"); diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index afed0429e..fc7fb81f9 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -18,10 +18,11 @@ import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, type Sql import { normalizeSqlVariableSyntaxOverrides, type SqlVariableSyntaxOverrides } from "@/lib/sql/sqlVariableSyntax"; import { DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS, normalizeTableColumnTemplateFields } from "@/lib/table/tableColumnTemplates"; import { type DataTabReuseMode, DEFAULT_DATA_TAB_REUSE_MODE, normalizeDataTabReuseMode } from "@/lib/tabs/dataTabReuseMode"; +import { normalizeCompletionTriggerMode, type SqlCompletionTriggerMode } from "@/lib/sql/sqlCompletionTriggerPolicy"; import type { AiApiStyle, AiAuthMethod, AiChatSelectionState, AiConfig, AiConfigItem, AiConfiguredModel, AiEffortLevel, AiEffortSelection, AiModelEffortPreference, AiProvider, AiReasoningLevel, AiTestConnectionResult } from "@/types/ai"; import type { SqlSnippet, TableInfoTab } from "@/types/database"; -export type { AiApiStyle, AiAuthMethod, AiChatSelectionState, AiConfig, AiConfigItem, AiConfiguredModel, AiEffortLevel, AiEffortSelection, AiProvider, AiReasoningLevel, AiTestConnectionResult, DataTabReuseMode, SavedSqlOpenTargetMode }; +export type { AiApiStyle, AiAuthMethod, AiChatSelectionState, AiConfig, AiConfigItem, AiConfiguredModel, AiEffortLevel, AiEffortSelection, AiProvider, AiReasoningLevel, AiTestConnectionResult, DataTabReuseMode, SavedSqlOpenTargetMode, SqlCompletionTriggerMode }; export interface DesktopSettings { show_tray_icon: boolean; @@ -550,6 +551,7 @@ export interface EditorSettings { sqlVariableSyntaxOverrides: SqlVariableSyntaxOverrides; continueOnErrorOnBatch: boolean; clickTableNavigationTarget: ClickTableNavigationTarget; + completionTriggerMode: SqlCompletionTriggerMode; } export interface ToolbarItems { @@ -730,6 +732,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { sqlVariableSyntaxOverrides: {}, continueOnErrorOnBatch: false, clickTableNavigationTarget: "data", + completionTriggerMode: "positional", }; export const STORAGE_KEY = "dbx-editor-settings"; @@ -1079,6 +1082,7 @@ export function normalizeEditorSettings(settings: Partial, exist sqlVariableSyntaxOverrides: normalizeSqlVariableSyntaxOverrides(settings.sqlVariableSyntaxOverrides), continueOnErrorOnBatch: settings.continueOnErrorOnBatch === true, clickTableNavigationTarget: normalizeClickTableNavigationTarget(settings.clickTableNavigationTarget), + completionTriggerMode: normalizeCompletionTriggerMode(settings.completionTriggerMode), }; } @@ -1536,6 +1540,7 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.sqlVariableSyntaxOverrides !== undefined) editorSettings.value.sqlVariableSyntaxOverrides = normalizeSqlVariableSyntaxOverrides(partial.sqlVariableSyntaxOverrides); if (partial.continueOnErrorOnBatch !== undefined) editorSettings.value.continueOnErrorOnBatch = partial.continueOnErrorOnBatch === true; if (partial.clickTableNavigationTarget !== undefined) editorSettings.value.clickTableNavigationTarget = normalizeClickTableNavigationTarget(partial.clickTableNavigationTarget); + if (partial.completionTriggerMode !== undefined) editorSettings.value.completionTriggerMode = normalizeCompletionTriggerMode(partial.completionTriggerMode); saveEditorSettings(editorSettings.value); }