Skip to content
Merged
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
111 changes: 111 additions & 0 deletions src/tui/switch-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ interface SwitchPickerChoice {
message: string;
hint: string;
searchText: string;
index?: number;
}

interface SwitchPickerPromptInternal {
choices: SwitchPickerChoice[];
index: number;
limit: number;
alert(): unknown;
down(): unknown;
reset(...args: unknown[]): unknown;
up(): unknown;
}

interface EnquirerPromptInternal {
Expand Down Expand Up @@ -51,6 +62,102 @@ function formatPickerLabel(
return `${isCurrent ? '*' : ' '} ${branchName} ${folderName}`;
}

function getChoiceOrder(choice: SwitchPickerChoice, fallback: number): number {
return typeof choice.index === 'number' ? choice.index : fallback;
}

function isAtPickerBoundary(
choices: SwitchPickerChoice[],
index: number,
direction: 'up' | 'down'
): boolean {
const focused = choices[index];
if (!focused) {
return true;
}

const focusedOrder = getChoiceOrder(focused, index);
const boundaryOrder = choices.reduce((boundary, choice, choiceIndex) => {
const order = getChoiceOrder(choice, choiceIndex);
return direction === 'up'
? Math.min(boundary, order)
: Math.max(boundary, order);
}, focusedOrder);

return focusedOrder === boundaryOrder;
}

export function getSwitchPickerViewport<T>(
choices: T[],
selectedIndex: number,
limit: number
): { choices: T[]; index: number } {
if (choices.length === 0) {
return { choices, index: 0 };
}

const boundedSelectedIndex = Math.max(
0,
Math.min(selectedIndex, choices.length - 1)
);
const visibleLimit = Math.max(1, Math.min(limit, choices.length));
const start = Math.max(
0,
Math.min(
boundedSelectedIndex - visibleLimit + 1,
choices.length - visibleLimit
)
);

if (start === 0) {
return { choices, index: boundedSelectedIndex };
}

return {
choices: choices.slice(start).concat(choices.slice(0, start)),
index: boundedSelectedIndex - start,
};
}

export function configureBoundedSwitchPickerPrompt(
prompt: SwitchPickerPromptInternal
): void {
// Enquirer scrolls autocomplete lists by rotating choices; keep that model,
// but stop delegating once the focused original choice is at a boundary.
const reset = prompt.reset.bind(prompt);
prompt.reset = async (...args: unknown[]) => {
const result = await reset(...args);
const viewport = getSwitchPickerViewport(
prompt.choices,
prompt.index,
prompt.limit
);

prompt.choices = viewport.choices;
prompt.index = viewport.index;

return result;
};

const up = prompt.up.bind(prompt);
prompt.up = () => {
if (isAtPickerBoundary(prompt.choices, prompt.index, 'up')) {
return prompt.alert();
}

return up();
};

const down = prompt.down.bind(prompt);
prompt.down = () => {
if (isAtPickerBoundary(prompt.choices, prompt.index, 'down')) {
return prompt.alert();
}

return down();
};
}

export async function cancelPromptSilently(
this: EnquirerPromptInternal
): Promise<void> {
Expand Down Expand Up @@ -110,6 +217,10 @@ export async function pickSwitchWorktreePath(

const initial = rows.findIndex((row) => row.isCurrent);
const enquirer = new Enquirer<{ selected: string }>();
enquirer.on('prompt', (prompt: SwitchPickerPromptInternal) => {
configureBoundedSwitchPickerPrompt(prompt);
});

const promptOptions = {
type: 'autocomplete',
name: 'selected',
Expand Down
137 changes: 136 additions & 1 deletion test/tui/switch-picker.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,40 @@
import { describe, expect, test, vi } from 'vitest';

import { cancelPromptSilently } from '@/tui/switch-picker';
import {
cancelPromptSilently,
configureBoundedSwitchPickerPrompt,
getSwitchPickerViewport,
} from '@/tui/switch-picker';

interface FakeChoice {
index: number;
hint: string;
message: string;
name: string;
searchText: string;
value: string;
}

interface FakePrompt {
choices: FakeChoice[];
index: number;
limit: number;
alert(): unknown;
down(): unknown;
reset(...args: unknown[]): unknown;
up(): unknown;
}

function createChoices(length: number): FakeChoice[] {
return Array.from({ length }, (_, index) => ({
index,
hint: String(index),
message: String(index),
name: String(index),
searchText: String(index),
value: String(index),
}));
}

function createReadlineClosedError(): Error & { code: string } {
const error = new Error('readline was closed') as Error & { code: string };
Expand All @@ -9,6 +43,107 @@ function createReadlineClosedError(): Error & { code: string } {
}

describe('switch picker', () => {
test('shows an initial selection beyond the first rendered window', () => {
const choices = createChoices(15);
const viewport = getSwitchPickerViewport(choices, 12, 10);

expect(viewport.choices.slice(0, 10).map((choice) => choice.index)).toEqual(
[3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
);
expect(viewport.index).toBe(9);
expect(viewport.choices[viewport.index].index).toBe(12);
});

test('keeps an initial selection that is already visible in place', () => {
const choices = createChoices(15);
const viewport = getSwitchPickerViewport(choices, 4, 10);

expect(viewport.choices).toBe(choices);
expect(viewport.index).toBe(4);
});

test('positions the initial selection using the actual rendered limit', async () => {
const prompt: FakePrompt = {
choices: createChoices(20),
index: 12,
limit: 5,
alert: vi.fn(),
down: vi.fn(),
reset: vi.fn(),
up: vi.fn(),
};

configureBoundedSwitchPickerPrompt(prompt);
await prompt.reset();

expect(prompt.choices.slice(0, 5).map((choice) => choice.index)).toEqual([
8, 9, 10, 11, 12,
]);
expect(prompt.index).toBe(4);
});

test('blocks up navigation at the first matching choice', () => {
const alert = vi.fn();
const up = vi.fn();
const prompt: FakePrompt = {
choices: createChoices(12),
index: 0,
limit: 10,
alert,
down: vi.fn(),
reset: vi.fn(),
up,
};

configureBoundedSwitchPickerPrompt(prompt);
prompt.up();

expect(alert).toHaveBeenCalledTimes(1);
expect(up).not.toHaveBeenCalled();
});

test('blocks down navigation at the last matching choice', () => {
const choices = createChoices(12);
const alert = vi.fn();
const down = vi.fn();
const prompt: FakePrompt = {
choices: choices.slice(2).concat(choices.slice(0, 2)),
index: 9,
limit: 10,
alert,
down,
reset: vi.fn(),
up: vi.fn(),
};

configureBoundedSwitchPickerPrompt(prompt);
prompt.down();

expect(alert).toHaveBeenCalledTimes(1);
expect(down).not.toHaveBeenCalled();
});

test('keeps scrolling when the visible edge is not the list boundary', () => {
const choices = createChoices(12);
const alert = vi.fn();
const up = vi.fn();
const prompt: FakePrompt = {
choices: choices.slice(2).concat(choices.slice(0, 2)),
index: 0,
limit: 10,
alert,
down: vi.fn(),
reset: vi.fn(),
up,
};

configureBoundedSwitchPickerPrompt(prompt);
prompt.up();

expect(alert).not.toHaveBeenCalled();
expect(up).toHaveBeenCalledTimes(1);
});

test('silently cancels when Enquirer readline cleanup has already closed', async () => {
const stop = vi.fn(() => {
throw createReadlineClosedError();
Expand Down