file tree
[github]a tree listing opened folder by folder, then closed again
ui 36×23 15 fps 5.6 kB
. ├── docs/ │ ├── guide.md │ └── notes.md ├── public/ │ ├── favicon.ico │ └── robots.txt ├── src/ │ ├── components/ │ │ ├─> button.ts │ │ ├── card.ts │ │ └── menu.ts │ ├── lib/ + │ ├── index.ts │ └── styles.css ├── tests/ + ├── package.json └── README.md 6 directories, 14 files . ├── docs/ │ ├── guide.md │ └── notes.md ├── public/ │ ├── favicon.ico │ └── robots.txt ├── src/ │ ├── components/ │ │ ├─> button.ts │ │ ├── card.ts │ │ └── menu.ts │ ├── lib/ + │ ├── index.ts │ └── styles.css ├── tests/ + ├── package.json └── README.md 6 directories, 14 files
use it
html
<script type="module" src="https://ascii.rest/ascii.js"></script>
<ascii-art piece="file-tree" options='{"root":".","entries":"docs/\n guide.md\n notes.md\npublic/\n favicon.ico\n robots.txt\nsrc/\n components/\n button.ts\n card.ts\n menu.ts\n lib/\n fetch.ts\n format.ts\n index.ts\n styles.css\ntests/\n format.test.ts\npackage.json\nREADME.md"}'></ascii-art> No install and no build step. ascii.js defines the <ascii-art> tag, loads the piece from ascii.rest, plays it while it is on screen, and holds the first frame for anyone who prefers reduced motion.
react
// npm install github:bas3line/ascii
import { Ascii } from "ascii.rest/react";
import { fileTree } from "ascii.rest/pieces";
<Ascii piece={fileTree} options={{"root":".","entries":"docs/\n guide.md\n notes.md\npublic/\n favicon.ico\n robots.txt\nsrc/\n components/\n button.ts\n card.ts\n menu.ts\n lib/\n fetch.ts\n format.ts\n index.ts\n styles.css\ntests/\n format.test.ts\npackage.json\nREADME.md"}} /> A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="file-tree", to fetch the piece only when it mounts.
astro
---
// npm install github:bas3line/ascii
import Ascii from "ascii.rest/astro";
---
<Ascii piece="file-tree" options={{"root":".","entries":"docs/\n guide.md\n notes.md\npublic/\n favicon.ico\n robots.txt\nsrc/\n components/\n button.ts\n card.ts\n menu.ts\n lib/\n fetch.ts\n format.ts\n index.ts\n styles.css\ntests/\n format.test.ts\npackage.json\nREADME.md"}} /> Renders the first frame on the server, so the page is whole before any script runs, then plays the piece once it loads.
typescript
// npm install github:bas3line/ascii
import { mount } from "ascii.rest";
import { fileTree } from "ascii.rest/pieces";
const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, fileTree, {"root":".","entries":"docs/\n guide.md\n notes.md\npublic/\n favicon.ico\n robots.txt\nsrc/\n components/\n button.ts\n card.ts\n menu.ts\n lib/\n fetch.ts\n format.ts\n index.ts\n styles.css\ntests/\n format.test.ts\npackage.json\nREADME.md"}); mount plays a piece in a <pre>, or on a <canvas> for the coloured ones, and returns a function that stops it. Every piece, option and frame is typed.
/*
* file-tree: a project listed the way `tree -F` prints it, opened one folder
* at a time by a cursor walking down the list, then closed from the bottom up.
*/
import type { Frame, Meta } from "../types.ts";
const ENTRIES = [
"docs/",
" guide.md",
" notes.md",
"public/",
" favicon.ico",
" robots.txt",
"src/",
" components/",
" button.ts",
" card.ts",
" menu.ts",
" lib/",
" fetch.ts",
" format.ts",
" index.ts",
" styles.css",
"tests/",
" format.test.ts",
"package.json",
"README.md",
].join("\n");
export interface FileTreeOptions {
[key: string]: unknown;
/** The first line, above the listing. */
root: string;
/** One entry per line, two spaces of indent per level, folders end in "/". */
entries: string;
}
export const meta = {
name: "file tree",
category: "ui",
note: "a tree listing opened folder by folder, then closed again",
cols: 36,
rows: 23,
fps: 15,
options: { root: ".", entries: ENTRIES },
} satisfies Meta<FileTreeOptions>;
const MOVE = 0.13; // seconds per line the cursor steps
const PAUSE = 0.3; // on a folder before it opens or closes
const REVEAL = 0.07; // per line appearing or going
const HOLD = 1.8; // all open
const REST = 0.7; // all closed, held at both ends of the loop
// The root holds entries; an entry is a file, or a folder holding more.
interface Folder {
kids: Entry[];
}
interface Entry extends Folder {
name: string;
dir: boolean;
}
// One entry per line, two spaces of indent per level, folders end in "/".
function parse(text: string) {
const root: Folder = { kids: [] };
const stack: Folder[] = [root];
for (const raw of String(text).split("\n")) {
const name = raw.trim();
if (!name) continue;
const depth = Math.floor((raw.length - raw.trimStart().length) / 2);
const node: Entry = { name, dir: name.endsWith("/"), kids: [] };
stack.length = Math.min(stack.length, depth + 1);
stack[stack.length - 1].kids.push(node);
if (node.dir) stack.push(node);
}
return root;
}
export default function fileTree({ root: label = ".", entries = ENTRIES }: Partial<FileTreeOptions> = {}): Frame {
const { cols, rows } = meta;
const root = parse(entries);
const dirs: Entry[] = [];
let nd = 0, nf = 0;
const collect = (n: Folder): void =>
n.kids.forEach((k) => {
k.dir ? nd++ : nf++;
if (k.dir && k.kids.length) dirs.push(k);
collect(k);
});
collect(root);
// Counted over the whole tree, as tree itself does, not just what is open.
const summary = `${nd} director${nd === 1 ? "y" : "ies"}, ${nf} file${nf === 1 ? "" : "s"}`;
const shown = new Map<Folder, number>(); // folder to how many of its entries are listed
shown.set(root, root.kids.length);
let cursor = dirs[0] || root.kids[0];
// The lines on screen: the folders' entries in order, cut short where a
// folder is closed or still opening. A closed folder that holds something
// wears a "+", and the cursor is the branch turning into an arrow.
const listing = () => {
const out: { node: Entry; text: string }[] = [];
const walk = (node: Folder, prefix: string): void => {
const n = shown.get(node) || 0;
for (let i = 0; i < n; i++) {
const kid = node.kids[i], last = i === node.kids.length - 1;
const branch = (last ? "└─" : "├─") + (kid === cursor ? "> " : "─ ");
const closed = kid.dir && kid.kids.length && !shown.get(kid);
out.push({ node: kid, text: prefix + branch + kid.name + (closed ? " +" : "") });
if (kid.dir) walk(kid, prefix + (last ? " " : "│ "));
}
};
walk(root, "");
return out;
};
// Centre the listing as it stands fully open, with room for the marker.
dirs.forEach((d) => shown.set(d, d.kids.length));
const width = Math.max(label.length, summary.length, ...listing().map((l) => l.text.length + 2));
dirs.forEach((d) => shown.set(d, 0));
const pad = " ".repeat(Math.max(0, Math.floor((cols - width) / 2)));
// The tree grows down from the top; the count stays on the last row.
const render = () => {
const lines = [label, ...listing().map((v) => v.text)].slice(0, rows - 2);
while (lines.length < rows - 1) lines.push("");
lines.push(summary);
return lines.map((s) => (pad + s).padEnd(cols).slice(0, cols)).join("\n");
};
// The whole loop as a list of pictures and how long each stays up.
const snaps: { at: number; text: string }[] = [];
let total = 0, start = 0;
const snap = (dur: number) => {
snaps.push({ at: total, text: render() });
total += dur;
};
const step = (dir: number) => {
const vis = listing();
const i = vis.findIndex((v) => v.node === cursor);
cursor = vis[i + dir].node;
snap(MOVE);
};
const walkTo = (node: Entry) => {
const vis = listing();
const dir = vis.findIndex((v) => v.node === node) > vis.findIndex((v) => v.node === cursor) ? 1 : -1;
while (cursor !== node) step(dir);
};
snap(REST);
dirs.forEach((node, i) => {
walkTo(node);
snap(PAUSE);
for (let k = 1; k <= node.kids.length; k++) (shown.set(node, k), snap(REVEAL));
if (i + 1 === Math.ceil(dirs.length * 0.6)) start = total;
});
snap(HOLD);
for (let i = dirs.length - 1; i >= 0; i--) {
const node = dirs[i];
walkTo(node);
snap(PAUSE);
for (let k = node.kids.length - 1; k >= 0; k--) (shown.set(node, k), snap(REVEAL));
}
total += REST; // the last picture is the first one, so the loop closes on it
return (t) => {
const p = (t + start) % total;
let i = 0;
while (i + 1 < snaps.length && snaps[i + 1].at <= p) i++;
return snaps[i].text;
};
}