diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index f06e332cd..343937f17 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -181,6 +181,8 @@ let semanticDiagnosticTimer: ReturnType | null = null; let semanticDiagnosticRunId = 0; let editorIsActive = true; let tableReferenceDropListenerRegistered = false; +let imeCompositionActive = false; +let pendingImeModelEmit = false; function editorThemeAppearance() { return isDark.value ? "dark" : "light"; @@ -904,6 +906,7 @@ async function provideElasticsearchCompletions(currentState: import("@codemirror } async function provideSqlCompletions(currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean) { + if (imeCompositionActive || view.value?.compositionStarted || view.value?.composing) return null; if (!props.connectionId) return null; const fullDoc = currentState.doc.toString(); if (props.databaseType === "elasticsearch") { @@ -990,6 +993,23 @@ async function provideSqlCompletions(currentState: import("@codemirror/state").E } } +function isEditorComposing(currentView: EditorViewType): boolean { + return imeCompositionActive || currentView.compositionStarted || currentView.composing; +} + +function flushImeComposition() { + const currentView = view.value; + if (!currentView || !pendingImeModelEmit) return; + pendingImeModelEmit = false; + emit("update:modelValue", currentView.state.doc.toString()); + scheduleSemanticDiagnostics(); + syncContextMenuState(currentView); + emit("selectionChange", selectedSqlFromView(currentView)); + emit("cursorChange", currentView.state.selection.main.head); + latestSelection = readEditorSelection(currentView); + if (editorIsActive) emitEditorSelection(latestSelection); +} + function buildLocalSqlCompletionResult(completionContext: ReturnType, fullDoc: string, position: number) { if (!props.connectionId || props.database == null) return null; const databaseNames = supportsDatabaseQualifierCompletion() && completionContext.suggestTables && !completionContext.insertTable ? connectionStore.lookupLocalCompletionDatabases(props.connectionId, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES) : []; @@ -1547,14 +1567,19 @@ onMounted(async () => { rectangularSelection({ eventFilter: (e: MouseEvent) => e.altKey || e.button === 1 }), EditorView.updateListener.of((update) => { if (update.docChanged) { - emit("update:modelValue", update.state.doc.toString()); - scheduleSemanticDiagnostics(); - let insertedText = ""; - update.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => { - insertedText += inserted.toString(); - }); - if (insertedText.endsWith(".")) { - startCompletion(update.view); + if (isEditorComposing(update.view)) { + pendingImeModelEmit = true; + completionEpoch++; + } else { + emit("update:modelValue", update.state.doc.toString()); + scheduleSemanticDiagnostics(); + let insertedText = ""; + update.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => { + insertedText += inserted.toString(); + }); + if (insertedText.endsWith(".")) { + startCompletion(update.view); + } } } if (update.selectionSet || update.docChanged) { @@ -1586,6 +1611,16 @@ onMounted(async () => { if (editorIsActive) emitEditorSelection(latestSelection); return false; }, + compositionstart() { + imeCompositionActive = true; + completionEpoch++; + return false; + }, + compositionend() { + imeCompositionActive = false; + window.setTimeout(flushImeComposition, 0); + return false; + }, wheel(event) { if (!event.metaKey && !event.ctrlKey) return false; event.preventDefault(); @@ -1736,6 +1771,7 @@ watch( () => props.modelValue, (val) => { if (view.value && val !== view.value.state.doc.toString()) { + if (isEditorComposing(view.value)) return; view.value.dispatch({ changes: { from: 0, to: view.value.state.doc.length, insert: val }, }); diff --git a/apps/desktop/src/lib/sqlCompletion.ts b/apps/desktop/src/lib/sqlCompletion.ts index 68f1ae78e..fa2cb23ad 100644 --- a/apps/desktop/src/lib/sqlCompletion.ts +++ b/apps/desktop/src/lib/sqlCompletion.ts @@ -1041,6 +1041,7 @@ export function buildSqlCompletionItemsFromContext( } export function shouldAutoOpenSqlCompletion(sql: string, cursor: number): boolean { + if (isSqlCommentContext(sql, cursor)) return false; const previousChar = sql[cursor - 1]; if (!previousChar) return false; if (/\bon\s+$/i.test(sql.slice(0, cursor))) return true; @@ -1053,6 +1054,82 @@ export function shouldAutoOpenSqlCompletion(sql: string, cursor: number): boolea return /[\w$.@]/.test(previousChar); } +export function isSqlCommentContext(sql: string, cursor: number): boolean { + const end = Math.max(0, Math.min(cursor, sql.length)); + let inSingleQuote = false; + let inDoubleQuote = false; + let inBacktick = false; + let inBracket = false; + let inLineComment = false; + let inBlockComment = false; + + for (let index = 0; index < end; index += 1) { + const ch = sql[index] ?? ""; + const next = sql[index + 1] ?? ""; + + if (inLineComment) { + if (ch === "\n" || ch === "\r") inLineComment = false; + continue; + } + if (inBlockComment) { + if (ch === "*" && next === "/") { + inBlockComment = false; + index += 1; + } + continue; + } + + if (inSingleQuote) { + if (ch === "\\" && next) { + index += 1; + } else if (ch === "'" && next === "'") { + index += 1; + } else if (ch === "'") { + inSingleQuote = false; + } + continue; + } + if (inDoubleQuote) { + if (ch === "\\" && next) { + index += 1; + } else if (ch === '"' && next === '"') { + index += 1; + } else if (ch === '"') { + inDoubleQuote = false; + } + continue; + } + if (inBacktick) { + if (ch === "`") inBacktick = false; + continue; + } + if (inBracket) { + if (ch === "]") inBracket = false; + continue; + } + + if (ch === "-" && next === "-") { + inLineComment = true; + index += 1; + } else if (ch === "#") { + inLineComment = true; + } else if (ch === "/" && next === "*") { + inBlockComment = true; + index += 1; + } else if (ch === "'") { + inSingleQuote = true; + } else if (ch === '"') { + inDoubleQuote = true; + } else if (ch === "`") { + inBacktick = true; + } else if (ch === "[") { + inBracket = true; + } + } + + return inLineComment || inBlockComment; +} + export function isSqlLikeCompletionStatement(sql: string, cursor: number): boolean { const statement = extractStatementAt(sql, cursor).trimStart(); if (/^(select|with)\b/i.test(statement)) return true; diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 217a76fd1..a019210a4 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -4,6 +4,7 @@ import { buildSqlCompletionItems, getSqlFunctionSignatureHelp, getSqlCompletionResultValidFor, + isSqlCommentContext, shouldAutoOpenSqlCompletion, extractCteDefinitions, getSqlCompletionContext, @@ -520,6 +521,22 @@ test("does not auto-open completion after structural punctuation", () => { } }); +test("does not auto-open completion inside SQL comments", () => { + for (const { sql, cursor } of [ + { sql: "-- sougou", cursor: "-- sougou".length }, + { sql: "select 1 -- sougou", cursor: "select 1 -- sougou".length }, + { sql: "# sougou", cursor: "# sougou".length }, + { sql: "select /* sougou */ 1", cursor: "select /* sougou".length }, + { sql: "select /* sougou", cursor: "select /* sougou".length }, + ]) { + assert.equal(shouldAutoOpenSqlCompletion(sql, cursor), false, sql); + } + assert.equal(isSqlCommentContext("select '-- not comment' as value", "select '-- not comment'".length), false); + assert.equal(isSqlCommentContext("select /* comment */ val", "select /* comment */ val".length), false); + assert.equal(shouldAutoOpenSqlCompletion("select '-- not comment' as value", "select '-- not comment' as value".length), true); + assert.equal(shouldAutoOpenSqlCompletion("select /* comment */ val", "select /* comment */ val".length), true); +}); + test("auto-opens completion after word characters and explicit dot qualifiers", () => { for (const sql of ["sel", "select * from us", "select u."]) { assert.equal(shouldAutoOpenSqlCompletion(sql, sql.length), true, sql);