Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(@clack/core,@clack/prompts): path prompt #148

Draft
wants to merge 28 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
16a3c34
feat(@clack/core): path prompt
orochaa Aug 24, 2023
f6b5042
feat(@clack/prompts): path prompt
orochaa Aug 24, 2023
a93b884
feat: free navigation
orochaa Aug 24, 2023
6a64200
chore: add changeset
orochaa Aug 24, 2023
c55a847
feat: max items
orochaa Aug 24, 2023
a1a6a94
feat: folder arrow
orochaa Aug 24, 2023
fb8262a
chore: lint
orochaa Aug 24, 2023
9395dbb
feat: only show dir option
orochaa Aug 24, 2023
c99fa86
chore: restore basic example
orochaa Aug 24, 2023
6ffd8c3
chore: add file selection example
orochaa Aug 24, 2023
ef2d7e1
fix: leading space
orochaa Aug 24, 2023
647cefd
refactor: remove unused event listener
orochaa Aug 25, 2023
2aaf6bb
refactor: improve cursor mapping
orochaa Aug 25, 2023
1a11247
refactor: cancel state
orochaa Aug 25, 2023
a28d2b1
Merge branch 'main' of https://github.com/natemoo-re/clack into path
orochaa Aug 26, 2023
9bc1e8b
Merge branch 'path' of https://github.com/Mist3rBru/clack into path
orochaa Aug 26, 2023
1bdf0bc
docs: add path example
orochaa Aug 27, 2023
154f9c2
feat: path input
orochaa Aug 29, 2023
9ed6904
Merge branch 'main' of https://github.com/natemoo-re/clack into path
orochaa Aug 29, 2023
0beb787
Merge branch 'path' of https://github.com/Mist3rBru/clack into path
orochaa Aug 29, 2023
c9e3043
refactor: prevent error on file root
orochaa Aug 29, 2023
b2c639c
chore: update docs
orochaa Aug 29, 2023
b118f43
feat: path validate
orochaa Aug 29, 2023
5487280
feat: shell like input
orochaa Nov 3, 2023
ec70704
feat: highlight current hint option
orochaa Nov 9, 2023
3f1db88
feat: maxHintOptions param
orochaa Nov 9, 2023
dec422f
feat: reverse hint option selection
orochaa Nov 9, 2023
8c854c1
feat: indentify directories on hintOptions
orochaa Nov 10, 2023
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
5 changes: 5 additions & 0 deletions .changeset/fresh-grapes-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clack/core': minor
---

Feat PathPrompt
5 changes: 5 additions & 0 deletions .changeset/modern-eggs-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clack/prompts': minor
orochaa marked this conversation as resolved.
Show resolved Hide resolved
---

Feat path prompt
15 changes: 15 additions & 0 deletions examples/basic/file-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as p from '@clack/prompts';

(async () => {
const path = await p.path({
message: 'Pick a file:',
onlyShowDir: false,
initialValue: process.cwd(),
maxItems: 15,
});
if (p.isCancel(path)) {
p.cancel('File selection canceled');
process.exit(0);
}
p.outro(path);
})();
3 changes: 2 additions & 1 deletion examples/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
},
"scripts": {
"start": "jiti ./index.ts",
"spinner": "jiti ./spinner.ts"
"spinner": "jiti ./spinner.ts",
"file": "jiti ./file-selection.ts"
},
"devDependencies": {
"jiti": "^1.17.0"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"organize-imports-cli": "^0.10.0",
"prettier": "^2.8.4",
"typescript": "^4.9.5",
"unbuild": "^1.1.2"
"unbuild": "1.1.2"
},
"packageManager": "[email protected]",
"volta": {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { default as ConfirmPrompt } from './prompts/confirm';
export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect';
export { default as MultiSelectPrompt } from './prompts/multi-select';
export { default as PasswordPrompt } from './prompts/password';
export { default as PathPrompt } from './prompts/path';
export { default as Prompt, isCancel } from './prompts/prompt';
export type { State } from './prompts/prompt';
export { default as SelectPrompt } from './prompts/select';
Expand Down
151 changes: 151 additions & 0 deletions packages/core/src/prompts/path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { readdirSync } from 'node:fs';
import { resolve } from 'node:path';
import Prompt, { PromptOptions } from './prompt';

interface PathNode {
name: string;
children: PathNode[] | undefined;
}

interface PathOptions extends PromptOptions<PathPrompt> {
onlyShowDir?: boolean;
}

export default class PathPrompt extends Prompt {
private cursorMap: number[];
private onlyShowDir: boolean;
public root: PathNode;

public get option() {
let aux: PathNode = this.root;
for (let i = 0; i < this.cursorMap.length; i++) {
if (aux.children && aux.children[this.cursorMap[i]]) {
aux = aux.children[this.cursorMap[i]];
} else {
break;
}
}
return {
index: this.cursorMap[this.cursorMap.length - 1] ?? 0,
depth: this.cursorMap.length,
node: aux,
};
}

public get options(): PathNode[] {
let aux: PathNode = this.root;
let options: PathNode[] = [this.root];
for (let i = 0; i < this.cursorMap.length; i++) {
if (aux.children && aux.children[this.cursorMap[i]]) {
options = options.concat(aux.children);
aux = aux.children[this.cursorMap[i]];
} else {
options = options.concat(aux.children ?? []);
}
}
return options;
}

public get cursor(): number {
return this.cursorMap.reduce((a, b) => a + b, 0);
}

private get _node(): PathNode[] {
let aux: PathNode = this.root;
let options: PathNode[] = [];
for (const index of this.cursorMap) {
if (aux.children?.[index]) {
options = aux.children;
aux = aux.children[index];
} else {
break;
}
}
return options;
}

private get _value(): string {
const value: string[] = [];
let option: PathNode = this.root;
for (const index of this.cursorMap) {
if (option.children?.[index]) {
option = option.children[index];
value.push(option.name);
}
}
return resolve(this.root.name, ...value);
}

private _changeValue() {
this.value = this._value;
}

private mapDir(path: string): PathNode[] {
return readdirSync(path, { withFileTypes: true })
.map((item) => ({
name: item.name,
children: item.isDirectory() ? [] : undefined,
}))
.filter((node) => {
return this.onlyShowDir ? !!node.children : true;
});
}

constructor(opts: PathOptions) {
super(opts, false);

this.onlyShowDir = opts.onlyShowDir ?? false;
const cwd = opts.initialValue ?? process.cwd();
this.root = {
name: cwd,
children: this.mapDir(cwd),
};
this.cursorMap = [0];
this._changeValue();

this.on('cursor', (key) => {
switch (key) {
case 'up':
if (this.cursorMap.length) {
this.cursorMap = [
...this.cursorMap.slice(0, -1),
this.option.index > 0 ? this.option.index - 1 : this._node.length - 1,
];
}
break;
case 'down':
if (this.cursorMap.length) {
this.cursorMap = [
...this.cursorMap.slice(0, -1),
this.option.index < this._node.length - 1 ? this.option.index + 1 : 0,
];
}
break;
case 'right':
if (this.option.node.children) {
const children = this.mapDir(this._value);
this.option.node.children = children;
this.cursorMap = children.length ? [...this.cursorMap, 0] : this.cursorMap;
this.emit('resize');
}
break;
case 'left':
const prevCursor = this.cursorMap;
this.cursorMap = this.cursorMap.slice(0, -1);
if (this.option.node.children?.length && this.cursorMap.length) {
this.option.node.children = [];
this.emit('resize');
} else if (prevCursor.length === 0) {
const cwd = resolve(this.root.name, '..');
this.root = {
name: cwd,
children: this.mapDir(cwd),
};
this.emit('resize');
}
break;
}
this._changeValue();
});
}
}
1 change: 1 addition & 0 deletions packages/core/src/prompts/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export default class Prompt {
this.input.on('keypress', this.onKeypress);
setRawMode(this.input, true);
this.output.on('resize', this.render);
this.on('resize', this.render);

this.render();

Expand Down
101 changes: 100 additions & 1 deletion packages/prompts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import {
isCancel,
MultiSelectPrompt,
PasswordPrompt,
PathPrompt,
SelectKeyPrompt,
SelectPrompt,
State,
TextPrompt,
TextPrompt
} from '@clack/core';
import isUnicodeSupported from 'is-unicode-supported';
import color from 'picocolors';
Expand Down Expand Up @@ -168,6 +169,104 @@ export const confirm = (opts: ConfirmOptions) => {
}).prompt() as Promise<boolean | symbol>;
};

interface PathNode {
name: string;
children: PathNode[] | undefined;
}

export interface PathOptions {
message: string;
/**
* @default process.cwd() // current working dir
*/
initialValue?: string;
maxItems?: number;
onlyShowDir?: boolean;
}

export const path = (opts: PathOptions) => {
const opt = (node: PathNode, state: boolean, depth: number): string => {
return [
color.cyan(S_BAR),
' '.repeat(depth + 2),
state ? color.green(S_RADIO_ACTIVE) : color.dim(S_RADIO_INACTIVE),
' ',
node.name,
node.children ? (node.children.length ? ` v` : ` >`) : undefined,
].join('');
};

let slidingWindowLocation = 0;

return new PathPrompt({
initialValue: opts.initialValue,
onlyShowDir: opts.onlyShowDir,
render() {
const option = this.option;
const map = (node: PathNode, index: number = 0, depth: number = 0): string => {
const state =
option.index === index && option.depth === depth && option.node.name === node.name;
return node.children && node.children.length
? [
opt(node, state, depth),
node.children.map((_node, i) => map(_node, i, depth + 1)).join('\n'),
].join('\n')
: opt(node, state, depth);
};

const title = [color.gray(S_BAR), `${symbol(this.state)} ${opts.message}`].join('\n');

switch (this.state) {
case 'submit':
return [title, `${color.gray(S_BAR)} ${color.dim(this.value)}`].join('\n');
case 'cancel':
return [
title,
`${color.gray(S_BAR)} ${color.dim(color.strikethrough(this.value))}`,
].join('\n');
default:
const maxItems = opts.maxItems === undefined ? Infinity : Math.max(opts.maxItems, 5);
if (this.cursor >= slidingWindowLocation + maxItems - 4) {
slidingWindowLocation = Math.max(
Math.min(this.cursor - maxItems + 4, this.options.length - maxItems),
0
);
} else if (this.cursor < slidingWindowLocation + 2) {
slidingWindowLocation = Math.max(this.cursor - 2, 0);
}

const shouldRenderTopEllipsis =
maxItems < this.options.length && slidingWindowLocation > 0;
const shouldRenderBottomEllipsis =
maxItems < this.options.length &&
slidingWindowLocation + maxItems < this.options.length;

const dots = `${color.cyan(S_BAR)} ${color.dim('...')}`;

return [
title,
map(this.root)
.split(/\n/g)
.slice(slidingWindowLocation, slidingWindowLocation + maxItems)
.map((option, i, arr) => {
if (i === 0 && shouldRenderTopEllipsis) {
return dots;
} else if (i === arr.length - 1 && shouldRenderBottomEllipsis) {
return dots;
} else {
return option;
}
}),
,
color.cyan(S_BAR_END),
]
.flat()
.join('\n');
}
},
}).prompt();
};

type Primitive = Readonly<string | boolean | number>;

type Option<Value> = Value extends Primitive
Expand Down
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.