Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion apps/desktop/src/components/editor/QueryEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ let buildSqlSemanticHighlightExtension: (() => import("@codemirror/state").Exten
let codeMirrorSnippetCompletion: typeof import("@codemirror/autocomplete").snippetCompletion;
let codeMirrorCompletionStatus: typeof import("@codemirror/autocomplete").completionStatus | null = null;
let codeMirrorAcceptCompletion: typeof import("@codemirror/autocomplete").acceptCompletion | null = null;
let codeMirrorSelectedCompletion: typeof import("@codemirror/autocomplete").selectedCompletion | 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;
Expand Down Expand Up @@ -1620,6 +1621,31 @@ function acceptCompletionOrNextSnippetField(view: EditorViewType): boolean {
return false;
}

function acceptSqlServerCompletionOnSpace(view: EditorViewType): boolean {
if (props.databaseType !== "sqlserver" || codeMirrorCompletionStatus?.(view.state) !== "active") return false;
const completionType = codeMirrorSelectedCompletion?.(view.state)?.type;
if (completionType !== "keyword" && completionType !== "table" && completionType !== "column") return false;
if (!(codeMirrorAcceptCompletion?.(view) ?? false)) return false;

const selection = view.state.selection.main;
if (!selection.empty) return true;
const cursor = selection.head;
const previousCharacter = cursor > 0 ? view.state.sliceDoc(cursor - 1, cursor) : "";
if (/\s/.test(previousCharacter)) return true;

const nextCharacter = view.state.sliceDoc(cursor, cursor + 1);
if (/\s/.test(nextCharacter)) {
view.dispatch({ selection: { anchor: cursor + 1 }, scrollIntoView: true });
} else {
view.dispatch({
changes: { from: cursor, insert: " " },
selection: { anchor: cursor + 1 },
scrollIntoView: true,
});
}
return true;
}

function clearPendingCompletionTab() {
if (pendingCompletionTabTimer === null) return;
clearTimeout(pendingCompletionTabTimer);
Expand Down Expand Up @@ -3930,7 +3956,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, closeCompletion },
{ autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField, closeCompletion, selectedCompletion },
{ 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 },
Expand Down Expand Up @@ -3964,6 +3990,7 @@ onMounted(async () => {
setSqlDiagnosticsEffect = StateEffect.define<SqlSemanticDiagnostic[]>();
codeMirrorCompletionStatus = completionStatus;
codeMirrorAcceptCompletion = acceptCompletion;
codeMirrorSelectedCompletion = selectedCompletion;
codeMirrorCloseCompletion = closeCompletion;
codeMirrorStartCompletion = startCompletion;
codeMirrorInsertCompletionText = insertCompletionText;
Expand Down Expand Up @@ -4443,6 +4470,7 @@ onMounted(async () => {
Prec.highest(
keymap.of([
{ key: "'", run: handleSqlSingleQuote },
{ key: "Space", run: acceptSqlServerCompletionOnSpace },
{ key: "Tab", run: handleTab },
{
key: "Escape",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface MockState {
doc: {
lineAt: (position: number) => { from: number; text: string };
};
sliceDoc: (from: number, to: number) => string;
selection: { main: MockSelection };
replaceSelection: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
Expand All @@ -47,10 +48,18 @@ interface MockView {
interface TabHarness {
handleTab: (view: MockView) => boolean;
acceptCompletionOrNextSnippetField: (view: MockView) => boolean;
acceptSqlServerCompletionOnSpace: (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 {
function createHarness(options: {
databaseType?: string;
completionStatus: (state: MockState) => "active" | "pending" | null;
selectedCompletion?: (state: MockState) => { type?: string } | 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"),
Expand All @@ -61,14 +70,26 @@ function createHarness(options: { completionStatus: (state: MockState) => "activ
extractFunction("handleTab"),
extractFunction("performNormalTab"),
extractFunction("acceptCompletionOrNextSnippetField"),
extractFunction("acceptSqlServerCompletionOnSpace"),
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;
const factory = new Function(
"props",
"codeMirrorCompletionStatus",
"codeMirrorSelectedCompletion",
"codeMirrorAcceptCompletion",
"codeMirrorNextSnippetField",
"codeMirrorIndentMore",
"settingsStore",
`${javascript}\nreturn { handleTab, acceptCompletionOrNextSnippetField, acceptSqlServerCompletionOnSpace, clearPendingCompletionTab };`,
);
return factory({ databaseType: options.databaseType }, options.completionStatus, options.selectedCompletion ?? (() => ({ type: "column" })), 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 {
Expand All @@ -77,6 +98,7 @@ function createView(text = "SELECT", position = text.length): MockView {
doc: {
lineAt: () => ({ from: 0, text }),
},
sliceDoc: (from, to) => text.slice(from, to),
selection: { main: selection },
replaceSelection: vi.fn((insert: string) => ({ insert })),
update: vi.fn((change: unknown, options: unknown) => ({ change, options })),
Expand Down Expand Up @@ -200,3 +222,64 @@ describe("QueryEditor completion Tab keymap", () => {
expect(view.dispatch).toHaveBeenCalledOnce();
});
});

describe("QueryEditor SQL Server completion Space keymap", () => {
it.each(["keyword", "table", "column"])("accepts an active %s completion", (type) => {
const acceptCompletion = vi.fn(() => true);
const harness = createHarness({
databaseType: "sqlserver",
completionStatus: () => "active",
selectedCompletion: () => ({ type }),
acceptCompletion,
});
const view = createView("FXXX ");

expect(harness.acceptSqlServerCompletionOnSpace(view)).toBe(true);
expect(acceptCompletion).toHaveBeenCalledWith(view);
expect(view.dispatch).not.toHaveBeenCalled();
});

it("inserts the typed space when automatic completion spacing is disabled", () => {
const harness = createHarness({ databaseType: "sqlserver", completionStatus: () => "active", acceptCompletion: () => true });
const view = createView("FXXX");

expect(harness.acceptSqlServerCompletionOnSpace(view)).toBe(true);
expect(view.dispatch).toHaveBeenCalledWith({
changes: { from: 4, insert: " " },
selection: { anchor: 5 },
scrollIntoView: true,
});
});

it("moves over an existing following space instead of duplicating it", () => {
const harness = createHarness({ databaseType: "sqlserver", completionStatus: () => "active", acceptCompletion: () => true });
const view = createView("FXXX ", 4);

expect(harness.acceptSqlServerCompletionOnSpace(view)).toBe(true);
expect(view.dispatch).toHaveBeenCalledWith({ selection: { anchor: 5 }, scrollIntoView: true });
});

it.each([
{ databaseType: "postgresql", status: "active", completionType: "column" },
{ databaseType: "sqlserver", status: "pending", completionType: "column" },
{ databaseType: "sqlserver", status: "active", completionType: "function" },
{ databaseType: "sqlserver", status: "active", completionType: "snippet" },
])("keeps ordinary Space input for $databaseType/$status/$completionType", ({ databaseType, status, completionType }) => {
const acceptCompletion = vi.fn(() => true);
const harness = createHarness({
databaseType,
completionStatus: () => status as "active" | "pending",
selectedCompletion: () => ({ type: completionType }),
acceptCompletion,
});

expect(harness.acceptSqlServerCompletionOnSpace(createView())).toBe(false);
expect(acceptCompletion).not.toHaveBeenCalled();
});

it("keeps ordinary Space input when CodeMirror cannot accept the selected completion", () => {
const harness = createHarness({ databaseType: "sqlserver", completionStatus: () => "active", acceptCompletion: () => false });

expect(harness.acceptSqlServerCompletionOnSpace(createView())).toBe(false);
});
});
Loading