Skip to content

Commit

Permalink
feat(prompt): add support for path completion and dynamic suggestions (
Browse files Browse the repository at this point in the history
  • Loading branch information
c4spar committed Feb 1, 2022
1 parent 04e6917 commit 0be6da7
Show file tree
Hide file tree
Showing 7 changed files with 244 additions and 99 deletions.
81 changes: 46 additions & 35 deletions prompt/README.md

Large diffs are not rendered by default.

205 changes: 159 additions & 46 deletions prompt/_generic_suggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@ import {
GenericInputPromptOptions,
GenericInputPromptSettings,
} from "./_generic_input.ts";
import { blue, bold, dim, stripColor, underline } from "./deps.ts";
import {
blue,
bold,
dim,
dirname,
join,
normalize,
stripColor,
underline,
} from "./deps.ts";
import { Figures, getFiguresByKeys } from "./figures.ts";
import { distance } from "../_utils/distance.ts";

Expand All @@ -24,12 +33,23 @@ export interface GenericSuggestionsKeys extends GenericInputKeys {
previousPage?: string[];
}

export type SuggestionHandler = (
input: string,
) => Array<string | number> | Promise<Array<string | number>>;

export type CompleteHandler = (
input: string,
suggestion?: string,
) => Promise<string> | string;

/** Generic input prompt options. */
export interface GenericSuggestionsOptions<T, V>
extends GenericInputPromptOptions<T, V> {
keys?: GenericSuggestionsKeys;
id?: string;
suggestions?: Array<string | number>;
suggestions?: Array<string | number> | SuggestionHandler;
complete?: CompleteHandler;
files?: boolean | RegExp;
list?: boolean;
info?: boolean;
listPointer?: string;
Expand All @@ -41,13 +61,17 @@ export interface GenericSuggestionsSettings<T, V>
extends GenericInputPromptSettings<T, V> {
keys?: GenericSuggestionsKeys;
id?: string;
suggestions?: Array<string | number>;
suggestions?: Array<string | number> | SuggestionHandler;
complete?: CompleteHandler;
files?: boolean | RegExp;
list?: boolean;
info?: boolean;
listPointer: string;
maxRows: number;
}

const sep = Deno.build.os === "windows" ? "\\" : "/";

/** Generic input prompt representation. */
export abstract class GenericSuggestions<
T,
Expand All @@ -57,6 +81,7 @@ export abstract class GenericSuggestions<
protected suggestionsIndex = -1;
protected suggestionsOffset = 0;
protected suggestions: Array<string | number> = [];
#hasReadPermissions?: boolean;

/**
* Prompt constructor.
Expand All @@ -74,13 +99,6 @@ export abstract class GenericSuggestions<
...(settings.keys ?? {}),
},
});
const suggestions: Array<string | number> = this.loadSuggestions();
if (suggestions.length || this.settings.suggestions) {
this.settings.suggestions = [
...suggestions,
...this.settings.suggestions ?? [],
].filter(uniqueSuggestions);
}
}

protected get localStorage(): LocalStorage | null {
Expand Down Expand Up @@ -120,30 +138,18 @@ export abstract class GenericSuggestions<
}
}

protected render(): Promise<void> {
this.match();
protected async render(): Promise<void> {
if (this.settings.files && this.#hasReadPermissions === undefined) {
const status = await Deno.permissions.request({ name: "read" });
// disable path completion if read permissions are denied.
this.#hasReadPermissions = status.state === "granted";
}
await this.match();
return super.render();
}

protected match(): void {
if (!this.settings.suggestions?.length) {
return;
}
const input: string = this.getCurrentInputValue().toLowerCase();
if (!input.length) {
this.suggestions = this.settings.suggestions.slice();
} else {
this.suggestions = this.settings.suggestions
.filter((value: string | number) =>
stripColor(value.toString())
.toLowerCase()
.startsWith(input)
)
.sort((a: string | number, b: string | number) =>
distance((a || a).toString(), input) -
distance((b || b).toString(), input)
);
}
protected async match(): Promise<void> {
this.suggestions = await this.getSuggestions();
this.suggestionsIndex = Math.max(
this.getCurrentInputValue().trim().length === 0 ? -1 : 0,
Math.min(this.suggestions.length - 1, this.suggestionsIndex),
Expand All @@ -168,6 +174,56 @@ export abstract class GenericSuggestions<
) ?? "";
}

protected async getUserSuggestions(
input: string,
): Promise<Array<string | number>> {
return typeof this.settings.suggestions === "function"
? await this.settings.suggestions(input)
: this.settings.suggestions ?? [];
}

#isFileModeEnabled(): boolean {
return !!this.settings.files && this.#hasReadPermissions === true;
}

protected async getFileSuggestions(
input: string,
): Promise<Array<string | number>> {
if (!this.#isFileModeEnabled()) {
return [];
}

const path = await Deno.stat(input)
.then((file) => file.isDirectory ? input : dirname(input))
.catch(() => dirname(input));

return await listDir(path, this.settings.files);
}

protected async getSuggestions(): Promise<Array<string | number>> {
const input = this.getCurrentInputValue().toLowerCase();
const suggestions = [
...this.loadSuggestions(),
...await this.getUserSuggestions(input),
...await this.getFileSuggestions(input),
].filter(uniqueSuggestions);

if (!input.length) {
return suggestions;
}

return suggestions
.filter((value: string | number) =>
stripColor(value.toString())
.toLowerCase()
.startsWith(input)
)
.sort((a: string | number, b: string | number) =>
distance((a || a).toString(), input) -
distance((b || b).toString(), input)
);
}

protected body(): string | Promise<string> {
return this.getList() + this.getInfo();
}
Expand All @@ -180,7 +236,7 @@ export abstract class GenericSuggestions<
const matched: number = this.suggestions.length;
const actions: Array<[string, Array<string>]> = [];

if (this.settings.suggestions?.length) {
if (this.suggestions.length) {
if (this.settings.list) {
actions.push(
["Next", getFiguresByKeys(this.settings.keys?.next ?? [])],
Expand All @@ -206,7 +262,7 @@ export abstract class GenericSuggestions<
);

let info = this.settings.indent;
if (this.settings.suggestions?.length) {
if (this.suggestions.length) {
info += blue(Figures.INFO) + bold(` ${selected}/${matched} `);
}
info += actions
Expand All @@ -217,7 +273,7 @@ export abstract class GenericSuggestions<
}

protected getList(): string {
if (!this.settings.suggestions?.length || !this.settings.list) {
if (!this.suggestions.length || !this.settings.list) {
return "";
}
const list: Array<string> = [];
Expand Down Expand Up @@ -304,13 +360,13 @@ export abstract class GenericSuggestions<
}
break;
case this.isKey(this.settings.keys, "complete", event):
this.complete();
await this.#completeValue();
break;
case this.isKey(this.settings.keys, "moveCursorRight", event):
if (this.inputIndex < this.inputValue.length) {
this.moveCursorRight();
} else {
this.complete();
await this.#completeValue();
}
break;
default:
Expand All @@ -329,18 +385,44 @@ export abstract class GenericSuggestions<
}
}

protected complete(): void {
if (this.suggestions.length && this.suggestions[this.suggestionsIndex]) {
this.inputValue = this.suggestions[this.suggestionsIndex].toString();
this.inputIndex = this.inputValue.length;
this.suggestionsIndex = 0;
this.suggestionsOffset = 0;
async #completeValue() {
this.inputValue = await this.complete();
this.inputIndex = this.inputValue.length;
this.suggestionsIndex = 0;
this.suggestionsOffset = 0;
}

protected async complete(): Promise<string> {
let input: string = this.getCurrentInputValue();

if (!input.length) {
return input;
}
const suggestion: string | undefined = this
.suggestions[this.suggestionsIndex]?.toString();

if (this.settings.complete) {
input = await this.settings.complete(input, suggestion);
} else if (
this.#isFileModeEnabled() &&
input.at(-1) !== sep &&
await isDirectory(input) &&
(
this.getCurrentInputValue().at(-1) !== "." ||
this.getCurrentInputValue().endsWith("..")
)
) {
input += sep;
} else if (suggestion) {
input = suggestion;
}

return this.#isFileModeEnabled() ? normalize(input) : input;
}

/** Select previous suggestion. */
protected selectPreviousSuggestion(): void {
if (this.suggestions?.length) {
if (this.suggestions.length) {
if (this.suggestionsIndex > -1) {
this.suggestionsIndex--;
if (this.suggestionsIndex < this.suggestionsOffset) {
Expand All @@ -352,7 +434,7 @@ export abstract class GenericSuggestions<

/** Select next suggestion. */
protected selectNextSuggestion(): void {
if (this.suggestions?.length) {
if (this.suggestions.length) {
if (this.suggestionsIndex < this.suggestions.length - 1) {
this.suggestionsIndex++;
if (
Expand All @@ -367,7 +449,7 @@ export abstract class GenericSuggestions<

/** Select previous suggestions page. */
protected selectPreviousSuggestionsPage(): void {
if (this.suggestions?.length) {
if (this.suggestions.length) {
const height: number = this.getListHeight();
if (this.suggestionsOffset >= height) {
this.suggestionsIndex -= height;
Expand All @@ -381,7 +463,7 @@ export abstract class GenericSuggestions<

/** Select next suggestions page. */
protected selectNextSuggestionsPage(): void {
if (this.suggestions?.length) {
if (this.suggestions.length) {
const height: number = this.getListHeight();
if (this.suggestionsOffset + height + height < this.suggestions.length) {
this.suggestionsIndex += height;
Expand All @@ -403,3 +485,34 @@ function uniqueSuggestions(
return typeof value !== "undefined" && value !== "" &&
self.indexOf(value) === index;
}

function isDirectory(path: string): Promise<boolean> {
return Deno.stat(path)
.then((file) => file.isDirectory)
.catch(() => false);
}

async function listDir(
path: string,
mode?: boolean | RegExp,
): Promise<Array<string>> {
const fileNames: string[] = [];

for await (const file of Deno.readDir(path || ".")) {
if (
mode === true && (file.name.startsWith(".") || file.name.endsWith("~"))
) {
continue;
}
const filePath = join(path, file.name);

if (mode instanceof RegExp && !mode.test(filePath)) {
continue;
}
fileNames.push(filePath);
}

return fileNames.sort(function (a, b) {
return a.toLowerCase().localeCompare(b.toLowerCase());
});
}
12 changes: 9 additions & 3 deletions prompt/confirm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@ import { Figures } from "./figures.ts";

export type ConfirmKeys = GenericSuggestionsKeys;

type UnsupportedInputOptions = "suggestions" | "list" | "info";
type UnsupportedOptions =
| "files"
| "complete"
| "suggestions"
| "list"
| "info";

/** Confirm prompt options. */
export interface ConfirmOptions
extends
Omit<GenericSuggestionsOptions<boolean, string>, UnsupportedInputOptions> {
extends Omit<GenericSuggestionsOptions<boolean, string>, UnsupportedOptions> {
active?: string;
inactive?: string;
keys?: ConfirmKeys;
Expand Down Expand Up @@ -48,6 +52,8 @@ export class Confirm
active: "Yes",
inactive: "No",
...options,
files: false,
complete: undefined,
suggestions: [
options.active ?? "Yes",
options.inactive ?? "No",
Expand Down
5 changes: 5 additions & 0 deletions prompt/deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ export {
underline,
yellow,
} from "https://deno.land/std@0.113.0/fmt/colors.ts";
export {
dirname,
join,
normalize,
} from "https://deno.land/std@0.113.0/path/mod.ts";
6 changes: 3 additions & 3 deletions prompt/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
GenericSuggestionsOptions,
GenericSuggestionsSettings,
} from "./_generic_suggestions.ts";
import { blue, yellow } from "./deps.ts";
import { blue, normalize, yellow } from "./deps.ts";
import { Figures } from "./figures.ts";

export type InputKeys = GenericSuggestionsKeys;
Expand Down Expand Up @@ -58,9 +58,9 @@ export class Input extends GenericSuggestions<string, string, InputSettings> {
return super.success(value);
}

/** Get input input. */
/** Get input value. */
protected getValue(): string {
return this.inputValue;
return this.settings.files ? normalize(this.inputValue) : this.inputValue;
}

/**
Expand Down

0 comments on commit 0be6da7

Please sign in to comment.