fix(editor): handle mixed multi-range Tab indentation

This commit is contained in:
Melody Ruch authored and GitHub committed 2026-08-19 15:15:57 +08:00
1 parent f4f894d979
commit 45819df9e3
2 files changed
+86 -11

No files matched your search

@@ -35,7 +35,7 @@ interface MockState {
doc: {
lineAt: (position: number) => { from: number; text: string };
};
selection: { main: MockSelection };
selection: { main: MockSelection; ranges: MockSelection[] };
replaceSelection: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
}
@@ -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<MockSelection> = {}, 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";