feat(completion): configurable SQL completion trigger modes

This commit is contained in:
Abeautifulsnow 2026-08-08 12:00:50 +08:00 committed by GitHub
parent bad7f6b472
commit 5b85f9eae8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 457 additions and 18 deletions

View File

@ -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<SqlCompletionTriggerMode>(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(() => {
</div>
<Switch id="editor-auto-alias-tables" v-model="editAutoAliasTables" class="mt-0.5" />
</div>
<div class="space-y-2">
<Label>{{ t("settings.completionTriggerMode") }}</Label>
<Select :model-value="editCompletionTriggerMode" @update:model-value="onCompletionTriggerModeChange">
<SelectTrigger>
<SelectValue :placeholder="t('settings.completionTriggerMode')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">{{ t("settings.completionTriggerModeManual") }}</SelectItem>
<SelectItem value="require-prefix">{{ t("settings.completionTriggerModeRequirePrefix") }}</SelectItem>
<SelectItem value="positional">{{ t("settings.completionTriggerModePositional") }}</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">
{{ t("settings.completionTriggerModeDescription") }}
</p>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">

View File

@ -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<typeof setTimeout> | 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<typeof getSqlCompletionContext>, 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<SqlSemanticDiagnostic[]>();
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) => {

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -5438,6 +5438,11 @@ export default withEnglishFallback({
syncSnippetGuide: "設定ガイド",
insertSpaceAfterCompletion: "補全後に自動でスペースを挿入",
insertSpaceAfterCompletionDescription: "キーワード、テーブル名、列名の補全確定時、次の文字が許容する場合に自動でスペースを補填します",
completionTriggerMode: "自動補完トリガーモード",
completionTriggerModeDescription: "SQL補完ポップアップが自動的に開くタイミングを制御します。手動はショートカットキーが必要; 識別子入力後は1文字以上の入力が必要; スマート位置は現在の動作を維持します。",
completionTriggerModeManual: "手動のみ",
completionTriggerModeRequirePrefix: "識別子入力後",
completionTriggerModePositional: "スマート位置",
},
driverStore: {
jreDirRemoveFailed: "古い JRE ディレクトリを削除できませんでした: {path}(元のエラー: {error}",

View File

@ -4839,6 +4839,11 @@ export default withEnglishFallback({
autoCloseBracketsDescription: "여는 괄호나 따옴표를 입력할 때 닫는 괄호와 따옴표를 자동으로 삽입합니다",
insertSpaceAfterCompletion: "완성 후 공백 삽입",
insertSpaceAfterCompletionDescription: "다음 문자가 허용할 때 키워드, 테이블 또는 컬럼 완성을 수락한 후 공백을 추가합니다",
completionTriggerMode: "자동 완성 트리거 모드",
completionTriggerModeDescription: "SQL 완성 팝업이 자동으로 열리는 시기를 제어합니다. 수동은 단축키 필요; 입력 식별자는 하나 이상의 문자 입력 필요; 스마트 위치는 현재 동작 유지.",
completionTriggerModeManual: "수동만",
completionTriggerModeRequirePrefix: "식별자 입력 후",
completionTriggerModePositional: "스마트 위치",
sqlSemanticDiagnosticsEnabled: "SQL 의미 진단",
sqlSemanticDiagnosticsEnabledDescription: "활성화하면 편집기가 알 수 없는 테이블과 컬럼 같은 의미 문제를 보고합니다. 대규모 SQL의 파싱과 메타데이터 검사를 줄이려면 비활성화하세요.",
confirmDangerousSqlExecution: "위험한 작업 전에 확인",

View File

@ -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",

View File

@ -5331,6 +5331,11 @@ export default withEnglishFallback({
autoCloseBracketsDescription: "输入左括号或左引号时自动补全对应的右括号或右引号",
insertSpaceAfterCompletion: "补全后自动添加空格",
insertSpaceAfterCompletionDescription: "选择关键字、表名或列名补全后,在后续字符允许时自动追加空格",
completionTriggerMode: "自动补全触发方式",
completionTriggerModeDescription: "控制 SQL 补全弹窗何时自动打开。仅手动提示需快捷键;输入标识符后提示需输入至少一个字符;智能位置提示保持当前行为。",
completionTriggerModeManual: "仅手动提示",
completionTriggerModeRequirePrefix: "输入标识符后提示",
completionTriggerModePositional: "智能位置提示",
sqlSemanticDiagnosticsEnabled: "SQL 语义诊断",
sqlSemanticDiagnosticsEnabledDescription: "开启后,编辑器会提示未知表、字段等语义问题;关闭可减少 SQL 解析和元数据检查的性能开销。",
confirmDangerousSqlExecution: "执行危险操作前弹出确认",

View File

@ -4510,6 +4510,11 @@ export default withEnglishFallback({
vimModeDescription: "在 SQL 編輯器中使用 Vim 風格的模態編輯",
autoCloseBrackets: "自動成對補全",
autoCloseBracketsDescription: "輸入左括號或左引號時自動補全對應的右括號或右引號",
completionTriggerMode: "自動補全觸發方式",
completionTriggerModeDescription: "控制 SQL 補全彈窗何時自動開啟。僅手動提示需快捷鍵;輸入識別碼後提示需輸入至少一個字元;智慧位置提示保持目前行為。",
completionTriggerModeManual: "僅手動提示",
completionTriggerModeRequirePrefix: "輸入識別碼後提示",
completionTriggerModePositional: "智慧位置提示",
sqlSemanticDiagnosticsEnabled: "SQL 語意診斷",
sqlSemanticDiagnosticsEnabledDescription: "開啟後,編輯器會提示未知資料表、欄位等語意問題;關閉可減少 SQL 解析和中繼資料檢查的效能負擔。",
confirmDangerousSqlExecution: "執行危險操作前彈出確認",

View File

@ -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> = {}): SqlCompletionTriggerFacts => ({
origin: "typing",
hasIdentifierPrefix: false,
qualifierTriggered: false,
useDatabasePrefix: null,
...overrides,
});
const explicitFacts = (overrides: Partial<SqlCompletionTriggerFacts> = {}): 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<space> 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");
});
});

View File

@ -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<EditorSettings>));
expect(draft.completionTriggerMode).toBe("require-prefix");
});
it("normalizes invalid completionTriggerMode to positional", () => {
const draft = editorSettingsDraftFromSettings(makeSettings({ completionTriggerMode: "always" as unknown } as Partial<EditorSettings>));
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<EditorSettings>);
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<EditorSettings>);
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<EditorSettings>);
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<EditorSettings>);
const draft = editorSettingsDraftFromSettings(settings);
const base = editorSettingsDraftFromSettings(settings);
const patch = editorSettingsPatchFromDraft(draft, base);
expect(patch.completionTriggerMode).toBeUndefined();
});
});
describe("EDITOR_SETTINGS_DRAFT_KEYS - tabLayout", () => {

View File

@ -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" },

View File

@ -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;
}

View File

@ -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" },

View File

@ -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;
}

View File

@ -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");

View File

@ -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<EditorSettings>, 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);
}