From fe10790bb33834c05ad237e71dbb402dd1aaa783 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 23 Jul 2026 18:24:37 +0800 Subject: [PATCH] fix(editor): wait for pending completions on Tab --- .../src/components/editor/QueryEditor.vue | 61 ++++++- .../queryEditorCompletionKeymap.spec.ts | 164 ++++++++++++++++++ 2 files changed, 217 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 0364af57f..1911d8809 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -119,6 +119,9 @@ const props = defineProps<{ }>(); const COMPLETION_REMOTE_LATENCY_BUDGET_MS = 120; +const COMPLETION_DEBOUNCE_DELAY_MS = 150; +const COMPLETION_TAB_RETRY_DELAY_MS = 16; +const COMPLETION_TAB_MAX_WAIT_MS = COMPLETION_DEBOUNCE_DELAY_MS + COMPLETION_REMOTE_LATENCY_BUDGET_MS + 100; // Internal rollback switch: flip to false to route completion, diagnostics, and navigation through the legacy SQL context path. const SEMANTIC_SQL_COMPLETION_ENABLED = true; @@ -338,6 +341,7 @@ let codeMirrorRedo: typeof import("@codemirror/commands").redo | null = null; let codeMirrorSelectAll: typeof import("@codemirror/commands").selectAll | null = null; let codeMirrorInsertNewlineKeepIndent: typeof import("@codemirror/commands").insertNewlineKeepIndent | null = null; let codeMirrorToggleLineComment: typeof import("@codemirror/commands").toggleLineComment | null = null; +let pendingCompletionTabTimer: ReturnType | null = null; let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType | null = null; let setPreviewRangeEffect: | import("@codemirror/state").StateEffectType<{ @@ -527,7 +531,11 @@ function editorIndentUnit(): string { } function handleTab(view: EditorViewType): boolean { - if (codeMirrorCompletionStatus?.(view.state) === "active") return false; + if (codeMirrorCompletionStatus?.(view.state)) return false; + return performNormalTab(view); +} + +function performNormalTab(view: EditorViewType): boolean { const { state, dispatch } = view; const sel = state.selection.main; if (!sel.empty) return codeMirrorIndentMore?.(view) ?? false; @@ -1337,12 +1345,48 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view") } function acceptCompletionOrNextSnippetField(view: EditorViewType): boolean { - if (codeMirrorCompletionStatus?.(view.state) && (codeMirrorAcceptCompletion?.(view) ?? false)) { - return true; - } - // Table/column completions can happen inside a CodeMirror snippet field. When - // the completion popup is gone, Tab should continue through the snippet fields. - return codeMirrorNextSnippetField?.(view) ?? false; + const completionStatus = codeMirrorCompletionStatus?.(view.state) ?? null; + if (completionStatus === "active" && (codeMirrorAcceptCompletion?.(view) ?? false)) return true; + // Snippet fields keep their normal immediate Tab priority when completion + // is pending or still inside CodeMirror's interaction delay. + if (codeMirrorNextSnippetField?.(view)) return true; + if (completionStatus) return waitForCompletionTab(view); + return false; +} + +function clearPendingCompletionTab() { + if (pendingCompletionTabTimer === null) return; + clearTimeout(pendingCompletionTabTimer); + pendingCompletionTabTimer = null; +} + +function waitForCompletionTab(view: EditorViewType): boolean { + clearPendingCompletionTab(); + const initialDoc = view.state.doc; + const initialSelection = view.state.selection.main; + const startedAt = Date.now(); + + const retry = () => { + pendingCompletionTabTimer = null; + const selection = view.state.selection.main; + if (view.state.doc !== initialDoc || selection.anchor !== initialSelection.anchor || selection.head !== initialSelection.head) return; + + const completionStatus = codeMirrorCompletionStatus?.(view.state) ?? null; + if (completionStatus === "active" && (codeMirrorAcceptCompletion?.(view) ?? false)) return; + if (codeMirrorNextSnippetField?.(view)) return; + if (completionStatus && Date.now() - startedAt < COMPLETION_TAB_MAX_WAIT_MS) { + pendingCompletionTabTimer = setTimeout(retry, COMPLETION_TAB_RETRY_DELAY_MS); + return; + } + + // A pending completion may resolve without any applicable option. Preserve + // snippet navigation first, then fall back to the editor's normal Tab action. + if (codeMirrorNextSnippetField?.(view)) return; + performNormalTab(view); + }; + + pendingCompletionTabTimer = setTimeout(retry, COMPLETION_TAB_RETRY_DELAY_MS); + return true; } function wordWrapExtension() { @@ -2513,7 +2557,7 @@ async function provideSqlCompletions(context: CompletionContext) { } catch { resolve(localResult); } - }, 150); + }, COMPLETION_DEBOUNCE_DELAY_MS); }); } catch { return null; @@ -4108,6 +4152,7 @@ function pauseQueryEditorBackgroundWork() { flushEditorViewport(); flushEditorSelection(); clearTableNavigationHover(); + clearPendingCompletionTab(); editorIsActive = false; clearScheduledSemanticDiagnostics(); completionEpoch++; diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts new file mode 100644 index 000000000..d815b97cb --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts @@ -0,0 +1,164 @@ +import { readFileSync } from "node:fs"; +import ts from "typescript"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8"); + +function extractFunction(name: string): string { + const start = queryEditorSource.indexOf(`function ${name}(`); + if (start < 0) throw new Error(`Missing QueryEditor function: ${name}`); + const bodyStart = queryEditorSource.indexOf("{", start); + let depth = 0; + for (let index = bodyStart; index < queryEditorSource.length; index++) { + const character = queryEditorSource[index]; + if (character === "{") depth++; + if (character === "}" && --depth === 0) return queryEditorSource.slice(start, index + 1); + } + throw new Error(`Unterminated QueryEditor function: ${name}`); +} + +function extractDeclaration(pattern: RegExp, label: string): string { + const match = queryEditorSource.match(pattern); + if (!match) throw new Error(`Missing QueryEditor declaration: ${label}`); + return match[0]; +} + +interface MockSelection { + anchor: number; + head: number; + from: number; + empty: boolean; +} + +interface MockState { + doc: { + lineAt: (position: number) => { from: number; text: string }; + }; + selection: { main: MockSelection }; + replaceSelection: ReturnType; + update: ReturnType; +} + +interface MockView { + state: MockState; + dispatch: ReturnType; +} + +interface TabHarness { + handleTab: (view: MockView) => boolean; + acceptCompletionOrNextSnippetField: (view: MockView) => boolean; + clearPendingCompletionTab: () => void; +} + +function createHarness(options: { completionStatus: (state: MockState) => "active" | "pending" | null; acceptCompletion?: (view: MockView) => boolean; nextSnippetField?: (view: MockView) => boolean; indentMore?: (view: MockView) => boolean }): TabHarness { + const source = [ + extractDeclaration(/const COMPLETION_REMOTE_LATENCY_BUDGET_MS = \d+;/, "remote completion latency budget"), + extractDeclaration(/const COMPLETION_DEBOUNCE_DELAY_MS = \d+;/, "completion debounce delay"), + extractDeclaration(/const COMPLETION_TAB_RETRY_DELAY_MS = \d+;/, "completion retry delay"), + extractDeclaration(/const COMPLETION_TAB_MAX_WAIT_MS = [^;]+;/, "completion wait timeout"), + "let pendingCompletionTabTimer: ReturnType | null = null;", + extractFunction("editorIndentUnit"), + extractFunction("handleTab"), + extractFunction("performNormalTab"), + extractFunction("acceptCompletionOrNextSnippetField"), + extractFunction("clearPendingCompletionTab"), + extractFunction("waitForCompletionTab"), + ].join("\n"); + const javascript = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.None, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const factory = new Function("codeMirrorCompletionStatus", "codeMirrorAcceptCompletion", "codeMirrorNextSnippetField", "codeMirrorIndentMore", "settingsStore", `${javascript}\nreturn { handleTab, acceptCompletionOrNextSnippetField, clearPendingCompletionTab };`); + return factory(options.completionStatus, options.acceptCompletion ?? (() => false), options.nextSnippetField ?? (() => false), options.indentMore ?? (() => false), { editorSettings: { sqlFormatter: { useTabs: false, tabWidth: 2 } } }) as TabHarness; +} + +function createView(text = "SELECT", position = text.length): MockView { + const selection: MockSelection = { anchor: position, head: position, from: position, empty: true }; + const state: MockState = { + doc: { + lineAt: () => ({ from: 0, text }), + }, + selection: { main: selection }, + replaceSelection: vi.fn((insert: string) => ({ insert })), + update: vi.fn((change: unknown, options: unknown) => ({ change, options })), + }; + return { state, dispatch: vi.fn() }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("QueryEditor completion Tab keymap", () => { + it("keeps normal Tab indentation when completion is inactive", () => { + const harness = createHarness({ completionStatus: () => null }); + const view = createView(); + + expect(harness.handleTab(view)).toBe(true); + expect(view.state.replaceSelection).toHaveBeenCalledWith(" "); + expect(view.dispatch).toHaveBeenCalledOnce(); + }); + + it("keeps snippet-field navigation when no completion is open", () => { + const nextSnippetField = vi.fn(() => true); + const harness = createHarness({ completionStatus: () => null, nextSnippetField }); + const view = createView(); + + expect(harness.acceptCompletionOrNextSnippetField(view)).toBe(true); + expect(nextSnippetField).toHaveBeenCalledWith(view); + expect(view.dispatch).not.toHaveBeenCalled(); + }); + + it("advances an available snippet field immediately while completion is pending", () => { + vi.useFakeTimers(); + const nextSnippetField = vi.fn(() => true); + const indentMore = vi.fn(() => true); + const harness = createHarness({ completionStatus: () => "pending", nextSnippetField, indentMore }); + const view = createView(); + + expect(harness.acceptCompletionOrNextSnippetField(view)).toBe(true); + expect(nextSnippetField).toHaveBeenCalledWith(view); + expect(vi.getTimerCount()).toBe(0); + expect(indentMore).not.toHaveBeenCalled(); + expect(view.dispatch).not.toHaveBeenCalled(); + }); + + it("accepts an already-open completion popup", () => { + const acceptCompletion = vi.fn(() => true); + const harness = createHarness({ completionStatus: () => "active", acceptCompletion }); + const view = createView(); + + expect(harness.acceptCompletionOrNextSnippetField(view)).toBe(true); + expect(acceptCompletion).toHaveBeenCalledWith(view); + expect(view.dispatch).not.toHaveBeenCalled(); + }); + + it("waits for an immediate Tab completion that is still pending", async () => { + vi.useFakeTimers(); + let status: "active" | "pending" | null = "pending"; + const acceptCompletion = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); + const harness = createHarness({ completionStatus: () => status, acceptCompletion }); + const view = createView(); + + expect(harness.acceptCompletionOrNextSnippetField(view)).toBe(true); + status = "active"; + await vi.advanceTimersByTimeAsync(32); + + expect(acceptCompletion).toHaveBeenCalledTimes(2); + expect(acceptCompletion).toHaveBeenLastCalledWith(view); + expect(view.dispatch).not.toHaveBeenCalled(); + }); + + it("falls back to normal Tab when pending completion has no candidate", async () => { + vi.useFakeTimers(); + let status: "active" | "pending" | null = "pending"; + const harness = createHarness({ completionStatus: () => status }); + const view = createView(); + + expect(harness.acceptCompletionOrNextSnippetField(view)).toBe(true); + status = null; + await vi.advanceTimersByTimeAsync(16); + + expect(view.state.replaceSelection).toHaveBeenCalledWith(" "); + expect(view.dispatch).toHaveBeenCalledOnce(); + }); +});