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
8 changes: 4 additions & 4 deletions .talismanrc
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ fileignoreconfig:
ignore_detectors:
- filename
- filename: src/environments/environment.update.prompt.test.ts
checksum: b83aa8da125e6a45b455cf03c50fe82cc41890343be9be0c2ccf0a01d09bd160
checksum: 6653e245baac00cef348acfc0aa2f32d03a7ff63ca3c320ee56a0c6250775004
ignore_detectors:
- filename
- filename: src/environments/environment.presenter.ts
Expand Down Expand Up @@ -228,7 +228,7 @@ fileignoreconfig:
- "a project name passed on argv to the project resolver"
- "expect\\(Object.keys\\(DEPENDENCIES\\)\\).toEqual\\(\\['project', 'env'\\]\\)"
- filename: AGENTS.md
checksum: d46db51dcf83e6db92f5588c1649f0ba76599ccb9a65aca540b19dc0a71208a4
checksum: c3276952d1eb16ea53f89363c9a70b59a19efc21fe087ea89ee70946c4b7cfc7
allowed_patterns:
- "Do not add an exemption to a guard to make a new file pass"
- "passes its own error wording to `client.request\\(req, MESSAGES\\)`"
Expand Down Expand Up @@ -314,9 +314,9 @@ fileignoreconfig:
- filename: src/environments/environment.errors.ts
checksum: 3e17db3577ba944633f97063d208bdec57c833d4b6c38e203796a9fea43baacc
- filename: src/core/prompt.ts
checksum: 3285bdbccc31cba5b50eafe8ed8be4750af6339078a3eaee6df1e0630979aa2c
checksum: e9154ec91c36f7a8e8c2f91ec13fa554cfbf5fe3b8506db44fb7c68bdef8efa8
- filename: src/core/prompt.test.ts
checksum: 2deacd402e8d7f79458ee6d3917205c6dbe5cd7e6a98a2e5c49e37ac1a9d57c4
checksum: 7be5497acf4cee870667751d44aa230e309784df76dc9e95d5ec2c4e6e6a57e0
- filename: test/integration/environments-update.test.ts
checksum: 4cd0106bf1881d373d23f063314627057d85cab0c43b793f013ffc2ca8859036
- filename: src/commands/launch/env/update.ts
Expand Down
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,8 @@ payload inline.
on an arrow or a digit and ignores every letter, so text can never become the answer and nothing
needs validating. Every choice prompt (`askOption`, and the checkbox `askChoices`) passes `loop: false`, so the
arrow stops at the last entry instead of wrapping to the first, and a `pageSize` of the choice count
capped at `PROMPT_PAGE_SIZE` (15), so a short list shows whole and a 100-entry picker scrolls in a
fixed window. `askChoices` refuses an empty selection at the prompt, naming <space>. Project type, branch, Framework preset, Response mode and Contentstack
capped at `PROMPT_PAGE_SIZE` (7), so a list of seven or fewer shows whole and anything longer - the
eleven framework presets, the env:update checklist - scrolls seven at a time in a fixed window. `askChoices` refuses an empty selection at the prompt, naming <space>. Project type, branch, Framework preset, Response mode and Contentstack
Authentication are all asked this way. Prefer it: the `❯` always shows what Enter will take, where a
search box hides the list the moment the filter matches nothing. The branch picker is on this side
despite fetching `PICKER_PAGE_SIZE` branches, because `noteTruncation` already prints the escape
Expand All @@ -407,6 +407,10 @@ answers with an `UnlistedAnswer`. The wrapper installs `refuseUnlisted` over `th
its constructor, so that answer never leaves the prompt: inquirer renders `"my-site" is not one of
the options. Pick one from the list.` and asks again. A caller's own `validate` is wrapped, not
replaced, and still sees every listed answer.
The same wrapper makes the pickers scroll like the lists: `PROMPT_PAGE_SIZE` rows at a time, and the
arrow stops at the first and last entry. Upstream, the up and down arrow handlers wrap round and the
bundled inquirer 3 paginator repeats the list three times to look endless, so the wrapper overrides both
arrow handlers and swaps the paginator for `scrollWindow`, a window that stops at both ends.

Together these are the prompt-side twin of `oneOf` on the flag side (`--cs-auth yes` has always been
exit 2); the two now agree, and no prompt module needs its own check.
Expand Down
17 changes: 15 additions & 2 deletions bin/dev.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
#!/usr/bin/env node_modules/.bin/ts-node
(async () => {
const oclif = await import('@oclif/core');
const { handleStandaloneFailure } = require('../dist/core/failure');

process.env.NODE_ENV = 'development';
oclif.settings.debug = true;

function failureHandler() {
for (const path of ['../src/core/failure', '../dist/core/failure']) {
try {
return require(path).handleStandaloneFailure;
} catch (error) {
if (error.code !== 'MODULE_NOT_FOUND') {
throw error;
}
}
}

return oclif.Errors.handle;
}

await oclif
.run(process.argv.slice(2), __dirname)
.then(() => oclif.flush())
.catch(handleStandaloneFailure);
.catch((err) => failureHandler()(err));
})();
8 changes: 4 additions & 4 deletions src/core/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,17 @@ describe('prompt helpers', () => {

it.each([
[3, 3],
[15, 15],
[16, 15],
[100, 15],
[7, 7],
[8, 7],
[100, 7],
])('shows %i choices %i rows at a time, so a short list is shown whole and a long one scrolls', async (count, rows) => {
const { asked, ux } = scriptedUx(['c0']);
const choices = Array.from({ length: count }, (_, index) => ({ name: `C${index}`, value: `c${index}` }));

await askOption(ux, 'Pick one', choices);

expect((asked[0] as { pageSize: number }).pageSize).toBe(rows);
expect(PROMPT_PAGE_SIZE).toBe(15);
expect(PROMPT_PAGE_SIZE).toBe(7);
});

it('cancels rather than accepting nothing at a list of options', async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/core/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export async function askOptionalText(
return text === '' ? undefined : text;
}

export const PROMPT_PAGE_SIZE = 15;
export const PROMPT_PAGE_SIZE = 7;

function scrolling(choices: Choice[]): { loop: false; pageSize: number } {
return { loop: false, pageSize: Math.min(choices.length, PROMPT_PAGE_SIZE) };
Expand Down
120 changes: 117 additions & 3 deletions src/core/search-list.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { PROMPT_PAGE_SIZE } from './prompt';
import type { SearchListClass } from './search-list';
import {
MORE_CHOICES_HINT,
UnlistedAnswer,
answerFor,
launchPaginated,
launchSearchList,
refuseUnlisted,
registerSearchList,
scrollWindow,
startingPointer,
unlistedMessage,
utilitiesLoader,
Expand All @@ -14,8 +18,12 @@ const load = utilitiesLoader();
const SearchList = load('inquirer-search-list') as SearchListClass;

interface Prompt {
opt: { validate: (value: unknown, answers?: unknown) => unknown };
opt: { validate: (value: unknown, answers?: unknown) => unknown; pageSize?: number };
pointer: number;
paginator: { paginate: unknown };
render(): void;
onDownKey(): void;
onUpKey(): void;
rl: { line: string };
selected: unknown;
filterChoices(): void;
Expand Down Expand Up @@ -73,6 +81,56 @@ describe('launchSearchList', () => {
expect(prompt.getCurrentValue()).toBe('Gatsby');
});

it('stops at the last choice instead of wrapping round to the first', () => {
const prompt = open({ default: 'Other' });
prompt.render = jest.fn();

prompt.onDownKey();

expect(prompt.pointer).toBe(2);
expect(prompt.render).toHaveBeenCalled();
});

it('stops at the first choice instead of wrapping round to the last', () => {
const prompt = open({});
prompt.render = jest.fn();

prompt.onUpKey();

expect(prompt.pointer).toBe(0);
});

it('still moves between choices inside the list', () => {
const prompt = open({});
prompt.render = jest.fn();

prompt.onDownKey();
prompt.onDownKey();
prompt.onUpKey();

expect(prompt.pointer).toBe(1);
});

it('stops at the last match of what was typed', () => {
const prompt = typed(open({}), 'Oth');
prompt.render = jest.fn();

prompt.onDownKey();

expect(prompt.pointer).toBe(0);
});

it('shows PROMPT_PAGE_SIZE choices at a time through a window that does not repeat the list', () => {
const prompt = open({});

expect(prompt.opt.pageSize).toBe(PROMPT_PAGE_SIZE);
expect(prompt.paginator.paginate).toBe(scrollWindow);
});

it('keeps a page size the question sets itself', () => {
expect(open({ pageSize: 3 }).opt.pageSize).toBe(3);
});

it('submits the highlighted match of what was typed', () => {
const prompt = typed(open({}), 'Oth');

Expand Down Expand Up @@ -162,6 +220,56 @@ describe('launchSearchList', () => {
});
});

describe('scrollWindow', () => {
const LINES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].join('\n');
const HINT = '\u001b[2m(Use ↑/↓ to see more options)\u001b[22m';
const shown = (active: number) => scrollWindow(LINES, active, 5).split('\n');

it('shows a list that fits the page whole, with no hint', () => {
expect(scrollWindow('a\nb\nc', 2, 5)).toBe('a\nb\nc');
});

it('starts at the top of a longer list and says there are more', () => {
expect(shown(0)).toEqual(['a', 'b', 'c', 'd', 'e', HINT]);
});

it('keeps the highlight in the middle while scrolling down', () => {
expect(shown(4)).toEqual(['c', 'd', 'e', 'f', 'g', HINT]);
});

it('ends at the last choice and never shows the first one again after it', () => {
expect(shown(7)).toEqual(['d', 'e', 'f', 'g', 'h', HINT]);
});
});

describe('launchPaginated', () => {
interface Paginated {
paginator: { paginate(output: string, active: number): string };
}

class Upstream {
paginator = {
paginate: (output: string) => `${output}\n\u001b[2m(Move up and down to reveal more choices)\u001b[22m`,
};
}

it('points at the up and down arrows instead of the upstream wording', () => {
const Prompt = launchPaginated(Upstream as never) as unknown as new () => Paginated;

expect(new Prompt().paginator.paginate('a\nb', 0)).toBe(`a\nb\n\u001b[2m${MORE_CHOICES_HINT}\u001b[22m`);
expect(MORE_CHOICES_HINT).toBe('(Use ↑/↓ to see more options)');
});

it('leaves a page without the hint untouched', () => {
class Short {
paginator = { paginate: (output: string) => output };
}
const Prompt = launchPaginated(Short as never) as unknown as new () => Paginated;

expect(new Prompt().paginator.paginate('a\nb', 0)).toBe('a\nb');
});
});

describe('answerFor', () => {
it('answers with the value of the choice whose name was typed in full', () => {
expect(answerFor(CHOICES, 'NextJs')).toBe('NextJs');
Expand Down Expand Up @@ -224,8 +332,8 @@ describe('registerSearchList', () => {

registerSearchList(fakeLoad);

expect(loaded).toEqual(['inquirer', 'inquirer-search-list']);
expect(registered).toHaveLength(1);
expect(loaded).toEqual(['inquirer', 'inquirer-search-list', 'inquirer/lib/prompts/list', 'inquirer/lib/prompts/checkbox']);
expect(registered.map(([name]) => name)).toEqual(['search-list', 'list', 'checkbox']);
expect(registered[0][0]).toBe('search-list');
expect((registered[0][1] as SearchListClass).prototype).toBeInstanceOf(SearchList);
expect(registered[0][1]).not.toBe(SearchList);
Expand All @@ -237,5 +345,11 @@ describe('registerSearchList', () => {
registerSearchList();

expect((inquirer.prompt.prompts['search-list'] as SearchListClass).prototype).toBeInstanceOf(SearchList);
for (const type of ['list', 'checkbox']) {
const Upstream = load(`inquirer/lib/prompts/${type}`) as SearchListClass;

expect((inquirer.prompt.prompts[type] as SearchListClass).prototype).toBeInstanceOf(Upstream);
expect(inquirer.prompt.prompts[type]).not.toBe(Upstream);
}
});
});
58 changes: 57 additions & 1 deletion src/core/search-list.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { createRequire } from 'node:module';

import { PROMPT_PAGE_SIZE } from './prompt';
import { styled } from './style';

export interface SearchChoice {
name?: string;
value: unknown;
Expand All @@ -8,11 +11,13 @@ export interface SearchChoice {
export type Validator = (value: unknown, answers?: unknown) => unknown;

export interface SearchListPrompt {
opt: { default?: unknown; validate: Validator };
opt: { default?: unknown; validate: Validator; pageSize?: number };
rl: { line: string };
pointer: number;
list: SearchChoice[];
filterList: SearchChoice[];
paginator: Paginator;
render(): void;
getCurrentValue(line?: unknown): unknown;
}

Expand Down Expand Up @@ -51,12 +56,59 @@ export function startingPointer(list: SearchChoice[], initial: unknown): number
return Math.max(index, 0);
}

export const MORE_CHOICES_HINT = '(Use ↑/↓ to see more options)';

const UPSTREAM_HINT = '(Move up and down to reveal more choices)';

interface Paginator {
paginate(output: string, active: number, pageSize?: number): string;
}

export type PaginatedClass = new (...params: never[]) => { paginator: Paginator };

export function launchPaginated(Prompt: PaginatedClass): PaginatedClass {
return class LaunchPaginated extends Prompt {
constructor(...params: never[]) {
super(...params);
const paginate = this.paginator.paginate.bind(this.paginator);
this.paginator.paginate = (output, active, pageSize) =>
paginate(output, active, pageSize).replace(UPSTREAM_HINT, MORE_CHOICES_HINT);
}
};
}

export function scrollWindow(output: string, active: number, pageSize = PROMPT_PAGE_SIZE): string {
const lines = output.split('\n');

if (lines.length <= pageSize) {
return output;
}

const top = Math.min(Math.max(active - Math.floor(pageSize / 2), 0), lines.length - pageSize);

return [...lines.slice(top, top + pageSize), styled(MORE_CHOICES_HINT, 'dim', true)].join(
'\n',
);
}

export function launchSearchList(SearchList: SearchListClass): SearchListClass {
return class LaunchSearchList extends SearchList {
constructor(...params: never[]) {
super(...params);
this.pointer = startingPointer(this.list, this.opt.default);
this.opt.validate = refuseUnlisted(this.opt.validate);
this.opt.pageSize = this.opt.pageSize ?? PROMPT_PAGE_SIZE;
this.paginator = { paginate: scrollWindow };
}

onDownKey(): void {
this.pointer = Math.min(this.pointer + 1, Math.max(this.filterList.length - 1, 0));
this.render();
}

onUpKey(): void {
this.pointer = Math.max(this.pointer - 1, 0);
this.render();
}

getCurrentValue(line?: unknown): unknown {
Expand Down Expand Up @@ -99,4 +151,8 @@ export function registerSearchList(load: ModuleLoader = utilitiesLoader()): void
const inquirer = load('inquirer') as PromptRegistry;

inquirer.registerPrompt('search-list', launchSearchList(load('inquirer-search-list') as SearchListClass));

for (const type of ['list', 'checkbox']) {
inquirer.registerPrompt(type, launchPaginated(load(`inquirer/lib/prompts/${type}`) as PaginatedClass));
}
}
2 changes: 1 addition & 1 deletion src/environments/environment.update.prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe('promptForEnvironmentUpdate', () => {
{ name: 'Password protection', value: 'passwordProtection' },
],
loop: false,
pageSize: 12,
pageSize: 7,
validate: expect.any(Function),
});
});
Expand Down
Loading