fix(editor): preserve IME composition in comments

This commit is contained in:
t8y2 2026-06-11 22:55:16 +08:00
parent a017e04d4c
commit c9d655324d
3 changed files with 138 additions and 8 deletions

View File

@ -181,6 +181,8 @@ let semanticDiagnosticTimer: ReturnType<typeof setTimeout> | 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<typeof getSqlCompletionContext>, 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 },
});

View File

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

View File

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