fix(editor): wait for pending completions on Tab
This commit is contained in:
parent
3a37d72a42
commit
fe10790bb3
|
|
@ -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<typeof setTimeout> | null = null;
|
||||
let setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType<SqlSemanticDiagnostic[]> | 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++;
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
interface MockView {
|
||||
state: MockState;
|
||||
dispatch: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
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<typeof setTimeout> | 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue