A file tree
The minimum: a tree of packed scenes, a string focus, and a parentOf that is always anchored. Also windowed with an honest aggregate (assets/icons holds 420 files and draws 120, with a hollow cluster saying how many it left out), ramp for file age, emphasis: ["ring"] for uncommitted changes, and invalidate(): switch to colouring by type and each file carries its own color, recoloured in place.
The frame below runs the example built from exactly these files. Start with source.ts. Every
example shares one small page shell (packages/core/examples/shared/ExampleShell.tsx): it keeps the focus in the
URL hash, builds a breadcrumb from onSceneChange, and shows the selected item from onNodeSelect.
import { createSceneSource, windowed, type ExplorerCluster, type ExplorerNode, type ExplorerScene, type SceneSource,} from "@data-xplorer/core";import { delay } from "../shared/delay";import { byteCount, fileCount, folderAt, parentPath, ROOT, type FileEntry, type FolderEntry,} from "./data";
/** * A tree, the simplest shape: every scene is packed (no `focusId`), the focus is a folder path, * and zooming out is always anchored, because every folder appears in its parent as a cluster * with the folder's own name as its id. */type Path = string;
/** The most files one scene draws before the rest become one "more files" aggregate. */const FILE_BUDGET = 120;const OLDEST_DAYS = 400;
export const folderHref = (path: Path) => `#${encodeURIComponent(path)}`;// The example has no file pages, so a file links to its folder with the file named: still a// real, stable URL that `decode` understands.export const fileHref = (file: FileEntry) => `#${encodeURIComponent(`${parentPath(file.path)}?file=${file.name}`)}`;
function kilobytes(bytes: number) { return `${(bytes / 1024).toFixed(1)} KB`;}
function folderCluster(folder: FolderEntry): ExplorerCluster<Path> { const count = fileCount(folder); const bytes = byteCount(folder); return { id: folder.name, label: folder.name, sublabel: `${count} files`, // Bytes, like a file's weight, so folders and files in one scene compare. Readable in `marks`. weight: bytes, marks: [`${count} files`, kilobytes(bytes)], href: folderHref(folder.path), drillsInto: folder.path, shape: "cloud", truncated: null, };}
/** How files are coloured: by age on the ramp, or (US071) by type with each item's own `color`. */export type ColourBy = "age" | "type";
/** One CSS colour per file type, in the forms `color` accepts: hex, short hex, `rgb()`, `hsl()`. */const TYPE_COLOURS: Record<string, string> = { ts: "#3178c6", tsx: "#61dafb", css: "hsl(330, 80%, 60%)", json: "rgb(240, 200, 60)", md: "#ccc", svg: "#f80", png: "#f80",};const OTHER_TYPE_COLOUR = "#8b95a7";
function typeColour(name: string): string { const extension = name.slice(name.lastIndexOf(".") + 1).toLowerCase(); return TYPE_COLOURS[extension] ?? OTHER_TYPE_COLOUR;}
function fileNode(file: FileEntry, colourBy: ColourBy): ExplorerNode<Path> { return { id: file.name, label: file.name, sublabel: kilobytes(file.bytes), weight: file.bytes, marks: [ kilobytes(file.bytes), `changed ${file.age} days ago`, ...(file.modified ? ["uncommitted changes"] : []), ], href: fileHref(file), drillsInto: null, shape: "disc", // Newest files sit at the green end of the ramp, the oldest at the red end. ramp: 1 - Math.min(file.age, OLDEST_DAYS) / OLDEST_DAYS, // An item's own colour wins over its ramp position, so "type" needs no other change. color: colourBy === "type" ? typeColour(file.name) : null, emphasis: file.modified ? ["ring"] : [], };}
function buildScene(path: Path | null, colourBy: ColourBy): ExplorerScene<Path> { const folder = folderAt(path ?? "") ?? ROOT; const folders = folder.children.filter((c): c is FolderEntry => c.kind === "folder"); const allFiles = folder.children.filter((c): c is FileEntry => c.kind === "file");
// `windowed` keeps the first N in the order given and says how many were left out. const { shown, truncated } = windowed(allFiles, FILE_BUDGET); const rest: ExplorerCluster<Path>[] = truncated ? [ { id: "more-files", label: `${truncated.total - truncated.shown} more files`, weight: allFiles.slice(truncated.shown).reduce((sum, file) => sum + file.bytes, 0), marks: [`Showing ${truncated.shown} of ${truncated.total}`], href: null, // an aggregate has no page of its own... drillsInto: null, // ...and cannot be entered; it is drawn hollow shape: "disc", truncated, }, ] : [];
return { id: folder.path || "/", heading: folder.name, clusters: [...folders.map(folderCluster), ...rest], nodes: shown.map((file) => fileNode(file, colourBy)), edges: [], };}
/** The colour mode, as a tiny store the source can listen to. */export function colourSetting(initial: ColourBy) { let value = initial; const listeners = new Set<() => void>(); return { get: () => value, set(next: ColourBy) { if (next === value) return; value = next; for (const listener of listeners) listener(); }, subscribe(listener: () => void) { listeners.add(listener); return () => void listeners.delete(listener); }, };}
export function fileTreeSource(colourBy = colourSetting("age")): SceneSource<Path> { const source = createSceneSource<Path>({ load: async (path) => { await delay(150); return buildScene(path, colourBy.get()); }, parentOf(path) { const parent = parentPath(path); // Zooming out of /src/lib lands on the `lib` cluster inside /src. return { kind: "anchored", focus: parent === "" ? null : parent, anchorId: folderAt(path)!.name, }; }, }); // Switching the colour mode changes every scene: forget them all, and the one on screen redraws. colourBy.subscribe(() => source.invalidate()); return source;}
/** `#/src/lib` → `/src/lib`; `#/src/lib?file=api.ts` → `/src/lib`; anything unknown → the root. */export function decodeFolder(hash: string): Path | null { const path = hash.split("?")[0]!; return path && folderAt(path) ? path : null;}import { useState } from "react";import { createRoot } from "react-dom/client";import { ExampleShell } from "../shared/ExampleShell";import { colourSetting, decodeFolder, fileTreeSource, type ColourBy } from "./source";
// Created once, outside React, so its cache outlives any re-render.const colourBy = colourSetting("age");const source = fileTreeSource(colourBy);
function FileTree() { const [mode, setMode] = useState<ColourBy>(colourBy.get());
function switchMode() { const next = mode === "age" ? "type" : "age"; colourBy.set(next); // the source invalidates, and the explorer recolours in place setMode(next); }
return ( <ExampleShell title="A file tree" intro={ <p> A project folder as a tree. Folders are clouds you can zoom into; files are discs sized by bytes, coloured by how recently they changed (green newest, red oldest), and ringed when they have uncommitted changes. Switch to colour by type and each file carries its own{" "} <code>color</code>, which wins over its place on the ramp. <code>assets/icons</code> holds 420 files: the scene draws 120 and a hollow aggregate says how many it left out. </p> } source={source} encode={(path) => path} decode={decodeFolder} trailOf={(path) => ["my-app", ...(path ?? "").split("/").filter(Boolean)]} toolbar={ <button type="button" onClick={switchMode}> Colour by {mode === "age" ? "type" : "age"} </button> } /> );}
createRoot(document.getElementById("root")!).render(<FileTree />);/** * A made-up project on disk. Deterministic, so the example looks the same every time. * `age` is days since last change; `modified` marks files with uncommitted changes. */export interface FileEntry { kind: "file"; name: string; path: string; bytes: number; age: number; modified: boolean;}
export interface FolderEntry { kind: "folder"; name: string; path: string; children: Entry[];}
export type Entry = FileEntry | FolderEntry;
// A tiny seeded generator, so sizes and ages vary but never change between reloads.let seed = 7;const random = () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;
function file(parent: string, name: string, modified = false, maxBytes = 24_000): FileEntry { return { kind: "file", name, path: `${parent}/${name}`, bytes: Math.round(200 + random() * maxBytes), age: Math.round(random() * 400), modified, };}
function folder(parent: string, name: string, build: (path: string) => Entry[]): FolderEntry { const path = `${parent}/${name}`; return { kind: "folder", name, path, children: build(path) };}
const files = (parent: string, names: string[], modified: string[] = []) => names.map((name) => file(parent, name, modified.includes(name)));
export const ROOT: FolderEntry = { kind: "folder", name: "my-app", path: "", children: [ folder("", "src", (src) => [ folder(src, "components", (p) => files( p, [ "Button.tsx", "Card.tsx", "Dialog.tsx", "Menu.tsx", "Table.tsx", "Tabs.tsx", "Toast.tsx", "Tooltip.tsx", ], ["Dialog.tsx"], ), ), folder(src, "routes", (p) => [ folder(p, "account", (q) => files(q, ["profile.tsx", "billing.tsx", "security.tsx"])), folder(p, "admin", (q) => [ folder(q, "reports", (r) => files(r, ["daily.tsx", "weekly.tsx", "export.ts"], ["export.ts"]), ), ...files(q, ["users.tsx", "audit.tsx"]), ]), ...files(p, ["index.tsx", "search.tsx", "not-found.tsx"]), ]), folder(src, "lib", (p) => files( p, ["api.ts", "dates.ts", "format.ts", "storage.ts", "validate.ts"], ["api.ts", "format.ts"], ), ), ...files(src, ["main.tsx", "App.tsx", "router.ts"]), ]), folder("", "assets", (assets) => [ // Far more than one scene should draw: the scene shows the first 120 and says so. folder(assets, "icons", (p) => Array.from({ length: 420 }, (_, i) => file(p, `icon-${String(i + 1).padStart(3, "0")}.svg`, false, 1_200), ), ), folder(assets, "fonts", (p) => files(p, ["inter.woff2", "inter-bold.woff2", "mono.woff2"])), ...files(assets, ["logo.svg", "hero.png", "favicon.ico"]), ]), folder("", "docs", (p) => files( p, ["getting-started.md", "architecture.md", "deploying.md", "faq.md"], ["deploying.md"], ), ), folder("", "test", (p) => files(p, ["api.test.ts", "dates.test.ts", "format.test.ts", "e2e.test.ts"]), ), ...files("", ["package.json", "README.md", "tsconfig.json", ".gitignore"], ["package.json"]), ],};
const index = new Map<string, Entry>();(function walk(entry: Entry) { index.set(entry.path, entry); if (entry.kind === "folder") entry.children.forEach(walk);})(ROOT);
export function folderAt(path: string): FolderEntry | null { const entry = index.get(path); return entry?.kind === "folder" ? entry : null;}
export function fileCount(entry: Entry): number { return entry.kind === "file" ? 1 : entry.children.reduce((sum, child) => sum + fileCount(child), 0);}
export function byteCount(entry: Entry): number { return entry.kind === "file" ? entry.bytes : entry.children.reduce((sum, child) => sum + byteCount(child), 0);}
export const parentPath = (path: string) => path.slice(0, path.lastIndexOf("/"));