Using FZF in a Deno Script
Piping to FZF, with extra steps
At some point, all of my Bash scripts become incomprehensible enough that I’m forced to re-write them in another language. Usually, I’d switch into Python (which I’m also terrible at) since it’s broadly available or trivial to install. Lately, I’ve started using Deno instead, since it means I can use TypeScript which I actually (kinda) know. Deno is is a little more work to install, but otherwise has some nice ergonomics for CLI tools for a few reasons:
- Deno supports TypeScript natively, so you get type safety without needing to compile the code
- No need to install or run a package manager like
pip
ornpm
to install dependencies,deno
takes care of it automatically when running the script - The Standard Library has a solid command line argument parser
- Startup time is faster than Node, so it’s not a huge pain to run a script for a one-off task
As you know, I’m a big fan of FZF, and I use it in my bash scripts all the time. There’s a library that mimics the fuzzy-find algorithim, but it’s mostly meant for the browser so it doesn’t handle terminal input and display (especially nice touches like honoring readline keyboard shortcuts).
Fortunately, Deno has some nice APIs for spawning external commands, so we can just use the real fzf
binary instead. Here’s some code I’ve used in a few places in order to spawn FZF in order to do some interactive selection:
export interface FzfSelection {
/** Value displayed to the user, must not contain newlines */
display: string;
/** Unique identifier for this selection, must not contain spaces */
id: string;
}
interface FzfOptions {
/** Allow multiple selections */
allowMultiple?: boolean;
/** Automatically select if only one item */
autoSelectSingle?: boolean;
}
const BASE_FZF_ARGUMENTS = [
"--cycle",
"--no-sort",
"--bind",
"ctrl-a:select-all,ctrl-d:deselect-all",
"--with-nth",
"2..",
];
/**
* Let the user make selections interactively via FZF
*/
export async function getUserSelections<T extends FzfSelection>(
items: T[],
{ allowMultiple = false, autoSelectSingle = false }: FzfOptions = {},
): Promise<T[]> {
if (!items.length || (items.length === 1 && autoSelectSingle)) {
return items;
}
const fzf = new Deno.Command(`fzf`, {
args: [...BASE_FZF_ARGUMENTS, allowMultiple ? "--multi" : ""].filter(
Boolean,
),
stdin: "piped",
stdout: "piped",
stderr: "inherit",
});
const process = fzf.spawn();
const choiceList = items
.map((line) => `${line.id} ${line.display}`)
.join("\n");
// Write the choices to stdin
const encoder = new TextEncoder();
const writer = process.stdin.getWriter();
// Must use trailing newline, otherwise last item won't appear
writer.write(encoder.encode(choiceList + "\n"));
// User can now interact with fzf to filter and select
// This will now wait until the process exits
const { code, success, stdout } = await process.output();
if (!success) {
switch (code) {
case 1: // No match
case 130: // Command terminated by user
return [];
default:
throw new Error(`fzf exited with status ${code}`);
}
}
const lines = new TextDecoder().decode(stdout).trim();
const selectedIds = lines
.split("\n")
.map((line) => line.split(" ")[0]);
return items.filter((item) => selectedIds.includes(item.id));
}
Here’s an example of how you’d use it:
const KombuchaFlavors = [
{id: '1', display: 'Gingerade ($3.99)'},
{id: '2', display: 'Multi-Green ($3.49)'},
{id: '3', display: 'Guava Goddess ($3.99)'},
{id: '4', display: 'Island Bliss ($2.99)'},
{id: '5', display: 'Strawberry Serenity ($4.99)'},
];
const flavorsToOrder = await getUserSelections(KombuchaFlavors, {allowMultiple: true});
console.log("You ordered: ", flavorsToOrder.map((flavor) => flavor.display).join(", "));
And here it is in action:
Note that there are a few edge cases that aren’t handled here: specifically, you need to make sure the id
does not have any spaces, and there cannot be any newlines in the display
value. If you want to get fancy, you could stream the options into FZF as they’re generated, but that will be left as an exercise to the reader.