diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 7e447c045f..d05d1a2655 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -772,6 +772,7 @@ function editorIndentUnit(): string { } function handleTab(view: EditorViewType): boolean { + if (view.state.selection.ranges.some((range) => !range.empty)) return codeMirrorIndentMore?.(view) ?? false; if (tabKeyAcceptsCompletion()) { return acceptCompletionOrNextSnippetField(view) || performNormalTab(view); } @@ -795,8 +796,8 @@ function handleTabWithoutAcceptingCompletion(view: EditorViewType): boolean { function performNormalTab(view: EditorViewType): boolean { const { state, dispatch } = view; + if (state.selection.ranges.some((range) => !range.empty)) return codeMirrorIndentMore?.(view) ?? false; const sel = state.selection.main; - if (!sel.empty) return codeMirrorIndentMore?.(view) ?? false; const line = state.doc.lineAt(sel.from); const before = line.text.slice(0, sel.from - line.from); if (/^\s*$/.test(before)) return codeMirrorIndentMore?.(view) ?? false; @@ -1974,9 +1975,14 @@ function extendQueryEditorSelectionForView(currentView: EditorViewType): boolean } function acceptCompletionOrNextSnippetField(view: EditorViewType): boolean { - const completionStatus = codeMirrorCompletionStatus?.(view.state) ?? null; - if (completionStatus === "active" && (codeMirrorAcceptCompletion?.(view) ?? false)) return true; - if (completionStatus) return waitForCompletionTab(view); + // Any non-empty selection range means Tab is being used for block indent, + // not word completion. A completion popup can still appear as a side effect + // of the indent edit itself, so it must never hijack this or a following Tab. + if (view.state.selection.ranges.every((range) => range.empty)) { + const completionStatus = codeMirrorCompletionStatus?.(view.state) ?? null; + if (completionStatus === "active" && (codeMirrorAcceptCompletion?.(view) ?? false)) return true; + if (completionStatus) return waitForCompletionTab(view); + } return codeMirrorNextSnippetField?.(view) ?? false; } @@ -1989,13 +1995,13 @@ function clearPendingCompletionTab() { function waitForCompletionTab(view: EditorViewType): boolean { clearPendingCompletionTab(); const initialDoc = view.state.doc; - const initialSelection = view.state.selection.main; + const initialSelectionRanges = view.state.selection.ranges.map((range) => ({ anchor: range.anchor, head: range.head })); 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 selectionRanges = view.state.selection.ranges; + if (view.state.doc !== initialDoc || selectionRanges.length !== initialSelectionRanges.length || selectionRanges.some((range, index) => !range.empty || range.anchor !== initialSelectionRanges[index]?.anchor || range.head !== initialSelectionRanges[index]?.head)) return; const completionStatus = codeMirrorCompletionStatus?.(view.state) ?? null; if (completionStatus === "active" && (codeMirrorAcceptCompletion?.(view) ?? false)) return; diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts index bc99cd5b90..54f49f8ee4 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts @@ -35,7 +35,7 @@ interface MockState { doc: { lineAt: (position: number) => { from: number; text: string }; }; - selection: { main: MockSelection }; + selection: { main: MockSelection; ranges: MockSelection[] }; replaceSelection: ReturnType; update: ReturnType; } @@ -117,19 +117,27 @@ function createHarness(options: { ) as TabHarness; } -function createView(text = "SELECT", position = text.length): MockView { - const selection: MockSelection = { anchor: position, head: position, from: position, empty: true }; +function createView(text = "SELECT", position = text.length, selectionOverrides: Partial = {}, additionalRanges: MockSelection[] = []): MockView { + const selection: MockSelection = { anchor: position, head: position, from: position, empty: true, ...selectionOverrides }; const state: MockState = { doc: { lineAt: () => ({ from: 0, text }), }, - selection: { main: selection }, + selection: { main: selection, ranges: [selection, ...additionalRanges] }, replaceSelection: vi.fn((insert: string) => ({ insert })), update: vi.fn((change: unknown, options: unknown) => ({ change, options })), }; return { state, dispatch: vi.fn() }; } +function createMultiLineSelectionView(text = "SELECT 1\nSELECT 2"): MockView { + return createView(text, 0, { anchor: 0, head: text.length, from: 0, empty: false }); +} + +function createMixedMultiRangeView(text = "SELECT 1\nSELECT 2"): MockView { + return createView(text, text.length, {}, [{ anchor: 0, head: 6, from: 0, empty: false }]); +} + afterEach(() => { vi.useRealTimers(); }); @@ -281,6 +289,67 @@ describe("QueryEditor completion Tab keymap", () => { expect(acceptCompletion).toHaveBeenCalledWith(view); }); + it("keeps indenting a multi-line selection instead of letting a completion popup hijack Tab (#5419)", () => { + const acceptCompletion = vi.fn(() => true); + const indentMore = vi.fn(() => true); + const harness = createHarness({ completionStatus: () => "active", acceptCompletion, indentMore }); + const view = createMultiLineSelectionView(); + + expect(harness.handleTab(view)).toBe(true); + expect(indentMore).toHaveBeenCalledWith(view); + expect(acceptCompletion).not.toHaveBeenCalled(); + }); + + it("does not let a pending completion hijack a second Tab press while indenting a selection (#5419)", async () => { + vi.useFakeTimers(); + let status: "active" | "pending" | null = "pending"; + const acceptCompletion = vi.fn(() => true); + const indentMore = vi.fn(() => true); + const harness = createHarness({ completionStatus: () => status, acceptCompletion, indentMore }); + const view = createMultiLineSelectionView(); + + expect(harness.handleTab(view)).toBe(true); + status = "active"; + await vi.advanceTimersByTimeAsync(32); + + expect(indentMore).toHaveBeenCalledWith(view); + expect(acceptCompletion).not.toHaveBeenCalled(); + }); + + it("indents mixed multi-range selections instead of accepting an active completion", () => { + const completionStatus = vi.fn(() => "active" as const); + const acceptCompletion = vi.fn(() => true); + const nextSnippetField = vi.fn(() => true); + const indentMore = vi.fn(() => true); + const harness = createHarness({ completionStatus, acceptCompletion, nextSnippetField, indentMore }); + const view = createMixedMultiRangeView(); + + expect(harness.handleTab(view)).toBe(true); + expect(indentMore).toHaveBeenCalledWith(view); + expect(completionStatus).not.toHaveBeenCalled(); + expect(acceptCompletion).not.toHaveBeenCalled(); + expect(nextSnippetField).not.toHaveBeenCalled(); + expect(view.state.replaceSelection).not.toHaveBeenCalled(); + }); + + it("does not accept or wait for completion when a mixed multi-range selection is active", async () => { + vi.useFakeTimers(); + let status: "active" | "pending" | null = "pending"; + const completionStatus = vi.fn(() => status); + const acceptCompletion = vi.fn(() => true); + const harness = createHarness({ completionStatus, acceptCompletion }); + const view = createMixedMultiRangeView(); + + expect(harness.acceptCompletionOrNextSnippetField(view)).toBe(false); + status = "active"; + await vi.advanceTimersByTimeAsync(32); + + expect(completionStatus).not.toHaveBeenCalled(); + expect(acceptCompletion).not.toHaveBeenCalled(); + expect(view.dispatch).not.toHaveBeenCalled(); + expect(view.state.replaceSelection).not.toHaveBeenCalled(); + }); + it("falls back to normal Tab when pending completion has no candidate", async () => { vi.useFakeTimers(); let status: "active" | "pending" | null = "pending";