diff --git a/bin/git-cas.js b/bin/git-cas.js
index 333e838..80b56fd 100755
--- a/bin/git-cas.js
+++ b/bin/git-cas.js
@@ -670,12 +670,22 @@ vault
// ---------------------------------------------------------------------------
vault
.command('dashboard')
- .description('Interactive vault explorer')
+ .description('Interactive CAS explorer')
.option('--cwd
', 'Git working directory', '.')
+ .option('--ref ', 'Inspect a git ref that points to a CAS tree, CAS index blob, or commit with a manifest hint')
+ .option('--oid ', 'Inspect a direct CAS tree OID')
.action(runAction(async (/** @type {Record} */ opts) => {
+ if (opts.ref && opts.oid) {
+ throw new Error('Choose either --ref or --oid, not both');
+ }
const cas = createCas(opts.cwd);
const { launchDashboard } = await import('./ui/dashboard.js');
- await launchDashboard(cas);
+ const source = opts.ref
+ ? { type: 'ref', ref: opts.ref }
+ : opts.oid
+ ? { type: 'oid', treeOid: opts.oid }
+ : { type: 'vault' };
+ await launchDashboard(cas, { cwd: path.resolve(opts.cwd), source });
}, getJson));
// ---------------------------------------------------------------------------
diff --git a/bin/ui/dashboard-cmds.js b/bin/ui/dashboard-cmds.js
index 8a88a06..72ccfeb 100644
--- a/bin/ui/dashboard-cmds.js
+++ b/bin/ui/dashboard-cmds.js
@@ -2,23 +2,1427 @@
* Async command factories for the vault dashboard.
*/
+import { lstat, readFile, readdir } from 'node:fs/promises';
+import path from 'node:path';
+import { buildVaultStats, inspectVaultHealth } from './vault-report.js';
+
/** @typedef {import('../../index.js').default} ContentAddressableStore */
+/** @typedef {{ type: 'vault' } | { type: 'ref', ref: string } | { type: 'oid', treeOid: string }} DashSource */
+/** @typedef {{ slug: string, treeOid: string }} ExplorerEntry */
+/** @typedef {'repository' | 'source'} TreemapScope */
+/** @typedef {'tracked' | 'ignored'} TreemapWorktreeMode */
+/** @typedef {'oid' | 'manifest' | 'tree' | 'index' | 'hint' | 'opaque'} RefResolutionKind */
+/** @typedef {'worktree' | 'git' | 'ref' | 'vault' | 'cas' | 'meta'} RepoTreemapKind */
+/** @typedef {{ kind: Exclude, segments: string[], label: string }} TreemapPathNode */
+/** @typedef {{ id: string, label: string, kind: RepoTreemapKind, value: number, detail: string, drillable: boolean, path: TreemapPathNode | null }} RepoTreemapTile */
+/** @typedef {{
+ * ref: string,
+ * oid: string,
+ * namespace: string,
+ * browsable: boolean,
+ * resolution: RefResolutionKind,
+ * entryCount: number,
+ * detail: string,
+ * previewSlugs: string[],
+ * source: Extract | null,
+ * }} RefInventoryItem */
+/** @typedef {{
+ * namespaces: Array<{ namespace: string, count: number, browsable: number }>,
+ * refs: RefInventoryItem[],
+ * }} RefInventory */
+/** @typedef {{
+ * scope: TreemapScope,
+ * worktreeMode: TreemapWorktreeMode,
+ * cwd: string,
+ * source: DashSource,
+ * drillPath: TreemapPathNode[],
+ * breadcrumb: string[],
+ * totalValue: number,
+ * tiles: RepoTreemapTile[],
+ * notes: string[],
+ * summary: {
+ * bare: boolean,
+ * gitDir: string,
+ * worktreeItems: number,
+ * worktreePaths: number,
+ * refNamespaces: number,
+ * refCount: number,
+ * vaultEntries: number,
+ * sourceEntries: number,
+ * }
+ * }} RepoTreemapReport
+ */
+/** @typedef {{ segments: string[], value: number, detail: string }} HierarchyRecord */
+
+/**
+ * Namespace bucket label for a git ref.
+ *
+ * @param {string} ref
+ * @returns {string}
+ */
+function refNamespace(ref) {
+ const parts = ref.split('/');
+ if (parts[0] === 'refs' && parts[1]) {
+ return `refs/${parts[1]}`;
+ }
+ return parts[0] || ref;
+}
+
+/**
+ * Build the segment layout used by the treemap for a git ref.
+ *
+ * Root scope groups refs by namespace such as `refs/heads` or `refs/warp`
+ * rather than starting with the raw `refs` segment.
+ *
+ * @param {string} ref
+ * @returns {string[]}
+ */
+function refSegments(ref) {
+ const parts = ref.split('/');
+ if (parts[0] === 'refs' && parts[1]) {
+ return [`refs/${parts[1]}`, ...parts.slice(2)];
+ }
+ return parts.filter(Boolean);
+}
+
+/**
+ * Return the display label for one drill path.
+ *
+ * @param {TreemapPathNode[]} drillPath
+ * @returns {string}
+ */
+function drillLabel(drillPath) {
+ return drillPath.map((node) => node.label).join(' / ') || 'root';
+}
+
+/**
+ * Create a stable tile id for one hierarchical segment path.
+ *
+ * @param {Exclude} kind
+ * @param {string[]} segments
+ * @returns {string}
+ */
+function tileId(kind, segments) {
+ return `${kind}:${segments.join('\u001f')}`;
+}
+
+/**
+ * Create one treemap path node from a kind and segment list.
+ *
+ * @param {Exclude} kind
+ * @param {string[]} segments
+ * @returns {TreemapPathNode}
+ */
+function pathNode(kind, segments) {
+ return {
+ kind,
+ segments,
+ label: segments[segments.length - 1] ?? '',
+ };
+}
+
+/**
+ * Return true when one segment list is nested under another.
+ *
+ * @param {string[]} left
+ * @param {string[]} right
+ * @returns {boolean}
+ */
+function segmentsStartWith(left, right) {
+ if (right.length > left.length) {
+ return false;
+ }
+ return right.every((segment, index) => left[index] === segment);
+}
+
+/**
+ * Compact OID label for human-facing rows.
+ *
+ * @param {string} oid
+ * @returns {string}
+ */
+function shortOid(oid) {
+ return oid.slice(0, 12);
+}
+
+/**
+ * Build a single-entry source result for direct CAS tree inspection.
+ *
+ * @param {string} slug
+ * @param {string} treeOid
+ * @returns {{ entries: ExplorerEntry[], metadata: any }}
+ */
+function singleEntrySource(slug, treeOid) {
+ return {
+ entries: [{ slug, treeOid }],
+ metadata: null,
+ };
+}
+
+/**
+ * Describe how a ref resolved into CAS entries.
+ *
+ * @param {RefResolutionKind} resolution
+ * @param {{ entries: ExplorerEntry[], resolvedOid?: string, targetTreeOid?: string | null }} result
+ * @returns {string}
+ */
+function describeResolution(resolution, result) {
+ const entryLabel = `${result.entries.length} CAS entr${result.entries.length === 1 ? 'y' : 'ies'}`;
+ const target = shortOid(result.targetTreeOid ?? result.resolvedOid ?? '');
+ switch (resolution) {
+ case 'manifest':
+ return `direct manifest tree ${target}`;
+ case 'tree':
+ return `commit/tree target ${target}`;
+ case 'index':
+ return `${entryLabel} from index blob`;
+ case 'hint':
+ return `manifest hint ${target}`;
+ case 'oid':
+ return `direct CAS tree ${target}`;
+ default:
+ return entryLabel;
+ }
+}
+
+/**
+ * Format bytes as a compact human-readable string.
+ *
+ * @param {number} bytes
+ * @returns {string}
+ */
+function formatBytes(bytes) {
+ if (bytes < 1024) { return `${bytes}B`; }
+ if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)}K`; }
+ if (bytes < 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(1)}M`; }
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
+}
+
+/**
+ * Normalize a manifest into plain data.
+ *
+ * @param {any} manifest
+ * @returns {any}
+ */
+function manifestData(manifest) {
+ return manifest?.toJSON ? manifest.toJSON() : manifest;
+}
+
+/**
+ * Resolve the true git-dir path for a non-bare working tree.
+ *
+ * Supports both regular repositories where `.git` is a directory and worktrees
+ * where `.git` is a pointer file containing `gitdir: `.
+ *
+ * @param {string} repoRoot
+ * @returns {Promise}
+ */
+async function resolveWorktreeGitDir(repoRoot) {
+ const dotGitPath = path.join(repoRoot, '.git');
+ try {
+ const stat = await lstat(dotGitPath);
+ if (stat.isDirectory()) {
+ return dotGitPath;
+ }
+ if (!stat.isFile()) {
+ return dotGitPath;
+ }
+ const raw = await readFile(dotGitPath, 'utf8');
+ const match = raw.match(/^\s*gitdir:\s*(.+)\s*$/i);
+ return match ? path.resolve(repoRoot, match[1]) : dotGitPath;
+ } catch {
+ return dotGitPath;
+ }
+}
+
+/**
+ * Read the Git repo root and git-dir paths for the current CAS plumbing.
+ *
+ * @param {{ cwd?: string, execute: ({ args }: { args: string[] }) => Promise }} plumbing
+ * @returns {Promise<{ cwd: string, gitDir: string, bare: boolean }>}
+ */
+async function resolveRepoInfo(plumbing) {
+ const cwd = plumbing.cwd ?? process.cwd();
+ const bareRaw = await plumbing.execute({ args: ['rev-parse', '--is-bare-repository'] });
+ const bare = bareRaw.trim() === 'true';
+ let repoRoot = cwd;
+ if (!bare) {
+ try {
+ repoRoot = (await plumbing.execute({ args: ['rev-parse', '--show-toplevel'] })).trim();
+ } catch {
+ repoRoot = cwd;
+ }
+ }
+ return {
+ cwd: repoRoot,
+ gitDir: bare ? repoRoot : await resolveWorktreeGitDir(repoRoot),
+ bare,
+ };
+}
+
+/**
+ * Parse null-delimited Git output into raw repo-relative paths.
+ *
+ * @param {string} output
+ * @returns {string[]}
+ */
+function parseNullPaths(output) {
+ return output.split('\0').filter(Boolean);
+}
+
+/**
+ * Normalize a repo-relative path and return its top-level label.
+ *
+ * @param {string} repoPath
+ * @returns {string}
+ */
+/**
+ * Recursively collect file records from the filesystem without following
+ * symlinks. Directories contribute their leaf files so the treemap can drill
+ * deeper instead of stopping at one opaque directory tile.
+ *
+ * @param {string} targetPath
+ * @param {string[]} segments
+ * @returns {Promise>}
+ */
+async function collectFilesystemRecords(targetPath, segments) {
+ let stat;
+ try {
+ stat = await lstat(targetPath);
+ } catch {
+ return [];
+ }
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
+ return [{ segments, value: stat.size }];
+ }
+ let entries;
+ try {
+ entries = await readdir(targetPath, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+ return (await Promise.all(entries.map((entry) =>
+ collectFilesystemRecords(path.join(targetPath, entry.name), [...segments, entry.name])))).flat();
+}
+
+/**
+ * Collect Git-reported worktree records for tracked or ignored mode.
+ *
+ * Tracked mode stays faithful to `git ls-files`. Ignored mode recursively
+ * expands ignored directories so the treemap can drill deeper than the single
+ * top-level bucket returned by Git.
+ *
+ * @param {{
+ * plumbing: { execute: ({ args }: { args: string[] }) => Promise },
+ * repo: { cwd: string, bare: boolean },
+ * worktreeMode: TreemapWorktreeMode,
+ * }} options
+ * @returns {Promise<{ records: HierarchyRecord[], pathCount: number }>}
+ */
+async function collectWorktreeRecords({ plumbing, repo, worktreeMode }) {
+ if (repo.bare) {
+ return { records: [], pathCount: 0 };
+ }
+
+ const args = worktreeMode === 'ignored'
+ ? ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '--no-empty-directory', '-z']
+ : ['ls-files', '-z'];
+
+ let output = '';
+ try {
+ output = await plumbing.execute({ args });
+ } catch {
+ return { records: [], pathCount: 0 };
+ }
+
+ const repoPaths = parseNullPaths(output);
+ const rawRecords = (await Promise.all(repoPaths.map(async (repoPath) => {
+ const normalizedPath = repoPath.replace(/\\/g, '/').replace(/\/+$/, '');
+ if (!normalizedPath || normalizedPath === '.git' || normalizedPath.startsWith('.git/')) {
+ return [];
+ }
+
+ const fullPath = path.join(repo.cwd, normalizedPath);
+ const stat = await lstat(fullPath).catch(() => null);
+ if (!stat) {
+ return [];
+ }
+
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
+ return collectFilesystemRecords(fullPath, normalizedPath.split('/'));
+ }
+
+ return [{ segments: normalizedPath.split('/'), value: stat.size }];
+ }))).flat();
+
+ return {
+ records: rawRecords.map((record) => ({
+ ...record,
+ detail: `1 ${worktreeMode} path · ${formatBytes(record.value)} on disk`,
+ })),
+ pathCount: repoPaths.length,
+ };
+}
+
+/**
+ * Collect git-dir records so repository treemap drill-down can move from
+ * `.git/objects` to packfiles and loose-object fanout directories.
+ *
+ * @param {{ gitDir: string, bare: boolean }} repo
+ * @returns {Promise}
+ */
+async function collectGitRecords(repo) {
+ let entries;
+ try {
+ entries = await readdir(repo.gitDir, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+
+ const rawRecords = (await Promise.all(entries.map(async (entry) => {
+ if (entry.name === 'refs') {
+ return [];
+ }
+ const rootLabel = repo.bare ? entry.name : `.git/${entry.name}`;
+ return collectFilesystemRecords(path.join(repo.gitDir, entry.name), [rootLabel]);
+ }))).flat();
+
+ return rawRecords.map((record) => ({
+ ...record,
+ detail: `${formatBytes(record.value)} on disk`,
+ }));
+}
+
+/**
+ * Collect one hierarchy record per ref for repository treemap drill-down.
+ *
+ * @param {RefInventory} inventory
+ * @returns {HierarchyRecord[]}
+ */
+function collectRefRecords(inventory) {
+ return inventory.refs.map((ref) => ({
+ segments: refSegments(ref.ref),
+ value: Math.max(1, 4096),
+ detail: `${ref.browsable ? 'browsable' : 'opaque'} · ${ref.detail} · ${shortOid(ref.oid)}`,
+ }));
+}
+
+/**
+ * Collect logical source records keyed by slug path.
+ *
+ * @param {Array} records
+ * @returns {HierarchyRecord[]}
+ */
+function collectLogicalRecords(records) {
+ return records.map((record) => {
+ const data = manifestData(record.manifest);
+ const format = data.compression?.algorithm ?? 'raw';
+ const crypto = data.encryption ? 'enc' : 'plain';
+ return {
+ segments: record.slug.split('/').filter(Boolean),
+ value: Math.max(1, record.size),
+ detail: `${formatBytes(record.size)} logical · ${data.chunks?.length ?? 0} chunks · ${crypto}/${format}`,
+ };
+ });
+}
+
+/**
+ * Build one visible hierarchy level from leaf records.
+ *
+ * @param {HierarchyRecord[]} records
+ * @param {{
+ * kind: Exclude,
+ * prefixSegments?: string[],
+ * aggregateDetail: (bucket: { segments: string[], records: HierarchyRecord[], value: number }) => string,
+ * }} options
+ * @returns {RepoTreemapTile[]}
+ */
+function buildHierarchyTiles(records, options) {
+ const prefixSegments = options.prefixSegments ?? [];
+ const buckets = new Map();
+
+ for (const record of records) {
+ if (!segmentsStartWith(record.segments, prefixSegments)) {
+ continue;
+ }
+ if (record.segments.length <= prefixSegments.length) {
+ continue;
+ }
+ const childSegments = [...prefixSegments, record.segments[prefixSegments.length]];
+ const key = tileId(options.kind, childSegments);
+ const bucket = buckets.get(key) ?? { segments: childSegments, records: [], value: 0 };
+ bucket.records.push(record);
+ bucket.value += record.value;
+ buckets.set(key, bucket);
+ }
+
+ return Array.from(buckets.values())
+ .map((bucket) => {
+ const leaf = bucket.records.length === 1 && bucket.records[0].segments.length === bucket.segments.length
+ ? bucket.records[0]
+ : null;
+ return {
+ id: tileId(options.kind, bucket.segments),
+ label: bucket.segments[bucket.segments.length - 1] ?? '',
+ kind: options.kind,
+ value: Math.max(1, bucket.value),
+ detail: leaf ? leaf.detail : options.aggregateDetail(bucket),
+ drillable: !leaf,
+ path: pathNode(options.kind, bucket.segments),
+ };
+ })
+ .sort((left, right) => right.value - left.value || left.label.localeCompare(right.label));
+}
+
+/**
+ * Load manifests for explorer entries so logical sizes can be summarized.
+ *
+ * @param {ContentAddressableStore} cas
+ * @param {ExplorerEntry[]} entries
+ * @returns {Promise>}
+ */
+async function loadEntryRecords(cas, entries) {
+ return Promise.all(entries.map(async (entry) => {
+ const manifest = await cas.readManifest({ treeOid: entry.treeOid });
+ const data = manifestData(manifest);
+ return {
+ ...entry,
+ manifest,
+ size: data.size ?? 0,
+ };
+ }));
+}
+
+/**
+ * Collapse low-value tiles into a single "other" bucket so the treemap stays legible.
+ *
+ * @param {RepoTreemapTile[]} tiles
+ * @param {number} limit
+ * @returns {RepoTreemapTile[]}
+ */
+function compactTiles(tiles, limit = 14) {
+ const sorted = [...tiles].sort((left, right) => right.value - left.value || left.label.localeCompare(right.label));
+ if (sorted.length <= limit) {
+ return sorted;
+ }
+ const kept = sorted.slice(0, limit - 1);
+ const remainder = sorted.slice(limit - 1);
+ const otherValue = remainder.reduce((sum, tile) => sum + tile.value, 0);
+ kept.push({
+ id: 'meta:other',
+ label: 'other',
+ kind: 'meta',
+ value: Math.max(1, otherValue),
+ detail: `${remainder.length} smaller regions`,
+ drillable: false,
+ path: null,
+ });
+ return kept;
+}
+
+/**
+ * Aggregate detail line for worktree hierarchy buckets.
+ *
+ * @param {TreemapWorktreeMode} worktreeMode
+ * @param {{ records: HierarchyRecord[], value: number }} bucket
+ * @returns {string}
+ */
+function worktreeAggregateDetail(worktreeMode, bucket) {
+ return `${bucket.records.length} ${worktreeMode} path${bucket.records.length === 1 ? '' : 's'} · ${formatBytes(bucket.value)} on disk`;
+}
+
+/**
+ * Aggregate detail line for git-dir hierarchy buckets.
+ *
+ * @param {{ records: HierarchyRecord[], value: number }} bucket
+ * @returns {string}
+ */
+function gitAggregateDetail(bucket) {
+ return `${bucket.records.length} git item${bucket.records.length === 1 ? '' : 's'} · ${formatBytes(bucket.value)} on disk`;
+}
+
+/**
+ * Aggregate detail line for ref hierarchy buckets.
+ *
+ * @param {{ records: HierarchyRecord[] }} bucket
+ * @returns {string}
+ */
+function refAggregateDetail(bucket) {
+ return `${bucket.records.length} ref${bucket.records.length === 1 ? '' : 's'}`;
+}
+
+/**
+ * Aggregate detail line for logical CAS hierarchy buckets.
+ *
+ * @param {{ records: HierarchyRecord[], value: number }} bucket
+ * @returns {string}
+ */
+function logicalAggregateDetail(bucket) {
+ return `${bucket.records.length} entr${bucket.records.length === 1 ? 'y' : 'ies'} · ${formatBytes(bucket.value)} logical`;
+}
+
+/**
+ * Build repository-scope notes.
+ *
+ * @param {{ gitDir: string, bare: boolean }} repo
+ * @param {TreemapWorktreeMode} worktreeMode
+ * @param {TreemapPathNode[]} drillPath
+ * @returns {string[]}
+ */
+function buildRepositoryNotes(repo, worktreeMode, drillPath) {
+ return [
+ drillPath.length > 0
+ ? `Drilled into ${drillLabel(drillPath)}. Press - to go up a level.`
+ : 'Repository view mixes Git-reported worktree paths, .git on-disk bytes, ref namespaces, and logical CAS region sizes.',
+ 'Press r to browse refs and switch the dashboard source to a CAS-backed ref.',
+ repo.bare
+ ? 'Bare repository: worktree regions are omitted.'
+ : `Worktree mode ${worktreeMode} via ${worktreeMode === 'tracked' ? 'git ls-files' : 'git ls-files --others --ignored --exclude-standard'}.`,
+ `Git dir ${repo.gitDir}`,
+ ];
+}
+
+/**
+ * Tile kind used for logical source treemap nodes.
+ *
+ * @param {DashSource} source
+ * @returns {'vault' | 'cas'}
+ */
+function logicalSourceKind(source) {
+ return source.type === 'vault' ? 'vault' : 'cas';
+}
+
+/**
+ * Human-readable source target used in notes and empty states.
+ *
+ * @param {DashSource} source
+ * @returns {string}
+ */
+function sourceTarget(source) {
+ if (source.type === 'vault') {
+ return 'the vault';
+ }
+ return source.type === 'ref' ? source.ref : source.treeOid;
+}
+
+/**
+ * Empty source fallback tile.
+ *
+ * @returns {RepoTreemapTile}
+ */
+function emptySourceTile() {
+ return {
+ id: 'meta:empty-source',
+ label: 'empty source',
+ kind: 'meta',
+ value: 1,
+ detail: 'No CAS entries resolved for this source',
+ drillable: false,
+ path: null,
+ };
+}
+
+/**
+ * Explanatory note lines for source scope.
+ *
+ * @param {{ source: DashSource, sourceResult: { entries: ExplorerEntry[] }, drillPath: TreemapPathNode[] }} options
+ * @returns {string[]}
+ */
+function buildSourceNotes({ source, sourceResult, drillPath }) {
+ const firstLine = sourceResult.entries.length === 0
+ ? `No CAS entries resolved for ${sourceTarget(source)}. Press r to browse refs or T to return to repository scope.`
+ : drillPath.length > 0
+ ? `Drilled into ${drillLabel(drillPath)}. Press - to go up a level.`
+ : `Loaded ${sourceResult.entries.length} source entr${sourceResult.entries.length === 1 ? 'y' : 'ies'} for ${sourceTarget(source)}.`;
+
+ return [
+ firstLine,
+ 'Source view weights tiles by logical manifest size.',
+ ];
+}
+
+/**
+ * Summary block for source scope reports.
+ *
+ * @param {{
+ * repo: { bare: boolean, gitDir: string },
+ * source: DashSource,
+ * sourceResult: { entries: ExplorerEntry[] },
+ * }} options
+ * @returns {RepoTreemapReport['summary']}
+ */
+function buildSourceSummary({ repo, source, sourceResult }) {
+ return {
+ bare: repo.bare,
+ gitDir: repo.gitDir,
+ worktreeItems: 0,
+ worktreePaths: 0,
+ refNamespaces: 0,
+ refCount: 0,
+ vaultEntries: source.type === 'vault' ? sourceResult.entries.length : 0,
+ sourceEntries: sourceResult.entries.length,
+ };
+}
+
+/**
+ * Build the source-focused treemap report.
+ *
+ * @param {{
+ * repo: { cwd: string, gitDir: string, bare: boolean },
+ * source: DashSource,
+ * sourceResult: { entries: ExplorerEntry[] },
+ * sourceRecords: Array,
+ * drillPath: TreemapPathNode[],
+ * }} options
+ * @returns {RepoTreemapReport}
+ */
+function buildSourceScopeReport({ repo, source, sourceResult, sourceRecords, drillPath }) {
+ const logicalKind = logicalSourceKind(source);
+ const prefixSegments = drillPath[drillPath.length - 1]?.segments ?? [];
+ const sourceTiles = compactTiles(buildHierarchyTiles(collectLogicalRecords(sourceRecords), {
+ kind: logicalKind,
+ prefixSegments,
+ aggregateDetail: logicalAggregateDetail,
+ }), drillPath.length > 0 ? 24 : 18);
+ const totalValue = sourceTiles.reduce((sum, tile) => sum + tile.value, 0);
+ return {
+ scope: 'source',
+ worktreeMode: 'tracked',
+ cwd: repo.cwd,
+ source,
+ drillPath,
+ breadcrumb: ['source', ...drillPath.map((node) => node.label)],
+ totalValue,
+ tiles: sourceTiles.length > 0 ? sourceTiles : [emptySourceTile()],
+ notes: buildSourceNotes({ source, sourceResult, drillPath }),
+ summary: buildSourceSummary({ repo, source, sourceResult }),
+ };
+}
+
+/**
+ * Worktree treemap options for one hierarchy level.
+ *
+ * @param {TreemapWorktreeMode} worktreeMode
+ * @param {string[]} [prefixSegments]
+ * @returns {{
+ * kind: 'worktree',
+ * prefixSegments?: string[],
+ * aggregateDetail: (bucket: { records: HierarchyRecord[], value: number }) => string,
+ * }}
+ */
+function worktreeTileOptions(worktreeMode, prefixSegments = []) {
+ return {
+ kind: 'worktree',
+ prefixSegments,
+ aggregateDetail: (bucket) => worktreeAggregateDetail(worktreeMode, bucket),
+ };
+}
+
+/**
+ * Repository root tiles across worktree, git, refs, vault, and source data.
+ *
+ * @param {{
+ * worktreeRecords: HierarchyRecord[],
+ * gitRecords: HierarchyRecord[],
+ * refRecords: HierarchyRecord[],
+ * vaultLogicalRecords: HierarchyRecord[],
+ * sourceLogicalRecords: HierarchyRecord[],
+ * worktreeMode: TreemapWorktreeMode,
+ * }} options
+ * @returns {RepoTreemapTile[]}
+ */
+function buildRepositoryRootTiles(options) {
+ return compactTiles([
+ ...buildHierarchyTiles(options.worktreeRecords, worktreeTileOptions(options.worktreeMode)),
+ ...buildHierarchyTiles(options.gitRecords, {
+ kind: 'git',
+ aggregateDetail: gitAggregateDetail,
+ }),
+ ...buildHierarchyTiles(options.refRecords, {
+ kind: 'ref',
+ aggregateDetail: refAggregateDetail,
+ }),
+ ...buildHierarchyTiles(options.vaultLogicalRecords, {
+ kind: 'vault',
+ aggregateDetail: logicalAggregateDetail,
+ }),
+ ...buildHierarchyTiles(options.sourceLogicalRecords, {
+ kind: 'cas',
+ aggregateDetail: logicalAggregateDetail,
+ }),
+ ], 18);
+}
+
+/**
+ * Drill one repository category deeper based on the selected treemap node.
+ *
+ * @param {{
+ * currentNode: TreemapPathNode,
+ * worktreeRecords: HierarchyRecord[],
+ * gitRecords: HierarchyRecord[],
+ * refRecords: HierarchyRecord[],
+ * vaultLogicalRecords: HierarchyRecord[],
+ * sourceLogicalRecords: HierarchyRecord[],
+ * worktreeMode: TreemapWorktreeMode,
+ * }} options
+ * @returns {RepoTreemapTile[]}
+ */
+function buildRepositoryDrillTiles(options) {
+ const tileBuilders = {
+ worktree: () => buildHierarchyTiles(options.worktreeRecords, worktreeTileOptions(options.worktreeMode, options.currentNode.segments)),
+ git: () => buildHierarchyTiles(options.gitRecords, {
+ kind: 'git',
+ prefixSegments: options.currentNode.segments,
+ aggregateDetail: gitAggregateDetail,
+ }),
+ ref: () => buildHierarchyTiles(options.refRecords, {
+ kind: 'ref',
+ prefixSegments: options.currentNode.segments,
+ aggregateDetail: refAggregateDetail,
+ }),
+ vault: () => buildHierarchyTiles(options.vaultLogicalRecords, {
+ kind: 'vault',
+ prefixSegments: options.currentNode.segments,
+ aggregateDetail: logicalAggregateDetail,
+ }),
+ cas: () => buildHierarchyTiles(options.sourceLogicalRecords, {
+ kind: 'cas',
+ prefixSegments: options.currentNode.segments,
+ aggregateDetail: logicalAggregateDetail,
+ }),
+ };
+
+ return compactTiles(tileBuilders[options.currentNode.kind](), 24);
+}
+
+/**
+ * Summary block for repository scope reports.
+ *
+ * @param {{
+ * repo: { bare: boolean, gitDir: string },
+ * worktreeRecords: HierarchyRecord[],
+ * worktreePaths: number,
+ * refInventory: RefInventory,
+ * vaultEntries: number,
+ * sourceEntries: number,
+ * worktreeMode: TreemapWorktreeMode,
+ * }} options
+ * @returns {RepoTreemapReport['summary']}
+ */
+function buildRepositorySummary(options) {
+ return {
+ bare: options.repo.bare,
+ gitDir: options.repo.gitDir,
+ worktreeItems: buildHierarchyTiles(options.worktreeRecords, worktreeTileOptions(options.worktreeMode)).length,
+ worktreePaths: options.worktreePaths,
+ refNamespaces: options.refInventory.namespaces.length,
+ refCount: options.refInventory.refs.length,
+ vaultEntries: options.vaultEntries,
+ sourceEntries: options.sourceEntries,
+ };
+}
+
+/**
+ * Data inputs needed to build repository treemap tiles.
+ *
+ * @param {{
+ * cas: ContentAddressableStore,
+ * source: DashSource,
+ * repo: { cwd: string, gitDir: string, bare: boolean },
+ * plumbing: { execute: ({ args }: { args: string[] }) => Promise },
+ * sourceResult: { entries: ExplorerEntry[] },
+ * sourceRecords: Array,
+ * worktreeMode: TreemapWorktreeMode,
+ * }} options
+ * @returns {Promise<{
+ * worktreeRecords: HierarchyRecord[],
+ * worktreePaths: number,
+ * gitRecords: HierarchyRecord[],
+ * refInventory: RefInventory,
+ * refRecords: HierarchyRecord[],
+ * vaultResult: { entries: ExplorerEntry[] },
+ * vaultLogicalRecords: HierarchyRecord[],
+ * sourceLogicalRecords: HierarchyRecord[],
+ * }>}
+ */
+async function loadRepositoryScopeData({ cas, source, repo, plumbing, sourceResult, sourceRecords, worktreeMode }) {
+ const [{ records: worktreeRecords, pathCount: worktreePaths }, gitRecords, refInventory] = await Promise.all([
+ collectWorktreeRecords({ plumbing, repo, worktreeMode }),
+ collectGitRecords(repo),
+ readRefInventory(cas),
+ ]);
+ const refRecords = collectRefRecords(refInventory);
+ const vaultResult = source.type === 'vault' ? sourceResult : await readSourceEntries(cas, { type: 'vault' });
+ const vaultRecords = source.type === 'vault' ? sourceRecords : await loadEntryRecords(cas, vaultResult.entries);
+
+ return {
+ worktreeRecords,
+ worktreePaths,
+ gitRecords,
+ refInventory,
+ refRecords,
+ vaultResult,
+ vaultLogicalRecords: collectLogicalRecords(vaultRecords),
+ sourceLogicalRecords: source.type === 'vault' ? [] : collectLogicalRecords(sourceRecords),
+ };
+}
+
+/**
+ * Empty repository fallback tile.
+ *
+ * @returns {RepoTreemapTile[]}
+ */
+function emptyRepositoryTiles() {
+ return [{
+ id: 'meta:empty-repo',
+ label: 'empty repo',
+ kind: 'meta',
+ value: 1,
+ detail: 'No worktree, ref, or CAS regions were detected',
+ drillable: false,
+ path: null,
+ }];
+}
+
+/**
+ * Final repository-scope report object.
+ *
+ * @param {{
+ * repo: { cwd: string, gitDir: string, bare: boolean },
+ * source: DashSource,
+ * worktreeMode: TreemapWorktreeMode,
+ * drillPath: TreemapPathNode[],
+ * tiles: RepoTreemapTile[],
+ * worktreeRecords: HierarchyRecord[],
+ * worktreePaths: number,
+ * refInventory: RefInventory,
+ * vaultEntries: number,
+ * sourceEntries: number,
+ * }} options
+ * @returns {RepoTreemapReport}
+ */
+function repositoryScopeReport(options) {
+ return {
+ scope: 'repository',
+ worktreeMode: options.worktreeMode,
+ cwd: options.repo.cwd,
+ source: options.source,
+ drillPath: options.drillPath,
+ breadcrumb: ['repository', ...options.drillPath.map((node) => node.label)],
+ totalValue: options.tiles.reduce((sum, tile) => sum + tile.value, 0),
+ tiles: options.tiles.length > 0 ? options.tiles : emptyRepositoryTiles(),
+ notes: buildRepositoryNotes(options.repo, options.worktreeMode, options.drillPath),
+ summary: buildRepositorySummary({
+ repo: options.repo,
+ worktreeRecords: options.worktreeRecords,
+ worktreePaths: options.worktreePaths,
+ refInventory: options.refInventory,
+ vaultEntries: options.vaultEntries,
+ sourceEntries: options.sourceEntries,
+ worktreeMode: options.worktreeMode,
+ }),
+ };
+}
+
+/**
+ * Visible tiles for the current repository drill level.
+ *
+ * @param {{
+ * drillPath: TreemapPathNode[],
+ * worktreeRecords: HierarchyRecord[],
+ * gitRecords: HierarchyRecord[],
+ * refRecords: HierarchyRecord[],
+ * vaultLogicalRecords: HierarchyRecord[],
+ * sourceLogicalRecords: HierarchyRecord[],
+ * worktreeMode: TreemapWorktreeMode,
+ * }} options
+ * @returns {RepoTreemapTile[]}
+ */
+function repositoryTilesForDrillPath(options) {
+ const currentNode = options.drillPath[options.drillPath.length - 1] ?? null;
+ return currentNode
+ ? buildRepositoryDrillTiles({
+ currentNode,
+ worktreeRecords: options.worktreeRecords,
+ gitRecords: options.gitRecords,
+ refRecords: options.refRecords,
+ vaultLogicalRecords: options.vaultLogicalRecords,
+ sourceLogicalRecords: options.sourceLogicalRecords,
+ worktreeMode: options.worktreeMode,
+ })
+ : buildRepositoryRootTiles({
+ worktreeRecords: options.worktreeRecords,
+ gitRecords: options.gitRecords,
+ refRecords: options.refRecords,
+ vaultLogicalRecords: options.vaultLogicalRecords,
+ sourceLogicalRecords: options.sourceLogicalRecords,
+ worktreeMode: options.worktreeMode,
+ });
+}
+
+/**
+ * Build repository-scope physical and logical tiles.
+ *
+ * @param {{
+ * cas: ContentAddressableStore,
+ * source: DashSource,
+ * repo: { cwd: string, gitDir: string, bare: boolean },
+ * plumbing: { execute: ({ args }: { args: string[] }) => Promise },
+ * sourceResult: { entries: ExplorerEntry[] },
+ * sourceRecords: Array,
+ * worktreeMode: TreemapWorktreeMode,
+ * drillPath: TreemapPathNode[],
+ * }} options
+ * @returns {Promise}
+ */
+async function buildRepositoryScopeReport({ cas, source, repo, plumbing, sourceResult, sourceRecords, worktreeMode, drillPath }) {
+ const {
+ worktreeRecords,
+ worktreePaths,
+ gitRecords,
+ refInventory,
+ refRecords,
+ vaultResult,
+ vaultLogicalRecords,
+ sourceLogicalRecords,
+ } = await loadRepositoryScopeData({
+ cas,
+ source,
+ repo,
+ plumbing,
+ sourceResult,
+ sourceRecords,
+ worktreeMode,
+ });
+ const tiles = repositoryTilesForDrillPath({
+ drillPath,
+ worktreeRecords,
+ gitRecords,
+ refRecords,
+ vaultLogicalRecords,
+ sourceLogicalRecords,
+ worktreeMode,
+ });
+ return repositoryScopeReport({
+ repo,
+ source,
+ worktreeMode,
+ drillPath,
+ tiles,
+ worktreeRecords,
+ worktreePaths,
+ refInventory,
+ vaultEntries: vaultResult.entries.length,
+ sourceEntries: sourceResult.entries.length,
+ });
+}
+
+/**
+ * Convert one `show-ref` line into a browsable or opaque ref record.
+ *
+ * @param {ContentAddressableStore} cas
+ * @param {{ service: { persistence: any }, vault: { ref: any } }} ports
+ * @param {string} line
+ * @returns {Promise}
+ */
+async function classifyRefLine(cas, ports, line) {
+ const [oid = '', ref = ''] = line.split(' ');
+ try {
+ const result = await resolveSourceDetailed(cas, { type: 'ref', ref }, ports);
+ return {
+ ref,
+ oid,
+ namespace: refNamespace(ref),
+ browsable: true,
+ resolution: result.resolution,
+ entryCount: result.entries.length,
+ detail: describeResolution(result.resolution, result),
+ previewSlugs: result.entries.slice(0, 3).map((entry) => entry.slug),
+ source: { type: 'ref', ref },
+ };
+ } catch (error) {
+ return {
+ ref,
+ oid,
+ namespace: refNamespace(ref),
+ browsable: false,
+ resolution: 'opaque',
+ entryCount: 0,
+ detail: error instanceof Error ? error.message : String(error),
+ previewSlugs: [],
+ source: null,
+ };
+ }
+}
+
+/**
+ * Summarize per-namespace ref counts.
+ *
+ * @param {RefInventoryItem[]} refs
+ * @returns {Array<{ namespace: string, count: number, browsable: number }>}
+ */
+function summarizeRefNamespaces(refs) {
+ const namespaceMap = new Map();
+ for (const ref of refs) {
+ const bucket = namespaceMap.get(ref.namespace) ?? { namespace: ref.namespace, count: 0, browsable: 0 };
+ bucket.count += 1;
+ bucket.browsable += ref.browsable ? 1 : 0;
+ namespaceMap.set(ref.namespace, bucket);
+ }
+ return Array.from(namespaceMap.values()).sort((left, right) => right.count - left.count || left.namespace.localeCompare(right.namespace));
+}
+
+/**
+ * Return true when the provided tree OID resolves to a CAS manifest.
+ *
+ * @param {ContentAddressableStore} cas
+ * @param {string} treeOid
+ * @returns {Promise}
+ */
+async function canReadManifest(cas, treeOid) {
+ try {
+ await cas.readManifest({ treeOid });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Try to resolve a commit/tree-ish object into a tree OID.
+ *
+ * @param {{ resolveTree: (commitOid: string) => Promise }} refPort
+ * @param {string} oid
+ * @returns {Promise}
+ */
+async function tryResolveTree(refPort, oid) {
+ try {
+ return await refPort.resolveTree(oid);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Read and parse a JSON blob by object ID.
+ *
+ * @param {{ readBlob: (oid: string) => Promise }} persistence
+ * @param {string} oid
+ * @returns {Promise}
+ */
+async function tryReadJsonBlob(persistence, oid) {
+ try {
+ const blob = await persistence.readBlob(oid);
+ return JSON.parse(blob.toString('utf8'));
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Normalize a JSON entry into explorer rows.
+ *
+ * @param {unknown} value
+ * @param {string} fallbackSlug
+ * @returns {ExplorerEntry | null}
+ */
+function toIndexedEntry(value, fallbackSlug) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return null;
+ }
+ const record = /** @type {Record} */ (value);
+ if (typeof record.treeOid !== 'string') {
+ return null;
+ }
+ return {
+ slug: typeof record.slug === 'string'
+ ? record.slug
+ : typeof record.key === 'string'
+ ? record.key
+ : typeof record.id === 'string'
+ ? record.id
+ : fallbackSlug,
+ treeOid: record.treeOid,
+ };
+}
+
+/**
+ * Extract CAS tree references from common JSON index shapes.
+ *
+ * Supports:
+ * - `{ treeOid }`
+ * - `{ entries: { key: { treeOid } } }`
+ * - `{ entries: [{ treeOid, slug? | key? | id? }] }`
+ * - `[{ treeOid, ... }]`
+ *
+ * @param {unknown} json
+ * @param {string} label
+ * @returns {ExplorerEntry[]}
+ */
+function extractJsonEntries(json, label) {
+ const entries = [];
+
+ const direct = toIndexedEntry(json, label);
+ if (direct) {
+ entries.push(direct);
+ }
+
+ if (Array.isArray(json)) {
+ return json
+ .map((item, index) => toIndexedEntry(item, `${label}#${index + 1}`))
+ .filter(Boolean)
+ .sort((left, right) => left.slug.localeCompare(right.slug));
+ }
+
+ if (!json || typeof json !== 'object') {
+ return entries;
+ }
+
+ const record = /** @type {Record} */ (json);
+ if (Array.isArray(record.entries)) {
+ return record.entries
+ .map((item, index) => toIndexedEntry(item, `${label}#${index + 1}`))
+ .filter(Boolean)
+ .sort((left, right) => left.slug.localeCompare(right.slug));
+ }
+
+ if (record.entries && typeof record.entries === 'object' && !Array.isArray(record.entries)) {
+ return Object.entries(record.entries)
+ .map(([key, value]) => toIndexedEntry(value, key))
+ .filter(Boolean)
+ .sort((left, right) => left.slug.localeCompare(right.slug));
+ }
+
+ return entries;
+}
+
+/**
+ * Parse a manifest tree hint out of a commit message.
+ *
+ * @param {{ plumbing?: { execute: ({ args }: { args: string[] }) => Promise } }} persistence
+ * @param {string} oid
+ * @returns {Promise}
+ */
+async function tryReadManifestHint(persistence, oid) {
+ if (!persistence.plumbing || typeof persistence.plumbing.execute !== 'function') {
+ return null;
+ }
+ try {
+ const message = await persistence.plumbing.execute({
+ args: ['show', '-s', '--format=%B', oid],
+ });
+ const match = message.match(/^\s*manifest:\s*([0-9a-f]{7,64})\s*$/mi);
+ return match ? match[1] : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Resolve a direct OID source.
+ *
+ * @param {Extract} source
+ * @returns {{ entries: ExplorerEntry[], metadata: any, resolution: RefResolutionKind, targetTreeOid: string }}
+ */
+function resolveOidSourceDetailed(source) {
+ return {
+ ...singleEntrySource(`oid:${shortOid(source.treeOid)}`, source.treeOid),
+ resolution: 'oid',
+ targetTreeOid: source.treeOid,
+ };
+}
+
+/**
+ * Resolve a ref source into CAS entries.
+ *
+ * @param {ContentAddressableStore} cas
+ * @param {Extract} source
+ * @param {{ service: { persistence: any }, vault: { ref: any } }} ports
+ * @returns {Promise<{ entries: ExplorerEntry[], metadata: any, resolution: RefResolutionKind, resolvedOid: string, targetTreeOid?: string | null }>}
+ */
+async function resolveRefSourceDetailed(cas, source, ports) {
+ const { service, vault } = ports;
+ const resolvedOid = await vault.ref.resolveRef(source.ref);
+
+ if (await canReadManifest(cas, resolvedOid)) {
+ return {
+ ...singleEntrySource(source.ref, resolvedOid),
+ resolution: 'manifest',
+ resolvedOid,
+ targetTreeOid: resolvedOid,
+ };
+ }
+
+ const treeOid = await tryResolveTree(vault.ref, resolvedOid);
+ if (treeOid && await canReadManifest(cas, treeOid)) {
+ return {
+ ...singleEntrySource(`${source.ref}^{tree}`, treeOid),
+ resolution: 'tree',
+ resolvedOid,
+ targetTreeOid: treeOid,
+ };
+ }
+
+ const indexed = extractJsonEntries(await tryReadJsonBlob(service.persistence, resolvedOid), source.ref);
+ if (indexed.length > 0) {
+ return {
+ entries: indexed,
+ metadata: null,
+ resolution: 'index',
+ resolvedOid,
+ targetTreeOid: null,
+ };
+ }
+
+ const hintedTreeOid = await tryReadManifestHint(service.persistence, resolvedOid);
+ if (hintedTreeOid) {
+ return {
+ ...singleEntrySource(source.ref, hintedTreeOid),
+ resolution: 'hint',
+ resolvedOid,
+ targetTreeOid: hintedTreeOid,
+ };
+ }
+
+ throw new Error(`Ref ${source.ref} did not resolve to a vault, CAS tree, supported CAS index, or manifest hint`);
+}
+
+/**
+ * Resolve dashboard entries for a source and include metadata about how the
+ * source was derived.
+ *
+ * @param {ContentAddressableStore} cas
+ * @param {DashSource} source
+ * @param {{ service?: any, vault?: any }} [ports]
+ * @returns {Promise<{ entries: ExplorerEntry[], metadata: any, resolution: RefResolutionKind, resolvedOid?: string, targetTreeOid?: string | null }>}
+ */
+async function resolveSourceDetailed(cas, source, ports = {}) {
+ if (source.type === 'oid') {
+ return resolveOidSourceDetailed(source);
+ }
+
+ const service = ports.service ?? await cas.getService();
+ const vault = ports.vault ?? await cas.getVaultService();
+ return resolveRefSourceDetailed(cas, source, { service, vault });
+}
+
+/**
+ * Resolve dashboard entries for a non-vault source.
+ *
+ * @param {ContentAddressableStore} cas
+ * @param {{ type: 'ref', ref: string } | { type: 'oid', treeOid: string }} source
+ * @returns {Promise<{ entries: ExplorerEntry[], metadata: any }>}
+ */
+async function resolveNonVaultSource(cas, source) {
+ const result = await resolveSourceDetailed(cas, source);
+ return { entries: result.entries, metadata: result.metadata };
+}
+
+/**
+ * Resolve dashboard entries for the requested source.
+ *
+ * @param {ContentAddressableStore} cas
+ * @param {DashSource} source
+ * @returns {Promise<{ entries: ExplorerEntry[], metadata: any }>}
+ */
+export async function readSourceEntries(cas, source = { type: 'vault' }) {
+ if (source.type === 'vault') {
+ const [entries, metadata] = await Promise.all([
+ cas.listVault(),
+ cas.getVaultMetadata(),
+ ]);
+ return { entries, metadata };
+ }
+ return resolveNonVaultSource(cas, source);
+}
+
+/**
+ * Read and classify refs so the dashboard can browse namespaces and switch the
+ * active source to CAS-backed refs.
+ *
+ * @param {ContentAddressableStore} cas
+ * @returns {Promise}
+ */
+export async function readRefInventory(cas) {
+ const [service, vault] = await Promise.all([
+ cas.getService(),
+ cas.getVaultService(),
+ ]);
+
+ let output = '';
+ try {
+ output = await service.persistence.plumbing.execute({ args: ['show-ref'] });
+ } catch {
+ return { namespaces: [], refs: [] };
+ }
+
+ const refs = await Promise.all(output
+ .split('\n')
+ .map((row) => row.trim())
+ .filter(Boolean)
+ .map((line) => classifyRefLine(cas, { service, vault }, line)));
+
+ return {
+ namespaces: summarizeRefNamespaces(refs),
+ refs: refs.sort((left, right) =>
+ Number(right.browsable) - Number(left.browsable)
+ || left.namespace.localeCompare(right.namespace)
+ || left.ref.localeCompare(right.ref)),
+ };
+}
+
+/**
+ * Build the semantic repo/source treemap report for the dashboard.
+ *
+ * @param {ContentAddressableStore} cas
+ * @param {{
+ * source?: DashSource,
+ * scope?: TreemapScope,
+ * worktreeMode?: TreemapWorktreeMode,
+ * drillPath?: TreemapPathNode[],
+ * }} [options]
+ * @returns {Promise}
+ */
+export async function buildRepoTreemapReport(cas, options = {}) {
+ const {
+ source = { type: 'vault' },
+ scope = 'repository',
+ worktreeMode = 'tracked',
+ drillPath = [],
+ } = options;
+ const service = await cas.getService();
+ const repo = await resolveRepoInfo(service.persistence.plumbing);
+ const sourceResult = await readSourceEntries(cas, source);
+ const sourceRecords = await loadEntryRecords(cas, sourceResult.entries);
+
+ if (scope === 'source') {
+ return buildSourceScopeReport({ repo, source, sourceResult, sourceRecords, drillPath });
+ }
+ return buildRepositoryScopeReport({
+ cas,
+ source,
+ repo,
+ plumbing: service.persistence.plumbing,
+ sourceResult,
+ sourceRecords,
+ worktreeMode,
+ drillPath,
+ });
+}
/**
* Load vault entries and metadata in parallel.
*
* @param {ContentAddressableStore} cas
+ * @param {DashSource} [source]
*/
-export function loadEntriesCmd(cas) {
+export function loadEntriesCmd(cas, source = { type: 'vault' }) {
return async () => {
try {
- const [entries, metadata] = await Promise.all([
- cas.listVault(),
- cas.getVaultMetadata(),
- ]);
- return /** @type {const} */ ({ type: 'loaded-entries', entries, metadata });
+ const { entries, metadata } = await readSourceEntries(cas, source);
+ return /** @type {const} */ ({ type: 'loaded-entries', entries, metadata, source });
} catch (/** @type {any} */ err) {
- return /** @type {const} */ ({ type: 'load-error', source: 'entries', error: /** @type {Error} */ (err).message });
+ return /** @type {const} */ ({ type: 'load-error', source: 'entries', forSource: source, error: /** @type {Error} */ (err).message });
}
};
}
@@ -27,16 +1431,113 @@ export function loadEntriesCmd(cas) {
* Load a single manifest by slug and tree OID.
*
* @param {ContentAddressableStore} cas
- * @param {string} slug
- * @param {string} treeOid
+ * @param {{ slug: string, treeOid: string, source: DashSource }} request
+ */
+export function loadManifestCmd(cas, request) {
+ return async () => {
+ try {
+ const manifest = await cas.readManifest({ treeOid: request.treeOid });
+ return /** @type {const} */ ({ type: 'loaded-manifest', slug: request.slug, manifest, source: request.source });
+ } catch (/** @type {any} */ err) {
+ return /** @type {const} */ ({ type: 'load-error', source: 'manifest', slug: request.slug, forSource: request.source, error: /** @type {Error} */ (err).message });
+ }
+ };
+}
+
+/**
+ * Load the current repository ref inventory for the dashboard refs browser.
+ *
+ * @param {ContentAddressableStore} cas
+ */
+export function loadRefsCmd(cas) {
+ return async () => {
+ try {
+ const refs = await readRefInventory(cas);
+ return /** @type {const} */ ({ type: 'loaded-refs', refs });
+ } catch (/** @type {any} */ err) {
+ return /** @type {const} */ ({ type: 'load-error', source: 'refs', error: /** @type {Error} */ (err).message });
+ }
+ };
+}
+
+/**
+ * Load aggregate vault stats for the current vault.
+ *
+ * @param {ContentAddressableStore} cas
+ * @param {ExplorerEntry[]} entries
+ * @param {DashSource} source
+ */
+export function loadStatsCmd(cas, entries, source) {
+ return async () => {
+ try {
+ const records = await Promise.all(entries.map(async (entry) => ({
+ ...entry,
+ manifest: await cas.readManifest({ treeOid: entry.treeOid }),
+ })));
+ return /** @type {const} */ ({ type: 'loaded-stats', stats: buildVaultStats(records), source });
+ } catch (/** @type {any} */ err) {
+ return /** @type {const} */ ({ type: 'load-error', source: 'stats', forSource: source, error: /** @type {Error} */ (err).message });
+ }
+ };
+}
+
+/**
+ * Load the doctor report for the current vault.
+ *
+ * @param {ContentAddressableStore} cas
+ * @param {DashSource} [source]
+ * @param {ExplorerEntry[]} [entries]
+ */
+export function loadDoctorCmd(cas, source = { type: 'vault' }, entries = []) {
+ return async () => {
+ try {
+ if (source.type !== 'vault') {
+ const target = source.type === 'ref' ? source.ref : source.treeOid;
+ const report = `source: ${source.type}\n`
+ + `target: ${target}\n`
+ + `entries: ${entries.length}\n\n`
+ + 'Repo-wide doctor currently targets vault mode. Use this source mode to inspect manifests and source-local stats.';
+ return /** @type {const} */ ({ type: 'loaded-doctor', report, source });
+ }
+ const report = await inspectVaultHealth(cas);
+ return /** @type {const} */ ({ type: 'loaded-doctor', report, source });
+ } catch (/** @type {any} */ err) {
+ return /** @type {const} */ ({ type: 'load-error', source: 'doctor', forSource: source, error: /** @type {Error} */ (err).message });
+ }
+ };
+}
+
+/**
+ * Load the repository/source treemap report for the dashboard drawer.
+ *
+ * @param {ContentAddressableStore} cas
+ * @param {{
+ * source?: DashSource,
+ * scope?: TreemapScope,
+ * worktreeMode?: TreemapWorktreeMode,
+ * drillPath?: TreemapPathNode[],
+ * }} [options]
*/
-export function loadManifestCmd(cas, slug, treeOid) {
+export function loadTreemapCmd(cas, options = {}) {
+ const {
+ source = { type: 'vault' },
+ scope = 'repository',
+ worktreeMode = 'tracked',
+ drillPath = [],
+ } = options;
return async () => {
try {
- const manifest = await cas.readManifest({ treeOid });
- return /** @type {const} */ ({ type: 'loaded-manifest', slug, manifest });
+ const report = await buildRepoTreemapReport(cas, { source, scope, worktreeMode, drillPath });
+ return /** @type {const} */ ({ type: 'loaded-treemap', report });
} catch (/** @type {any} */ err) {
- return /** @type {const} */ ({ type: 'load-error', source: 'manifest', slug, error: /** @type {Error} */ (err).message });
+ return /** @type {const} */ ({
+ type: 'load-error',
+ source: 'treemap',
+ scopeId: scope,
+ worktreeMode,
+ drillPath,
+ error: /** @type {Error} */ (err).message,
+ });
}
};
}
diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js
index 09cb5b4..fbe8012 100644
--- a/bin/ui/dashboard-view.js
+++ b/bin/ui/dashboard-view.js
@@ -2,55 +2,58 @@
* Pure render functions for the vault dashboard.
*/
-import { badge } from '@flyingrobots/bijou';
-import { flex, viewport } from '@flyingrobots/bijou-tui';
+import { boxV3, createSurface, parseAnsiToSurface, kbd } from '@flyingrobots/bijou';
+import { commandPalette, navigableTable, splitPaneLayout } from '@flyingrobots/bijou-tui';
+import { renderRepoTreemapMap, renderRepoTreemapSidebar } from './repo-treemap.js';
+import { GIT_CAS_PALETTE, chipSurface, inlineSurface, sectionHeading, shellRule, themeText } from './theme.js';
+import { renderDoctorReport, renderVaultStats } from './vault-report.js';
import { renderManifestView } from './manifest-view.js';
/**
* @typedef {import('./dashboard.js').DashModel} DashModel
* @typedef {import('./dashboard.js').DashDeps} DashDeps
+ * @typedef {import('./dashboard.js').DashSource} DashSource
* @typedef {import('@flyingrobots/bijou').BijouContext} BijouContext
- * @typedef {import('../../src/domain/value-objects/Manifest.js').default} Manifest
+ * @typedef {import('@flyingrobots/bijou').Surface} Surface
*/
-/**
- * Format bytes as compact string.
- *
- * @param {number} bytes
- * @returns {string}
- */
-function formatSize(bytes) {
- if (bytes < 1024) { return `${bytes}B`; }
- if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)}K`; }
- if (bytes < 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(1)}M`; }
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
-}
+const SPLIT_MIN_LIST_WIDTH = 28;
+const SPLIT_MIN_DETAIL_WIDTH = 32;
+const SPLIT_DIVIDER_SIZE = 1;
+const TOAST_THEME = {
+ error: { label: 'Error', bg: GIT_CAS_PALETTE.wine, fg: GIT_CAS_PALETTE.ivory },
+ warning: { label: 'Warning', bg: [148, 82, 23], fg: GIT_CAS_PALETTE.ivory },
+ info: { label: 'Info', bg: GIT_CAS_PALETTE.indigo, fg: GIT_CAS_PALETTE.ivory },
+ success: { label: 'Success', bg: GIT_CAS_PALETTE.moss, fg: GIT_CAS_PALETTE.ivory },
+};
/**
- * Format manifest stats for the list.
+ * Safely clip text to a pane width.
*
- * @param {Manifest} manifest
* @returns {string}
*/
-function formatStats(manifest) {
- const m = manifest.toJSON ? manifest.toJSON() : manifest;
- return `${formatSize(m.size)} ${m.chunks?.length ?? 0}c`;
+function clip(text, width) {
+ return width > 0 ? text.slice(0, width) : '';
}
/**
- * Render a single list item.
+ * Clip long paths from the left so the most specific suffix stays visible.
*
- * @param {{ slug: string, treeOid: string }} entry
- * @param {number} index
- * @param {{ model: DashModel, width?: number }} opts
+ * @param {string} text
+ * @param {number} width
* @returns {string}
*/
-function renderListItem(entry, index, opts) {
- const prefix = index === opts.model.cursor ? '> ' : ' ';
- const manifest = opts.model.manifestCache.get(entry.slug);
- const stats = manifest ? formatStats(manifest) : '...';
- const line = `${prefix}${entry.slug} ${stats}`;
- return opts.width ? line.slice(0, opts.width) : line;
+function tailClip(text, width) {
+ if (width <= 0) {
+ return '';
+ }
+ if (text.length <= width) {
+ return text;
+ }
+ if (width <= 3) {
+ return clip(text, width);
+ }
+ return `...${text.slice(text.length - (width - 3))}`;
}
/**
@@ -61,121 +64,1366 @@ function renderListItem(entry, index, opts) {
* @param {number} height
* @returns {{ start: number, end: number }}
*/
-function visibleRange(cursor, total, height) {
- const start = Math.max(0, Math.min(cursor - Math.floor(height / 2), total - height));
- return { start: Math.max(0, start), end: Math.min(Math.max(0, start) + height, total) };
+function textSurface(text, width, height) {
+ return parseAnsiToSurface(text, Math.max(1, width), Math.max(1, height));
+}
+
+/**
+ * Write inline items on a single row.
+ *
+ * @param {Surface} target
+ * @param {{ x: number, y: number, parts: (Surface | string)[], maxWidth: number }} options
+ */
+function blitInline(target, options) {
+ let cursor = options.x;
+ for (const part of options.parts) {
+ const surface = typeof part === 'string'
+ ? textSurface(
+ clip(part, Math.max(1, options.maxWidth - (cursor - options.x))),
+ Math.max(1, Math.min(part.length, options.maxWidth - (cursor - options.x))),
+ 1,
+ )
+ : part;
+ if (cursor >= options.x + options.maxWidth) {
+ break;
+ }
+ target.blit(surface, cursor, options.y);
+ cursor += surface.width + 1;
+ }
}
/**
- * Render the header line.
+ * Build header badges that summarize current explorer state.
*
* @param {DashModel} model
* @param {BijouContext} ctx
- * @returns {string}
+ * @returns {(Surface | string)[]}
*/
-function renderHeader(model, ctx) {
- const parts = [];
+function headerParts(model, ctx) {
+ const parts = [
+ chipSurface(ctx, `${model.filtered.length}/${model.entries.length || model.filtered.length} visible`, 'info'),
+ ];
if (model.metadata?.encryption) {
- parts.push(badge('encrypted', { variant: 'warning', ctx }));
+ parts.push(chipSurface(ctx, 'encrypted', 'warning'));
+ }
+ if (model.filtering || model.filterText) {
+ parts.push(chipSurface(ctx, model.filtering ? 'filtering' : `filter ${model.filterText}`, 'accent'));
+ }
+ if (model.activeDrawer === 'treemap') {
+ parts.push(chipSurface(ctx, 'atlas view', 'brand'));
+ } else if (model.activeDrawer === 'refs') {
+ parts.push(chipSurface(ctx, 'ref index', 'brand'));
+ } else {
+ parts.push(chipSurface(ctx, model.splitPane.focused === 'a' ? 'entries ledger' : 'manifest inspector', 'brand'));
+ }
+ appendSelectionBadges(parts, model, ctx);
+ return parts;
+}
+
+/**
+ * Append badges related to selection and overlays.
+ *
+ * @param {(Surface | string)[]} parts
+ * @param {DashModel} model
+ * @param {BijouContext} ctx
+ */
+function appendSelectionBadges(parts, model, ctx) {
+ const selected = model.filtered[model.table.focusRow];
+ if (selected && model.activeDrawer !== 'treemap') {
+ parts.push(chipSurface(ctx, `selected ${selected.slug}`, 'accent'));
+ }
+ if (model.toasts.length > 0) {
+ parts.push(chipSurface(ctx, `alerts ${model.toasts.length}`, 'warning'));
+ }
+ if (model.activeDrawer === 'treemap') {
+ parts.push(chipSurface(ctx, `scope ${model.treemapScope}`, 'brand'));
+ if (model.treemapScope === 'repository') {
+ parts.push(chipSurface(ctx, `files ${model.treemapWorktreeMode}`, 'accent'));
+ }
+ parts.push(chipSurface(ctx, `level ${treemapLevelLabel(model)}`, 'info'));
+ const tile = selectedTreemapTile(model);
+ if (tile) {
+ parts.push(chipSurface(ctx, `focus ${tile.label}`, 'warning'));
+ }
+ }
+ if (model.activeDrawer && model.activeDrawer !== 'treemap') {
+ parts.push(chipSurface(ctx, `${model.activeDrawer} drawer`, 'info'));
+ }
+ if (model.palette) {
+ parts.push(chipSurface(ctx, 'command deck', 'warning'));
+ }
+}
+
+/**
+ * Human-readable label for the active dashboard source.
+ *
+ * @param {DashSource} source
+ * @returns {string}
+ */
+function sourceLabel(source) {
+ if (source.type === 'vault') {
+ return 'source vault refs/cas/vault';
+ }
+ if (source.type === 'ref') {
+ return `source ref ${source.ref}`;
+ }
+ return `source oid ${source.treeOid}`;
+}
+
+/**
+ * Current breadcrumb label for the treemap level.
+ *
+ * @param {DashModel} model
+ * @returns {string}
+ */
+function treemapLevelLabel(model) {
+ return model.treemapReport?.breadcrumb?.join(' > ') ?? (model.treemapScope === 'repository' ? 'repository' : 'source');
+}
+
+/**
+ * Selected tile in the current treemap report.
+ *
+ * @param {DashModel} model
+ * @returns {import('./dashboard-cmds.js').RepoTreemapTile | null}
+ */
+function selectedTreemapTile(model) {
+ if (!model.treemapReport || model.treemapReport.tiles.length === 0) {
+ return null;
}
- parts.push(`${model.entries.length} entries`);
- parts.push('refs/cas/vault');
- return parts.join(' ');
+ return model.treemapReport.tiles[Math.max(0, Math.min(model.treemapFocus, model.treemapReport.tiles.length - 1))] ?? null;
}
/**
- * Render the list pane.
+ * Render the header surface.
*
* @param {DashModel} model
- * @param {{ height: number, width?: number }} size
+ * @param {DashDeps} deps
+ * @returns {Surface}
+ */
+function renderHeaderSurface(model, deps) {
+ const surface = createSurface(Math.max(1, model.columns), 4);
+ blitInline(surface, {
+ x: 0,
+ y: 0,
+ parts: [
+ inlineSurface(deps.ctx, 'git-cas', { tone: 'brand' }),
+ inlineSurface(deps.ctx, 'repository explorer', { tone: 'secondary' }),
+ ],
+ maxWidth: surface.width,
+ });
+ blitInline(surface, {
+ x: 0,
+ y: 1,
+ parts: [
+ inlineSurface(deps.ctx, 'cwd', { tone: 'accent' }),
+ inlineSurface(deps.ctx, tailClip(deps.cwdLabel ?? '-', Math.max(1, surface.width - 5)), { tone: 'subdued' }),
+ ],
+ maxWidth: surface.width,
+ });
+ blitInline(surface, {
+ x: 0,
+ y: 2,
+ parts: [inlineSurface(deps.ctx, sourceLabel(model.source), { tone: 'primary' }), ...headerParts(model, deps.ctx)],
+ maxWidth: surface.width,
+ });
+ surface.blit(textSurface(shellRule(deps.ctx, surface.width), surface.width, 1), 0, 3);
+ return surface;
+}
+
+/**
+ * Render a fixed-width overlay panel surface.
+ *
+ * @param {{ title: string, body: string, width: number, height: number, ctx: BijouContext }} options
+ * @returns {Surface}
+ */
+function renderOverlayPanel(options) {
+ const innerWidth = Math.max(1, options.width - 2);
+ const innerHeight = Math.max(1, options.height - 2);
+ return boxV3(textSurface(options.body, innerWidth, innerHeight), {
+ ctx: options.ctx,
+ title: options.title,
+ width: options.width,
+ });
+}
+
+/**
+ * Pad or clip text to a fixed width.
+ *
+ * @param {string} text
+ * @param {number} width
+ * @returns {string}
+ */
+function padToWidth(text, width) {
+ return text.length >= width ? text.slice(0, width) : `${text}${' '.repeat(width - text.length)}`;
+}
+
+/**
+ * Wrap text to the requested width and line budget.
+ *
+ * @param {string[]} lines
+ * @param {number} width
+ * @param {number} maxLines
+ * @returns {string[]}
+ */
+function limitWrappedLines(lines, width, maxLines) {
+ if (maxLines <= 0) {
+ return [];
+ }
+ if (lines.length <= maxLines) {
+ return lines;
+ }
+ const capped = lines.slice(0, maxLines);
+ capped[maxLines - 1] = `${clip(capped[maxLines - 1], Math.max(1, width - 1))}…`;
+ return capped;
+}
+
+/**
+ * Build one titled sidebar section within a line budget.
+ *
+ * @param {{ title: string, body: string, width: number, bodyLines: number, ctx: BijouContext, tone?: 'brand' | 'accent' | 'info' | 'warning' | 'subdued' }} options
+ * @returns {string[]}
+ */
+function sidebarSection(options) {
+ const lines = options.body.length === 0 ? [''] : options.body.split('\n');
+ return [
+ sectionHeading(options.ctx, options.title, options.tone ?? 'brand'),
+ ...limitWrappedLines(lines, options.width, Math.max(1, options.bodyLines)),
+ ];
+}
+
+/**
+ * Treemap sidebar text for loading/error/empty report states.
+ *
+ * @param {DashModel} model
+ * @returns {string | null}
+ */
+function treemapSidebarStateText(model) {
+ if (model.treemapStatus === 'loading') {
+ return `Overview\nLoading ${model.treemapScope} treemap...`;
+ }
+ if (model.treemapStatus === 'error') {
+ return `Overview\nFailed to load treemap\n\n${model.treemapError ?? 'unknown error'}`;
+ }
+ if (!model.treemapReport) {
+ return 'Overview\nTreemap has not been loaded yet.';
+ }
+ return null;
+}
+
+/**
+ * Compose the full set of sidebar sections for the treemap view.
+ *
+ * @param {{ sections: ReturnType, width: number, height: number, ctx: BijouContext }} options
* @returns {string}
*/
-function renderListPane(model, size) {
- const clamp = (/** @type {string} */ s) => (typeof size.width === 'number' && size.width > 0 ? s.slice(0, size.width) : s);
- const filterLine = model.filtering ? clamp(`/${model.filterText}\u2588`) : '';
- const listHeight = model.filtering ? size.height - 1 : size.height;
- const items = model.filtered;
+function composeTreemapSidebarText(options) {
+ const sectionBlocks = [
+ sidebarSection({
+ title: 'Overview',
+ body: options.sections.overview,
+ ctx: options.ctx,
+ tone: 'brand',
+ width: options.width,
+ bodyLines: 4,
+ }),
+ sidebarSection({
+ title: 'Focused Region',
+ body: options.sections.focused,
+ ctx: options.ctx,
+ tone: 'accent',
+ width: options.width,
+ bodyLines: 3,
+ }),
+ sidebarSection({
+ title: 'Legend',
+ body: options.sections.legend,
+ ctx: options.ctx,
+ tone: 'info',
+ width: options.width,
+ bodyLines: 6,
+ }),
+ sidebarSection({
+ title: 'Largest Regions',
+ body: options.sections.regions || 'No regions to display.',
+ ctx: options.ctx,
+ tone: 'warning',
+ width: options.width,
+ bodyLines: 4,
+ }),
+ sidebarSection({
+ title: 'Notes',
+ body: options.sections.notes || 'No notes.',
+ ctx: options.ctx,
+ tone: 'subdued',
+ width: options.width,
+ bodyLines: Math.max(2, options.height - 23),
+ }),
+ ];
+ return sectionBlocks.flat().join('\n');
+}
- if (items.length === 0) {
- const msg = clamp(
- model.status === 'loading'
- ? 'Loading...'
- : model.error
- ? `Error: ${model.error}`
- : 'No entries',
- );
- return padToHeight(msg, listHeight, filterLine);
+/**
+ * Find the last whitespace boundary at or before an index.
+ *
+ * @param {string} text
+ * @param {number} index
+ * @returns {number}
+ */
+function lastWhitespaceBoundary(text, index) {
+ for (let cursor = Math.min(index, text.length - 1); cursor >= 0; cursor -= 1) {
+ if (/\s/.test(text[cursor])) {
+ return cursor;
+ }
}
+ return -1;
+}
- const { start, end } = visibleRange(model.cursor, items.length, listHeight);
+/**
+ * Wrap one paragraph to a width using whitespace boundaries when available.
+ *
+ * @param {string} text
+ * @param {number} width
+ * @returns {string[]}
+ */
+function wrapWhitespaceParagraph(text, width) {
const lines = [];
- for (let i = start; i < end; i++) {
- lines.push(renderListItem(items[i], i, { model, width: size.width }));
+ let remaining = text.trimEnd();
+ if (remaining.length === 0) {
+ return [''];
+ }
+ while (remaining.length > width) {
+ let wrapIndex = Math.min(width, remaining.length);
+ if (wrapIndex < remaining.length && !/\s/.test(remaining[wrapIndex])) {
+ const boundary = lastWhitespaceBoundary(remaining, wrapIndex);
+ if (boundary > 0) {
+ wrapIndex = boundary;
+ }
+ }
+ if (wrapIndex <= 0) {
+ wrapIndex = width;
+ }
+ const line = remaining.slice(0, wrapIndex).trimEnd();
+ lines.push(line.length > 0 ? line : remaining.slice(0, width));
+ remaining = remaining.slice(wrapIndex).trimStart();
+ }
+ if (remaining.length > 0) {
+ lines.push(remaining);
}
- return padToHeight(lines.join('\n'), listHeight, filterLine);
+ return lines;
}
/**
- * Pad content to target height, optionally appending a suffix line.
+ * Wrap plain text on whitespace with hard-break fallback.
*
- * @param {string} content
+ * @param {string} text
+ * @param {number} width
+ * @returns {string[]}
+ */
+function wrapWhitespaceText(text, width) {
+ if (width <= 0) {
+ return [''];
+ }
+ return text
+ .split('\n')
+ .flatMap((part) => wrapWhitespaceParagraph(part, Math.max(1, width)));
+}
+
+/**
+ * Measure an appropriate toast width for its title and message.
+ *
+ * @param {{ level: 'error' | 'warning' | 'info' | 'success', title: string, message: string }} toast
+ * @param {number} maxWidth
+ * @returns {number}
+ */
+function measureToastWidth(toast, maxWidth) {
+ const theme = TOAST_THEME[toast.level] ?? TOAST_THEME.info;
+ const titleLength = `${theme.label.toUpperCase()} // ${toast.title}`.length;
+ const messageLength = toast.message
+ .split('\n')
+ .reduce((longest, line) => Math.max(longest, line.length), 0);
+ const preferredInnerWidth = Math.max(26, Math.min(maxWidth - 2, Math.max(titleLength, messageLength + 4)));
+ return Math.max(28, Math.min(maxWidth, preferredInnerWidth + 2));
+}
+
+/**
+ * Wrap toast copy while preferring whitespace boundaries.
+ *
+ * @param {string} text
+ * @param {number} width
+ * @param {number} maxLines
+ * @returns {string[]}
+ */
+function wrapToastText(text, width, maxLines) {
+ const lines = wrapWhitespaceText(text, width);
+ return limitWrappedLines(lines, width, maxLines);
+}
+
+/**
+ * Style a single toast content line.
+ *
+ * @param {{ text: string, theme: { bg: [number, number, number], fg: [number, number, number] }, ctx: BijouContext, width: number }} options
+ * @returns {string}
+ */
+function styleToastLine(options) {
+ return options.ctx.style.bgRgb(
+ options.theme.bg[0],
+ options.theme.bg[1],
+ options.theme.bg[2],
+ options.ctx.style.rgb(
+ options.theme.fg[0],
+ options.theme.fg[1],
+ options.theme.fg[2],
+ padToWidth(options.text, options.width),
+ ),
+ );
+}
+
+/**
+ * Ease toast entry with a small overshoot so it pops into place.
+ *
+ * @param {number} progress
+ * @returns {number}
+ */
+function easeOutBack(progress) {
+ const clamped = Math.max(0, Math.min(1, progress));
+ const overshoot = 1.70158;
+ const shifted = clamped - 1;
+ return 1 + ((overshoot + 1) * shifted * shifted * shifted) + (overshoot * shifted * shifted);
+}
+
+/**
+ * Visible body line budget for the current toast animation phase.
+ *
+ * @param {{ phase?: 'entering' | 'steady' | 'exiting', progress?: number }} toast
+ * @returns {number}
+ */
+function toastBodyLineBudget(toast) {
+ if (toast.phase !== 'exiting') {
+ return 3;
+ }
+ const progress = Math.max(0, Math.min(1, toast.progress ?? 1));
+ if (progress > 0.66) {
+ return 3;
+ }
+ if (progress > 0.36) {
+ return 2;
+ }
+ if (progress > 0.16) {
+ return 1;
+ }
+ return 0;
+}
+
+/**
+ * Width of the toast for the current motion phase.
+ *
+ * @param {{ phase?: 'entering' | 'steady' | 'exiting', progress?: number }} toast
+ * @param {number} baseWidth
+ * @returns {number}
+ */
+function visibleToastWidth(toast, baseWidth) {
+ if (toast.phase !== 'exiting') {
+ return baseWidth;
+ }
+ const progress = Math.max(0, Math.min(1, toast.progress ?? 1));
+ return Math.max(24, Math.min(baseWidth, Math.round(baseWidth * (0.56 + (progress * 0.44)))));
+}
+
+/**
+ * Render one toast box surface.
+ *
+ * @param {{ id: number, level: 'error' | 'warning' | 'info' | 'success', title: string, message: string, phase?: 'entering' | 'steady' | 'exiting', progress?: number }} toast
+ * @param {{ width: number, ctx: BijouContext }} opts
+ * @returns {Surface}
+ */
+function renderToastSurface(toast, opts) {
+ const theme = TOAST_THEME[toast.level] ?? TOAST_THEME.info;
+ const baseWidth = measureToastWidth(toast, Math.max(32, Math.min(48, opts.width)));
+ const width = visibleToastWidth(toast, baseWidth);
+ const innerWidth = Math.max(1, width - 2);
+ const bodyWidth = Math.max(1, innerWidth - 3);
+ const bodyLineBudget = toastBodyLineBudget(toast);
+ const bodyLines = wrapToastText(toast.message, bodyWidth, bodyLineBudget).map((line) => styleToastLine({
+ text: line,
+ theme,
+ ctx: opts.ctx,
+ width: bodyWidth,
+ }));
+ const titleText = padToWidth(`${theme.label.toUpperCase()} // ${toast.title}`, innerWidth);
+ const chrome = opts.ctx.style.bold(opts.ctx.style.rgb(theme.bg[0], theme.bg[1], theme.bg[2], '╔'));
+ const border = opts.ctx.style.bold(opts.ctx.style.rgb(theme.bg[0], theme.bg[1], theme.bg[2], '║'));
+ const bottom = opts.ctx.style.bold(opts.ctx.style.rgb(theme.bg[0], theme.bg[1], theme.bg[2], '╚'));
+ const topLine = `${chrome}${opts.ctx.style.rgb(theme.bg[0], theme.bg[1], theme.bg[2], '═'.repeat(innerWidth))}${opts.ctx.style.bold(opts.ctx.style.rgb(theme.bg[0], theme.bg[1], theme.bg[2], '╗'))}`;
+ const titleLine = `${border}${styleToastLine({ text: titleText, theme, ctx: opts.ctx, width: innerWidth })}${opts.ctx.style.bold(opts.ctx.style.rgb(theme.bg[0], theme.bg[1], theme.bg[2], '║'))}`;
+ const dividerLine = `${border}${opts.ctx.style.rgb(theme.bg[0], theme.bg[1], theme.bg[2], '─'.repeat(innerWidth))}${opts.ctx.style.bold(opts.ctx.style.rgb(theme.bg[0], theme.bg[1], theme.bg[2], '║'))}`;
+ const contentLines = bodyLines.map((line) => {
+ const rail = opts.ctx.style.bold(opts.ctx.style.rgb(theme.bg[0], theme.bg[1], theme.bg[2], '▌'));
+ return `${border}${rail} ${line} ${opts.ctx.style.bold(opts.ctx.style.rgb(theme.bg[0], theme.bg[1], theme.bg[2], '║'))}`;
+ });
+ const bottomLine = `${bottom}${opts.ctx.style.rgb(theme.bg[0], theme.bg[1], theme.bg[2], '═'.repeat(innerWidth))}${opts.ctx.style.bold(opts.ctx.style.rgb(theme.bg[0], theme.bg[1], theme.bg[2], '╝'))}`;
+ const lines = contentLines.length > 0
+ ? [topLine, titleLine, dividerLine, ...contentLines, bottomLine]
+ : [topLine, titleLine, bottomLine];
+ return textSurface(lines.join('\n'), width, lines.length);
+}
+
+/**
+ * Render a soft drop shadow behind a toast.
+ *
+ * @param {number} width
* @param {number} height
- * @param {string} suffix
+ * @param {BijouContext} ctx
+ * @returns {Surface}
+ */
+function renderToastShadow(width, height, ctx) {
+ const line = ctx.style.rgb(32, 38, 52, '░'.repeat(Math.max(1, width)));
+ return textSurface(Array.from({ length: Math.max(1, height) }, () => line).join('\n'), width, height);
+}
+
+/**
+ * Compute horizontal slide for toast motion.
+ *
+ * @param {{ progress?: number }} toast
+ * @returns {number}
+ */
+function toastSlideOffset(toast) {
+ const progress = Math.max(0, Math.min(1, toast.progress ?? 1));
+ if (toast.phase === 'entering') {
+ return Math.round((1 - easeOutBack(progress)) * 18);
+ }
+ if (toast.phase === 'exiting') {
+ return Math.round((1 - progress) * 24);
+ }
+ return 0;
+}
+
+/**
+ * Build drawer copy for the stats overlay.
+ *
+ * @param {DashModel} model
+ * @param {BijouContext} ctx
* @returns {string}
*/
-function padToHeight(content, height, suffix) {
- const lines = content.split('\n');
- while (lines.length < height) { lines.push(''); }
- return suffix ? `${lines.join('\n')}\n${suffix}` : lines.join('\n');
+function statsDrawerBody(model, ctx) {
+ if (model.statsStatus === 'loading') {
+ return 'Loading source stats...';
+ }
+ if (model.statsStatus === 'error') {
+ return `Failed to load stats\n\n${model.statsError ?? 'unknown error'}`;
+ }
+ return model.statsReport
+ ? `${sectionHeading(ctx, 'Repository Economics', 'brand')}\n${themeText(ctx, 'Logical size, dedupe, encryption, and chunk shape at a glance.', { tone: 'subdued' })}\n\n${renderVaultStats(model.statsReport)}`
+ : 'Stats have not been loaded yet.';
}
/**
- * Render the detail pane with viewport scrolling.
+ * Build drawer copy for the doctor overlay.
*
* @param {DashModel} model
- * @param {{ width: number, height: number, ctx: BijouContext }} opts
+ * @param {BijouContext} ctx
* @returns {string}
*/
+function doctorDrawerBody(model, ctx) {
+ if (model.doctorStatus === 'loading') {
+ return 'Loading doctor report...';
+ }
+ if (model.doctorStatus === 'error') {
+ return `Failed to load doctor report\n\n${model.doctorError ?? 'unknown error'}`;
+ }
+ return typeof model.doctorReport === 'string'
+ ? model.doctorReport
+ : model.doctorReport
+ ? `${sectionHeading(ctx, 'Integrity Sweep', 'brand')}\n${themeText(ctx, 'Vault reachability, manifest health, and issue inventory.', { tone: 'subdued' })}\n\n${renderDoctorReport(model.doctorReport)}`
+ : 'Doctor report has not been loaded yet.';
+}
+
+/**
+ * Render the stats drawer.
+ *
+ * @param {DashModel} model
+ * @param {{ width: number, height: number, ctx: BijouContext }} opts
+ * @returns {Surface}
+ */
+function renderStatsDrawer(model, opts) {
+ return renderOverlayPanel({
+ title: 'Vault Metrics',
+ body: statsDrawerBody(model, opts.ctx),
+ width: Math.max(32, Math.min(56, opts.width - 2)),
+ height: Math.max(8, opts.height),
+ ctx: opts.ctx,
+ });
+}
+
+/**
+ * Render the doctor drawer.
+ *
+ * @param {DashModel} model
+ * @param {{ width: number, height: number, ctx: BijouContext }} opts
+ * @returns {Surface}
+ */
+function renderDoctorDrawer(model, opts) {
+ return renderOverlayPanel({
+ title: 'Vault Doctor',
+ body: doctorDrawerBody(model, opts.ctx),
+ width: Math.max(32, Math.min(56, opts.width - 2)),
+ height: Math.max(8, opts.height),
+ ctx: opts.ctx,
+ });
+}
+
+/**
+ * Render a boxed panel surface.
+ *
+ * @param {{ title: string, body: string, width: number, height: number, ctx: BijouContext }} options
+ * @returns {Surface}
+ */
+function renderPanel(options) {
+ return boxV3(textSurface(options.body, Math.max(1, options.width - 2), Math.max(1, options.height - 2)), {
+ ctx: options.ctx,
+ title: options.title,
+ width: options.width,
+ });
+}
+
+/**
+ * Render the operator drawer surface when active.
+ *
+ * @param {DashModel} model
+ * @param {{ width: number, height: number, ctx: BijouContext }} opts
+ * @returns {Surface | null}
+ */
+function renderDrawerSurface(model, opts) {
+ if (!model.activeDrawer || model.activeDrawer === 'treemap' || model.activeDrawer === 'refs') {
+ return null;
+ }
+ return model.activeDrawer === 'stats'
+ ? renderStatsDrawer(model, opts)
+ : renderDoctorDrawer(model, opts);
+}
+
+/**
+ * Render stacked toast notifications in the lower-right corner.
+ *
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @param {{ top: number, height: number, screen: Surface }} options
+ */
+function renderToastStack(model, deps, options) {
+ const marginTop = 1;
+ const marginRight = 4;
+ let cursorY = options.top + marginTop;
+ for (const toast of model.toasts) {
+ const surface = renderToastSurface(toast, {
+ width: Math.min(52, Math.max(40, Math.floor(options.screen.width * 0.44))),
+ ctx: deps.ctx,
+ });
+ if (cursorY + surface.height > options.top + options.height) {
+ break;
+ }
+ const slideOffset = toastSlideOffset(toast);
+ const x = Math.max(0, options.screen.width - surface.width - marginRight + slideOffset);
+ const shadow = renderToastShadow(surface.width, surface.height, deps.ctx);
+ const shadowX = Math.max(0, x + 2);
+ const shadowY = Math.min(options.top + options.height - shadow.height, cursorY + 1);
+ options.screen.blit(shadow, shadowX, shadowY);
+ options.screen.blit(surface, x, cursorY);
+ cursorY += surface.height + 1;
+ }
+}
+
+/**
+ * Render the command palette overlay.
+ *
+ * @param {DashModel} model
+ * @param {{ width: number, height: number, ctx: BijouContext }} opts
+ * @returns {Surface | null}
+ */
+function renderPaletteSurface(model, opts) {
+ if (!model.palette) {
+ return null;
+ }
+ const width = Math.max(32, Math.min(72, opts.width - 8));
+ const body = commandPalette(model.palette, {
+ width: Math.max(16, width - 2),
+ ctx: opts.ctx,
+ });
+ return renderOverlayPanel({
+ title: 'Command Deck',
+ body,
+ width,
+ height: Math.min(opts.height, model.palette.height + 3),
+ ctx: opts.ctx,
+ });
+}
+
+/**
+ * Select the current vault entry from table focus.
+ *
+ * @param {DashModel} model
+ * @returns {{ slug: string, treeOid: string } | undefined}
+ */
+function selectedEntry(model) {
+ return model.filtered[model.table.focusRow];
+}
+
+/**
+ * Choose a responsive table schema for the explorer pane width.
+ *
+ * @param {number} width
+ * @returns {{ columns: { header: string, width: number, align?: 'left' | 'right' | 'center' }[], indexes: number[] }}
+ */
+function tableSchema(width) {
+ if (width >= 64) {
+ return {
+ columns: [
+ { header: 'Slug', width: Math.max(14, width - 36) },
+ { header: 'Size', width: 8, align: 'right' },
+ { header: 'Chunks', width: 6, align: 'right' },
+ { header: 'Crypto', width: 7 },
+ { header: 'Format', width: 9 },
+ ],
+ indexes: [0, 1, 2, 3, 4],
+ };
+ }
+ if (width >= 48) {
+ return {
+ columns: [
+ { header: 'Slug', width: Math.max(14, width - 23) },
+ { header: 'Size', width: 8, align: 'right' },
+ { header: 'Profile', width: 11 },
+ ],
+ indexes: [0, 1, 5],
+ };
+ }
+ return {
+ columns: [
+ { header: 'Slug', width: Math.max(14, width - 12) },
+ { header: 'State', width: 10 },
+ ],
+ indexes: [0, 5],
+ };
+}
+
+/**
+ * Clamp a table state to the current pane size and responsive schema.
+ *
+ * @param {DashModel} model
+ * @param {{ width: number, height: number }} size
+ * @returns {import('@flyingrobots/bijou-tui').NavigableTableState}
+ */
+function tableViewState(model, size) {
+ const schema = tableSchema(size.width);
+ const rows = model.table.rows.map((row) => schema.indexes.map((index) => row[index] ?? ''));
+ const focusRow = Math.max(0, Math.min(model.table.focusRow, rows.length - 1));
+ let scrollY = model.table.scrollY;
+ if (focusRow < scrollY) {
+ scrollY = focusRow;
+ } else if (focusRow >= scrollY + size.height) {
+ scrollY = focusRow - size.height + 1;
+ }
+ return {
+ ...model.table,
+ columns: schema.columns,
+ rows,
+ height: size.height,
+ focusRow,
+ scrollY: Math.min(scrollY, Math.max(0, rows.length - size.height)),
+ };
+}
+
+/**
+ * Render the split divider surface.
+ *
+ * @param {number} height
+ * @returns {Surface}
+ */
+function renderDividerSurface(height) {
+ return textSurface(Array.from({ length: Math.max(1, height) }, () => '│').join('\n'), 1, Math.max(1, height));
+}
+
+/**
+ * Render the explorer list pane.
+ *
+ * @param {DashModel} model
+ * @param {{ width: number, height: number, ctx: BijouContext }} opts
+ * @returns {Surface}
+ */
+function renderListPane(model, opts) {
+ const innerWidth = Math.max(1, opts.width - 2);
+ const innerHeight = Math.max(1, opts.height - 2);
+ const metaLines = [
+ themeText(opts.ctx, clip(model.filtering ? `filter /${model.filterText}\u2588` : model.filterText ? `filter ${model.filterText}` : 'filter all', innerWidth), { tone: 'accent' }),
+ themeText(opts.ctx, clip(`${model.filtered.length} assets focus row ${model.table.rows.length ? model.table.focusRow + 1 : 0}`, innerWidth), { tone: 'subdued' }),
+ ];
+ const tableHeight = Math.max(1, innerHeight - metaLines.length);
+
+ if (model.table.rows.length === 0) {
+ metaLines.push(model.status === 'loading'
+ ? themeText(opts.ctx, 'Loading...', { tone: 'info' })
+ : model.error
+ ? themeText(opts.ctx, `Error: ${model.error}`, { tone: 'danger' })
+ : themeText(opts.ctx, 'No entries', { tone: 'subdued' }));
+ } else {
+ const tableText = navigableTable(tableViewState(model, { width: innerWidth, height: tableHeight }), {
+ ctx: opts.ctx,
+ focusIndicator: model.splitPane.focused === 'a' ? '▸' : '·',
+ });
+ metaLines.push(tableText);
+ }
+
+ return boxV3(textSurface(metaLines.join('\n'), innerWidth, innerHeight), {
+ ctx: opts.ctx,
+ title: model.splitPane.focused === 'a' ? 'Entries Ledger *' : 'Entries Ledger',
+ width: opts.width,
+ });
+}
+
+/**
+ * Render the explorer detail pane.
+ *
+ * @param {DashModel} model
+ * @param {{ width: number, height: number, ctx: BijouContext }} opts
+ * @returns {Surface}
+ */
function renderDetailPane(model, opts) {
- const entry = model.filtered[model.cursor];
- if (!entry) { return ''; }
+ const innerWidth = Math.max(1, opts.width - 2);
+ const innerHeight = Math.max(1, opts.height - 2);
+ const content = createSurface(innerWidth, innerHeight);
+ const entry = selectedEntry(model);
+
+ if (!entry) {
+ content.blit(textSurface('Select an entry to inspect it.', innerWidth, innerHeight), 0, 0);
+ return boxV3(content, {
+ ctx: opts.ctx,
+ title: model.splitPane.focused === 'b' ? 'Manifest Inspector *' : 'Manifest Inspector',
+ width: opts.width,
+ });
+ }
+
const manifest = model.manifestCache.get(entry.slug);
- if (!manifest) { return 'Loading manifest...'; }
- const content = renderManifestView({ manifest, ctx: opts.ctx });
- return viewport({ width: opts.width, height: opts.height, content, scrollY: model.detailScroll });
+ const summary = [
+ `${themeText(opts.ctx, 'asset', { tone: 'accent' })} ${themeText(opts.ctx, entry.slug, { tone: 'primary', bold: true })}`,
+ `${themeText(opts.ctx, 'tree', { tone: 'subdued' })} ${themeText(opts.ctx, `${entry.treeOid.slice(0, 12)}...`, { tone: 'secondary' })}`,
+ ];
+ content.blit(textSurface(summary.join('\n'), innerWidth, Math.min(2, innerHeight)), 0, 0);
+
+ if (!manifest) {
+ const loadingText = entry.slug === model.loadingSlug
+ ? themeText(opts.ctx, 'Loading manifest...', { tone: 'info' })
+ : themeText(opts.ctx, 'Manifest not loaded yet.', { tone: 'subdued' });
+ content.blit(textSurface(loadingText, innerWidth, Math.max(1, innerHeight - 3)), 0, 3);
+ return boxV3(content, {
+ ctx: opts.ctx,
+ title: model.splitPane.focused === 'b' ? 'Manifest Inspector *' : 'Manifest Inspector',
+ width: opts.width,
+ });
+ }
+
+ const manifestBody = renderManifestView({ manifest, ctx: opts.ctx });
+ const manifestLines = Math.max(1, manifestBody.split('\n').length);
+ const manifestSurface = parseAnsiToSurface(manifestBody, innerWidth, manifestLines);
+ const bodyTop = 3;
+ const bodyHeight = Math.max(1, innerHeight - bodyTop);
+ content.blit(manifestSurface, 0, bodyTop, 0, model.detailScroll, innerWidth, bodyHeight);
+
+ return boxV3(content, {
+ ctx: opts.ctx,
+ title: model.splitPane.focused === 'b' ? 'Manifest Inspector *' : 'Manifest Inspector',
+ width: opts.width,
+ });
}
/**
- * Render the body with list and detail panes.
+ * Return the selected ref item from the refs browser.
+ *
+ * @param {DashModel} model
+ * @returns {import('./dashboard-cmds.js').RefInventoryItem | undefined}
+ */
+function selectedRef(model) {
+ return model.refsItems[model.refsTable.focusRow];
+}
+
+/**
+ * Build human-readable metadata for a ref row.
+ *
+ * @param {import('./dashboard-cmds.js').RefInventoryItem} item
+ * @returns {string}
+ */
+function refMetaText(item) {
+ return `${item.namespace} ${item.resolution} ${item.entryCount} entries ${item.oid.slice(0, 12)}`;
+}
+
+/**
+ * Wrap and prefix a line collection.
+ *
+ * @param {string[]} lines
+ * @param {string} text
+ * @param {{ width: number, prefix?: string }} options
+ */
+function pushWrappedText(lines, text, options) {
+ const prefix = options.prefix ?? '';
+ const width = options.width;
+ const wrapped = wrapWhitespaceText(text, Math.max(1, width - prefix.length));
+ for (const line of wrapped) {
+ lines.push(`${prefix}${line}`);
+ }
+}
+
+/**
+ * Render the visible lines for one ref row.
+ *
+ * @param {import('./dashboard-cmds.js').RefInventoryItem} item
+ * @param {boolean} focused
+ * @param {number} width
+ * @returns {string[]}
+ */
+function renderRefRowLines(item, focused, width) {
+ const lines = [];
+ pushWrappedText(lines, item.ref, { width, prefix: focused ? '▸ ' : ' ' });
+ pushWrappedText(lines, refMetaText(item), { width, prefix: ' ' });
+ return lines;
+}
+
+/**
+ * Render refs-browser status text.
+ *
+ * @param {DashModel} model
+ * @param {number} width
+ * @returns {string}
+ */
+function renderRefsListStatusBody(model, width) {
+ if (model.refsStatus === 'loading') {
+ return wrapWhitespaceText('Loading refs...', width).join('\n');
+ }
+ if (model.refsStatus === 'error') {
+ return wrapWhitespaceText(`Failed to load refs\n\n${model.refsError ?? 'unknown error'}`, width).join('\n');
+ }
+ return wrapWhitespaceText('No refs found.', width).join('\n');
+}
+
+/**
+ * Build a visible refs-list viewport.
+ *
+ * @param {{ items: import('./dashboard-cmds.js').RefInventoryItem[], focusRow: number, startIndex: number, width: number, height: number, ctx: BijouContext }} options
+ * @returns {{ lines: string[], visibleFocus: boolean }}
+ */
+function buildRefsViewport(options) {
+ const lines = [themeText(options.ctx, `${options.items.length} refs focus row ${options.focusRow + 1}`, { tone: 'subdued' })];
+ let visibleFocus = false;
+
+ for (let index = options.startIndex; index < options.items.length; index += 1) {
+ const rowLines = renderRefRowLines(options.items[index], index === options.focusRow, options.width);
+ const needed = rowLines.length + (index > options.startIndex ? 1 : 0);
+ if (lines.length + needed > options.height && lines.length > 1) {
+ break;
+ }
+ if (index > options.startIndex) {
+ lines.push('');
+ }
+ const remaining = Math.max(1, options.height - lines.length);
+ lines.push(...rowLines.slice(0, remaining));
+ if (index === options.focusRow) {
+ visibleFocus = true;
+ }
+ if (lines.length >= options.height) {
+ break;
+ }
+ }
+
+ return { lines, visibleFocus };
+}
+
+/**
+ * Render the refs-browser list body with whitespace-aware wrapping.
*
* @param {DashModel} model
* @param {DashDeps} deps
* @param {{ width: number, height: number }} size
* @returns {string}
*/
-function renderBody(model, deps, size) {
- const listBasis = Math.floor(size.width * 0.35);
- return flex(
- { direction: 'row', width: size.width, height: size.height, gap: 1 },
- { content: (/** @type {number} */ w, /** @type {number} */ h) => renderListPane(model, { height: h, width: w }), basis: listBasis },
- { content: (/** @type {number} */ w, /** @type {number} */ h) => renderDetailPane(model, { width: w, height: h, ctx: deps.ctx }), flex: 1 },
+function renderRefsListBody(model, deps, size) {
+ if (model.refsStatus !== 'ready') {
+ return renderRefsListStatusBody(model, size.width);
+ }
+
+ const focusRow = Math.max(0, Math.min(model.refsTable.focusRow, model.refsItems.length - 1));
+ let start = Math.max(0, Math.min(model.refsTable.scrollY, model.refsItems.length - 1));
+ let viewport = buildRefsViewport({
+ items: model.refsItems,
+ focusRow,
+ startIndex: start,
+ width: size.width,
+ height: size.height,
+ ctx: deps.ctx,
+ });
+ while (!viewport.visibleFocus && start < focusRow) {
+ start += 1;
+ viewport = buildRefsViewport({
+ items: model.refsItems,
+ focusRow,
+ startIndex: start,
+ width: size.width,
+ height: size.height,
+ ctx: deps.ctx,
+ });
+ }
+
+ return viewport.lines.join('\n');
+}
+
+/**
+ * Build ref namespace counts.
+ *
+ * @param {import('./dashboard-cmds.js').RefInventoryItem[]} refsItems
+ * @returns {Map}
+ */
+function refNamespaceCounts(refsItems) {
+ const counts = new Map();
+ for (const ref of refsItems) {
+ counts.set(ref.namespace, (counts.get(ref.namespace) ?? 0) + 1);
+ }
+ return counts;
+}
+
+/**
+ * Append inventory summary lines to the refs sidebar.
+ *
+ * @param {{ lines: string[], model: DashModel, ctx: BijouContext, width: number, namespaceCounts: Map }} options
+ */
+function appendRefsInventory(options) {
+ options.lines.push(sectionHeading(options.ctx, 'Inventory', 'brand'));
+ pushWrappedText(
+ options.lines,
+ `refs ${options.model.refsItems.length} under ${options.namespaceCounts.size} namespaces`,
+ { width: options.width },
+ );
+ pushWrappedText(options.lines, `current ${sourceLabel(options.model.source)}`, { width: options.width });
+}
+
+/**
+ * Append selected-ref detail lines to the refs sidebar.
+ *
+ * @param {{ lines: string[], current: import('./dashboard-cmds.js').RefInventoryItem, ctx: BijouContext, width: number }} options
+ */
+function appendSelectedRefDetails(options) {
+ options.lines.push('', sectionHeading(options.ctx, 'Selected Ref', 'accent'));
+ pushWrappedText(options.lines, `ref ${options.current.ref}`, { width: options.width });
+ pushWrappedText(
+ options.lines,
+ options.current.detail,
+ { width: options.width },
+ );
+ pushWrappedText(options.lines, `namespace ${options.current.namespace}`, { width: options.width });
+ pushWrappedText(
+ options.lines,
+ `status ${options.current.browsable ? 'browsable' : 'opaque'} kind ${options.current.resolution} entries ${options.current.entryCount}`,
+ { width: options.width },
);
+ if (options.current.browsable) {
+ options.lines.push('');
+ pushWrappedText(options.lines, 'Press enter to switch source to this ref.', { width: options.width });
+ }
+ options.lines.push('');
+ pushWrappedText(options.lines, `oid ${options.current.oid}`, { width: options.width });
+ if (options.current.previewSlugs.length > 0) {
+ options.lines.push('', sectionHeading(options.ctx, 'Preview', 'info'));
+ for (const slug of options.current.previewSlugs) {
+ pushWrappedText(options.lines, slug, { width: options.width, prefix: '- ' });
+ }
+ }
+}
+
+/**
+ * Append namespace summary lines to the refs sidebar.
+ *
+ * @param {{ lines: string[], namespaceCounts: Map, ctx: BijouContext, width: number }} options
+ */
+function appendNamespaceSummary(options) {
+ if (options.namespaceCounts.size === 0) {
+ return;
+ }
+ options.lines.push('', sectionHeading(options.ctx, 'Namespaces', 'warning'));
+ for (const [namespace, count] of Array.from(options.namespaceCounts.entries()).slice(0, 8)) {
+ pushWrappedText(options.lines, `${namespace} (${count})`, { width: options.width, prefix: '- ' });
+ }
}
/**
- * Render the full dashboard layout.
+ * Render the refs-browser detail sidebar.
+ *
+ * @param {DashModel} model
+ * @param {BijouContext} ctx
+ * @param {number} width
+ * @returns {string}
+ */
+function renderRefsDetailBody(model, ctx, width) {
+ const current = selectedRef(model);
+ const namespaceCounts = refNamespaceCounts(model.refsItems);
+
+ const sidebarLines = [];
+ appendRefsInventory({ lines: sidebarLines, model, ctx, width, namespaceCounts });
+
+ if (current) {
+ appendSelectedRefDetails({ lines: sidebarLines, current, ctx, width });
+ } else if (model.refsStatus === 'ready') {
+ sidebarLines.push('');
+ pushWrappedText(sidebarLines, 'Select a ref to inspect it.', { width });
+ }
+
+ appendNamespaceSummary({ lines: sidebarLines, namespaceCounts, ctx, width });
+ return sidebarLines.join('\n');
+}
+
+/**
+ * Render the full-screen refs browser.
+ *
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @param {{ top: number, height: number, screen: Surface }} options
+ */
+function renderRefsView(model, deps, options) {
+ const maxSidebarWidth = Math.max(22, options.screen.width - 25);
+ const sidebarWidth = Math.min(maxSidebarWidth, Math.max(30, Math.min(46, Math.floor(options.screen.width * 0.35))));
+ const listWidth = Math.max(18, options.screen.width - sidebarWidth - 1);
+ const viewHeight = options.height;
+ const listPanel = renderPanel({
+ title: 'Ref Index',
+ body: renderRefsListBody(model, deps, {
+ width: Math.max(8, listWidth - 2),
+ height: Math.max(4, viewHeight - 2),
+ }),
+ width: listWidth,
+ height: viewHeight,
+ ctx: deps.ctx,
+ });
+ const detailPanel = renderPanel({
+ title: 'Ref Dispatch',
+ body: renderRefsDetailBody(model, deps.ctx, Math.max(8, sidebarWidth - 2)),
+ width: sidebarWidth,
+ height: viewHeight,
+ ctx: deps.ctx,
+ });
+
+ options.screen.blit(listPanel, 0, options.top);
+ options.screen.blit(renderDividerSurface(options.height), listWidth, options.top);
+ options.screen.blit(detailPanel, listWidth + 1, options.top);
+}
+
+/**
+ * Render the body content of the treemap map panel.
*
* @param {DashModel} model
* @param {DashDeps} deps
+ * @param {{ mapWidth: number, mapHeight: number }} size
* @returns {string}
*/
+function renderTreemapMapBody(model, deps, size) {
+ if (model.treemapStatus === 'loading') {
+ return `Loading ${model.treemapScope} treemap...`;
+ }
+ if (model.treemapStatus === 'error') {
+ return `Failed to load treemap\n\n${model.treemapError ?? 'unknown error'}`;
+ }
+ if (model.treemapReport && model.treemapScope === 'source' && model.treemapReport.summary.sourceEntries === 0) {
+ return [
+ 'No CAS entries were resolved for the current source.',
+ '',
+ sourceLabel(model.source),
+ '',
+ 'Press r to browse refs or T to return to the repository view.',
+ ].join('\n');
+ }
+ if (model.treemapReport) {
+ return renderRepoTreemapMap(model.treemapReport, {
+ ctx: deps.ctx,
+ width: Math.max(8, size.mapWidth - 2),
+ height: Math.max(4, size.mapHeight - 2),
+ selectedTileId: selectedTreemapTile(model)?.id ?? null,
+ });
+ }
+ return 'Treemap has not been loaded yet.';
+}
+
+/**
+ * Compose sidebar copy for the full-screen treemap view.
+ *
+ * @param {{ model: DashModel, deps: DashDeps, width: number, height: number }} options
+ * @returns {string}
+ */
+function renderTreemapSidebarText(options) {
+ const stateText = treemapSidebarStateText(options.model);
+ if (stateText) {
+ return stateText;
+ }
+ const sections = renderRepoTreemapSidebar(options.model.treemapReport, {
+ ctx: options.deps.ctx,
+ width: Math.max(16, options.width),
+ height: options.height,
+ selectedTileId: selectedTreemapTile(options.model)?.id ?? null,
+ });
+ return composeTreemapSidebarText({
+ sections,
+ ctx: options.deps.ctx,
+ width: options.width,
+ height: options.height,
+ });
+}
+
+/**
+ * Render the full-screen treemap view.
+ *
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @param {{ top: number, height: number, screen: Surface }} options
+ */
+function renderTreemapView(model, deps, options) {
+ const maxSidebarWidth = Math.max(18, options.screen.width - 17);
+ const sidebarWidth = Math.min(maxSidebarWidth, Math.max(24, Math.min(42, Math.floor(options.screen.width * 0.32))));
+ const mapWidth = Math.max(16, options.screen.width - sidebarWidth - 1);
+ const mapHeight = options.height;
+ const sidebarHeight = options.height;
+
+ const mapTitle = `${model.treemapScope === 'repository' ? 'Repository Atlas' : 'Source Atlas'} · ${treemapLevelLabel(model)}`;
+ const mapPanel = renderPanel({
+ title: mapTitle,
+ body: renderTreemapMapBody(model, deps, { mapWidth, mapHeight }),
+ width: mapWidth,
+ height: mapHeight,
+ ctx: deps.ctx,
+ });
+ const sidebarPanel = renderPanel({
+ title: 'Atlas Briefing',
+ body: renderTreemapSidebarText({
+ model,
+ deps,
+ width: Math.max(8, sidebarWidth - 2),
+ height: Math.max(4, sidebarHeight - 2),
+ }),
+ width: sidebarWidth,
+ height: sidebarHeight,
+ ctx: deps.ctx,
+ });
+
+ options.screen.blit(mapPanel, 0, options.top);
+ options.screen.blit(renderDividerSurface(options.height), mapWidth, options.top);
+ options.screen.blit(sidebarPanel, mapWidth + 1, options.top);
+}
+
+/**
+ * Render the footer help surface.
+ *
+ * @param {DashModel} model
+ * @param {BijouContext} ctx
+ * @param {number} width
+ * @returns {Surface}
+ */
+function renderFooterSurface(model, ctx, width) {
+ const lines = model.activeDrawer === 'treemap'
+ ? [
+ shellRule(ctx, width),
+ `${themeText(ctx, 'atlas', { tone: 'accent' })} ${kbd('j/k', { ctx })} regions ${kbd('d/u', { ctx })} page ${kbd('+', { ctx })} descend ${kbd('-', { ctx })} ascend`,
+ `${themeText(ctx, 'scope', { tone: 'brand' })} ${kbd('T', { ctx })} scope ${kbd('i', { ctx })} files ${kbd('r', { ctx })} refs ${kbd('ctrl+p', { ctx })} palette`,
+ `${themeText(ctx, 'ops', { tone: 'warning' })} ${kbd('s', { ctx })} stats ${kbd('g', { ctx })} doctor ${kbd('esc', { ctx })} back ${kbd('q', { ctx })} quit`,
+ ]
+ : model.activeDrawer === 'refs'
+ ? [
+ shellRule(ctx, width),
+ `${themeText(ctx, 'index', { tone: 'accent' })} ${kbd('j/k', { ctx })} refs ${kbd('d/u', { ctx })} page ${kbd('enter', { ctx })} switch source`,
+ `${themeText(ctx, 'inspect', { tone: 'brand' })} ${kbd('t', { ctx })} treemap ${kbd('s', { ctx })} stats ${kbd('g', { ctx })} doctor ${kbd('ctrl+p', { ctx })} palette`,
+ `${themeText(ctx, 'shell', { tone: 'warning' })} ${kbd('esc', { ctx })} back ${kbd('q', { ctx })} quit`,
+ ]
+ : [
+ shellRule(ctx, width),
+ `${themeText(ctx, 'browse', { tone: 'accent' })} ${kbd('j/k', { ctx })} rows ${kbd('d/u', { ctx })} page ${kbd('J/K', { ctx })} scroll ${kbd('enter', { ctx })} inspect`,
+ `${themeText(ctx, 'shell', { tone: 'brand' })} ${kbd('tab', { ctx })} pane ${kbd('H/L', { ctx })} resize ${kbd('ctrl+p', { ctx })} palette`,
+ `${themeText(ctx, 'ops', { tone: 'warning' })} ${kbd('s', { ctx })} stats ${kbd('g', { ctx })} doctor ${kbd('r', { ctx })} refs ${kbd('t', { ctx })} treemap ${kbd('T', { ctx })} scope ${kbd('i', { ctx })} files ${kbd('esc', { ctx })} close ${kbd('q', { ctx })} quit`,
+ ];
+ return textSurface(lines.join('\n'), width, 4);
+}
+
+/**
+ * Render the body with a split explorer layout.
+ *
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @param {{ top: number, height: number, screen: Surface }} options
+ */
+function renderBody(model, deps, options) {
+ if (model.activeDrawer === 'treemap') {
+ renderTreemapView(model, deps, options);
+ return;
+ }
+ if (model.activeDrawer === 'refs') {
+ renderRefsView(model, deps, options);
+ return;
+ }
+ const layout = splitPaneLayout(model.splitPane, {
+ direction: 'row',
+ width: model.columns,
+ height: options.height,
+ minA: SPLIT_MIN_LIST_WIDTH,
+ minB: SPLIT_MIN_DETAIL_WIDTH,
+ dividerSize: SPLIT_DIVIDER_SIZE,
+ });
+ const listPane = renderListPane(model, { width: layout.paneA.width, height: layout.paneA.height, ctx: deps.ctx });
+ const detailPane = renderDetailPane(model, { width: layout.paneB.width, height: layout.paneB.height, ctx: deps.ctx });
+ options.screen.blit(listPane, layout.paneA.col, options.top + layout.paneA.row);
+ options.screen.blit(renderDividerSurface(layout.divider.height), layout.divider.col, options.top + layout.divider.row);
+ options.screen.blit(detailPane, layout.paneB.col, options.top + layout.paneB.row);
+}
+
+/**
+ * Render any active operator overlays over the dashboard body.
+ *
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @param {{ top: number, height: number, screen: Surface }} options
+ * @returns {void}
+ */
+function renderOverlays(model, deps, options) {
+ const drawer = renderDrawerSurface(model, {
+ width: options.screen.width,
+ height: options.height,
+ ctx: deps.ctx,
+ });
+ if (drawer) {
+ options.screen.blit(drawer, Math.max(0, options.screen.width - drawer.width), options.top);
+ }
+
+ const palette = renderPaletteSurface(model, {
+ width: options.screen.width,
+ height: options.height,
+ ctx: deps.ctx,
+ });
+ if (palette) {
+ const x = Math.max(0, Math.floor((options.screen.width - palette.width) / 2));
+ const y = options.top + Math.max(0, Math.floor((options.height - palette.height) / 3));
+ options.screen.blit(palette, x, y);
+ }
+
+ renderToastStack(model, deps, options);
+}
+
+/**
+ * Render the full dashboard explorer layout.
+ *
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @returns {Surface}
+ */
export function renderDashboard(model, deps) {
- return flex(
- { direction: 'column', width: model.columns, height: model.rows },
- { content: renderHeader(model, deps.ctx), basis: 1 },
- { content: (/** @type {number} */ w, /** @type {number} */ _h) => '\u2500'.repeat(w), basis: 1 },
- { content: (/** @type {number} */ w, /** @type {number} */ h) => renderBody(model, deps, { width: w, height: h }), flex: 1 },
- { content: (/** @type {number} */ w, /** @type {number} */ _h) => '\u2500'.repeat(w), basis: 1 },
- { content: 'j/k Navigate enter Load / Filter J/K Scroll q Quit', basis: 1 },
- );
+ const width = Math.max(1, model.columns);
+ const height = Math.max(1, model.rows);
+ const screen = createSurface(width, height);
+ const header = renderHeaderSurface(model, deps);
+ const footer = renderFooterSurface(model, deps.ctx, width);
+ const bodyTop = header.height;
+ const bodyHeight = Math.max(1, height - header.height - footer.height);
+
+ screen.blit(header, 0, 0);
+ renderBody(model, deps, { top: bodyTop, height: bodyHeight, screen });
+ renderOverlays(model, deps, { top: bodyTop, height: bodyHeight, screen });
+ screen.blit(footer, 0, Math.max(0, height - footer.height));
+
+ return screen;
}
diff --git a/bin/ui/dashboard.js b/bin/ui/dashboard.js
index 6b5cf8d..383da10 100644
--- a/bin/ui/dashboard.js
+++ b/bin/ui/dashboard.js
@@ -2,8 +2,14 @@
* TEA app shell for the vault dashboard.
*/
-import { run, quit, createKeyMap } from '@flyingrobots/bijou-tui';
-import { loadEntriesCmd, loadManifestCmd } from './dashboard-cmds.js';
+import {
+ run, quit, tick, createKeyMap,
+ createNavigableTableState, navTableFocusNext, navTableFocusPrev, navTablePageDown, navTablePageUp,
+ createSplitPaneState, splitPaneFocusNext, splitPaneResizeBy,
+ createCommandPaletteState, cpFilter, cpFocusNext, cpFocusPrev, cpPageDown, cpPageUp, cpSelectedItem, commandPaletteKeyMap,
+ animate,
+} from '@flyingrobots/bijou-tui';
+import { loadEntriesCmd, loadManifestCmd, loadRefsCmd, loadStatsCmd, loadDoctorCmd, loadTreemapCmd, readSourceEntries } from './dashboard-cmds.js';
import { createCliTuiContext, detectCliTuiMode } from './context.js';
import { renderDashboard } from './dashboard-view.js';
@@ -13,24 +19,68 @@ import { renderDashboard } from './dashboard-view.js';
* @typedef {import('@flyingrobots/bijou-tui').ResizeMsg} ResizeMsg
* @typedef {import('@flyingrobots/bijou-tui').Cmd} DashCmd
* @typedef {import('@flyingrobots/bijou-tui').KeyMap} DashKeyMap
+ * @typedef {import('@flyingrobots/bijou-tui').NavigableTableState} NavigableTableState
+ * @typedef {import('@flyingrobots/bijou-tui').SplitPaneState} SplitPaneState
+ * @typedef {import('@flyingrobots/bijou-tui').CommandPaletteState} CommandPaletteState
* @typedef {import('../../index.js').default} ContentAddressableStore
* @typedef {import('../../src/domain/value-objects/Manifest.js').default} Manifest
+ * @typedef {import('./dashboard-cmds.js').TreemapScope} TreemapScope
+ * @typedef {import('./dashboard-cmds.js').TreemapWorktreeMode} TreemapWorktreeMode
+ * @typedef {import('./dashboard-cmds.js').TreemapPathNode} TreemapPathNode
+ * @typedef {import('./dashboard-cmds.js').RepoTreemapTile} RepoTreemapTile
+ * @typedef {import('./dashboard-cmds.js').RefInventory} RefInventory
+ * @typedef {import('./dashboard-cmds.js').RefInventoryItem} RefInventoryItem
* @typedef {{ slug: string, treeOid: string }} VaultEntry
+ * @typedef {'error' | 'warning' | 'info' | 'success'} ToastLevel
+ * @typedef {'entering' | 'steady' | 'exiting'} ToastPhase
+ * @typedef {{ id: number, level: ToastLevel, title: string, message: string, phase: ToastPhase, progress: number }} ToastRecord
+ * @typedef {{ type: 'vault' } | { type: 'ref', ref: string } | { type: 'oid', treeOid: string }} DashSource
+ * @typedef {'idle' | 'loading' | 'ready' | 'error'} LoadState
*/
/**
* @typedef {{ type: 'quit' }
* | { type: 'move', delta: number }
+ * | { type: 'page', delta: number }
* | { type: 'select' }
* | { type: 'filter-start' }
* | { type: 'scroll-detail', delta: number }
+ * | { type: 'split-focus' }
+ * | { type: 'split-resize', delta: number }
+ * | { type: 'open-palette' }
+ * | { type: 'open-stats' }
+ * | { type: 'open-doctor' }
+ * | { type: 'open-treemap' }
+ * | { type: 'open-refs' }
+ * | { type: 'toggle-treemap-scope' }
+ * | { type: 'toggle-treemap-worktree' }
+ * | { type: 'treemap-drill-in' }
+ * | { type: 'treemap-drill-out' }
+ * | { type: 'overlay-close' }
* } DashAction
*/
/**
- * @typedef {{ type: 'loaded-entries', entries: VaultEntry[], metadata: any }
- * | { type: 'loaded-manifest', slug: string, manifest: Manifest }
- * | { type: 'load-error', source: string, slug?: string, error: string }
+ * @typedef {{ type: 'focus-next' }
+ * | { type: 'focus-prev' }
+ * | { type: 'page-down' }
+ * | { type: 'page-up' }
+ * | { type: 'select' }
+ * | { type: 'close' }
+ * } PaletteAction
+ */
+
+/**
+ * @typedef {{ type: 'loaded-entries', entries: VaultEntry[], metadata: any, source: DashSource }
+ * | { type: 'loaded-manifest', slug: string, manifest: Manifest, source: DashSource }
+ * | { type: 'loaded-refs', refs: RefInventory }
+ * | { type: 'loaded-stats', stats: any, source: DashSource }
+ * | { type: 'loaded-doctor', report: any, source: DashSource }
+ * | { type: 'loaded-treemap', report: any }
+ * | { type: 'toast-progress', id: number, progress: number }
+ * | { type: 'toast-expire', id: number }
+ * | { type: 'dismiss-toast', id: number }
+ * | { type: 'load-error', source: string, slug?: string, forSource?: DashSource, scopeId?: TreemapScope, worktreeMode?: TreemapWorktreeMode, drillPath?: TreemapPathNode[], error: string }
* } DashMsg
*/
@@ -39,9 +89,9 @@ import { renderDashboard } from './dashboard-view.js';
* @property {string} status
* @property {number} columns
* @property {number} rows
+ * @property {DashSource} source
* @property {VaultEntry[]} entries
* @property {VaultEntry[]} filtered
- * @property {number} cursor
* @property {string} filterText
* @property {boolean} filtering
* @property {any} metadata
@@ -49,6 +99,29 @@ import { renderDashboard } from './dashboard-view.js';
* @property {string | null} loadingSlug
* @property {number} detailScroll
* @property {string | null} error
+ * @property {NavigableTableState} table
+ * @property {NavigableTableState} refsTable
+ * @property {RefInventoryItem[]} refsItems
+ * @property {SplitPaneState} splitPane
+ * @property {CommandPaletteState | null} palette
+ * @property {'stats' | 'doctor' | 'treemap' | 'refs' | null} activeDrawer
+ * @property {LoadState} refsStatus
+ * @property {string | null} refsError
+ * @property {LoadState} statsStatus
+ * @property {any | null} statsReport
+ * @property {string | null} statsError
+ * @property {LoadState} doctorStatus
+ * @property {any | null} doctorReport
+ * @property {string | null} doctorError
+ * @property {TreemapScope} treemapScope
+ * @property {TreemapWorktreeMode} treemapWorktreeMode
+ * @property {TreemapPathNode[]} treemapPath
+ * @property {number} treemapFocus
+ * @property {LoadState} treemapStatus
+ * @property {any | null} treemapReport
+ * @property {string | null} treemapError
+ * @property {ToastRecord[]} toasts
+ * @property {number} nextToastId
*/
/**
@@ -56,6 +129,8 @@ import { renderDashboard } from './dashboard-view.js';
* @property {DashKeyMap} keyMap
* @property {ContentAddressableStore} cas
* @property {BijouContext} ctx
+ * @property {string | undefined} [cwdLabel]
+ * @property {DashSource} source
*/
/**
@@ -70,199 +145,1840 @@ export function createKeyBindings() {
.bind('down', 'Down', { type: 'move', delta: 1 })
.bind('k', 'Up', { type: 'move', delta: -1 })
.bind('up', 'Up', { type: 'move', delta: -1 })
+ .bind('d', 'Page down', { type: 'page', delta: 1 })
+ .bind('pagedown', 'Page down', { type: 'page', delta: 1 })
+ .bind('u', 'Page up', { type: 'page', delta: -1 })
+ .bind('pageup', 'Page up', { type: 'page', delta: -1 })
.bind('enter', 'Load', { type: 'select' })
.bind('/', 'Filter', { type: 'filter-start' })
+ .bind('tab', 'Focus pane', { type: 'split-focus' })
+ .bind('shift+h', 'Narrow pane', { type: 'split-resize', delta: -4 })
+ .bind('shift+l', 'Widen pane', { type: 'split-resize', delta: 4 })
+ .bind('ctrl+p', 'Palette', { type: 'open-palette' })
+ .bind(':', 'Palette', { type: 'open-palette' })
+ .bind('s', 'Stats', { type: 'open-stats' })
+ .bind('g', 'Doctor', { type: 'open-doctor' })
+ .bind('t', 'Treemap', { type: 'open-treemap' })
+ .bind('r', 'Refs', { type: 'open-refs' })
+ .bind('shift+t', 'Treemap scope', { type: 'toggle-treemap-scope' })
+ .bind('i', 'Treemap files', { type: 'toggle-treemap-worktree' })
+ .bind('shift+=', 'Treemap descend', { type: 'treemap-drill-in' })
+ .bind('-', 'Treemap ascend', { type: 'treemap-drill-out' })
+ .bind('escape', 'Close overlay', { type: 'overlay-close' })
.bind('shift+j', 'Scroll down', { type: 'scroll-detail', delta: 3 })
.bind('shift+k', 'Scroll up', { type: 'scroll-detail', delta: -3 });
}
+const TABLE_COLUMNS = [
+ { header: 'Slug', width: 20 },
+ { header: 'Size', width: 8, align: 'right' },
+ { header: 'Chunks', width: 6, align: 'right' },
+ { header: 'Crypto', width: 7 },
+ { header: 'Format', width: 10 },
+ { header: 'Profile', width: 12 },
+];
+
+const DASH_HEADER_ROWS = 4;
+const DASH_FOOTER_ROWS = 4;
+const PANE_BORDER_ROWS = 2;
+const LIST_META_ROWS = 2;
+const SPLIT_MIN_LIST_WIDTH = 28;
+const SPLIT_MIN_DETAIL_WIDTH = 32;
+const SPLIT_DIVIDER_SIZE = 1;
+const TOAST_LIMIT = 4;
+const TOAST_TTL_MS = 6000;
+const TOAST_ENTER_MS = 180;
+const TOAST_EXIT_MS = 180;
+
+const PALETTE_ITEMS = [
+ {
+ id: 'refs',
+ label: 'Browse Refs',
+ description: 'List refs by namespace and switch the dashboard source to a CAS-backed ref',
+ category: 'View',
+ shortcut: 'r',
+ },
+ {
+ id: 'treemap',
+ label: 'Open Repo Treemap',
+ description: 'Full-screen semantic atlas of the repo, refs, vault, and active source',
+ category: 'View',
+ shortcut: 't',
+ },
+ {
+ id: 'treemap-scope',
+ label: 'Toggle Treemap Scope',
+ description: 'Switch the treemap between repository and source views',
+ category: 'View',
+ shortcut: 'T',
+ },
+ {
+ id: 'treemap-worktree',
+ label: 'Toggle Repo Files',
+ description: 'Switch repository treemap files between git ls-files and ignored paths',
+ category: 'View',
+ shortcut: 'i',
+ },
+ {
+ id: 'treemap-drill-in',
+ label: 'Treemap Descend',
+ description: 'Drill into the focused treemap region',
+ category: 'View',
+ shortcut: '+',
+ },
+ {
+ id: 'treemap-drill-out',
+ label: 'Treemap Ascend',
+ description: 'Return to the parent treemap level',
+ category: 'View',
+ shortcut: '-',
+ },
+ {
+ id: 'stats',
+ label: 'Open Source Stats',
+ description: 'Logical size, dedup ratio, and format coverage',
+ category: 'View',
+ shortcut: 's',
+ },
+ {
+ id: 'doctor',
+ label: 'Open Doctor Report',
+ description: 'Health summary and vault issues',
+ category: 'View',
+ shortcut: 'g',
+ },
+ {
+ id: 'focus-entries',
+ label: 'Focus Entries Pane',
+ description: 'Move focus back to the explorer table',
+ category: 'Pane',
+ shortcut: 'tab',
+ },
+ {
+ id: 'focus-inspector',
+ label: 'Focus Inspector Pane',
+ description: 'Move focus to the manifest inspector',
+ category: 'Pane',
+ },
+ {
+ id: 'close-drawer',
+ label: 'Close Active View',
+ description: 'Leave treemap view or dismiss the stats or doctor overlay',
+ category: 'View',
+ shortcut: 'esc',
+ },
+];
+
+const paletteKeyMap = commandPaletteKeyMap({
+ focusNext: { type: 'focus-next' },
+ focusPrev: { type: 'focus-prev' },
+ pageDown: { type: 'page-down' },
+ pageUp: { type: 'page-up' },
+ select: { type: 'select' },
+ close: { type: 'close' },
+});
+
+/**
+ * Format manifest bytes as a compact human-readable string for the explorer table.
+ *
+ * @param {number} bytes
+ * @returns {string}
+ */
+function formatSize(bytes) {
+ if (bytes < 1024) { return `${bytes}B`; }
+ if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)}K`; }
+ if (bytes < 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(1)}M`; }
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
+}
+
/**
- * Create the initial model.
+ * Table viewport height based on the full dashboard frame.
*
- * @param {BijouContext} ctx
- * @returns {DashModel}
+ * @param {number} rows
+ * @returns {number}
+ */
+function tableHeight(rows) {
+ return Math.max(1, rows - DASH_HEADER_ROWS - DASH_FOOTER_ROWS - PANE_BORDER_ROWS - LIST_META_ROWS);
+}
+
+/**
+ * Clamp table scroll so the focused row remains visible.
+ *
+ * @param {{ focusRow: number, scrollY: number, height: number, totalRows: number }} options
+ * @returns {number}
+ */
+function adjustTableScroll(options) {
+ let nextScroll = options.scrollY;
+ if (options.focusRow < nextScroll) {
+ nextScroll = options.focusRow;
+ } else if (options.focusRow >= nextScroll + options.height) {
+ nextScroll = options.focusRow - options.height + 1;
+ }
+ return Math.min(nextScroll, Math.max(0, options.totalRows - options.height));
+}
+
+/**
+ * Build explorer table rows from the filtered vault entries.
+ *
+ * @param {VaultEntry[]} entries
+ * @param {Map} manifestCache
+ * @returns {string[][]}
+ */
+function buildTableRows(entries, manifestCache) {
+ return entries.map((entry) => {
+ const manifest = manifestCache.get(entry.slug);
+ if (!manifest) {
+ return [entry.slug, '...', '...', '...', '...', 'loading'];
+ }
+ const m = manifest.toJSON ? manifest.toJSON() : manifest;
+ const crypto = m.encryption ? 'enc' : 'plain';
+ const format = m.compression ? m.compression.algorithm : 'raw';
+ const profile = m.subManifests?.length ? `${format} merkle` : `${format} single`;
+ return [
+ entry.slug,
+ formatSize(m.size ?? 0),
+ String(m.chunks?.length ?? 0),
+ crypto,
+ format,
+ profile,
+ ];
+ });
+}
+
+/**
+ * Synchronize derived table rows and viewport metrics after a model change.
+ *
+ * @param {NavigableTableState} table
+ * @param {{
+ * entries?: VaultEntry[],
+ * manifestCache?: Map,
+ * rows?: number,
+ * focusRow?: number,
+ * scrollY?: number,
+ * }} updates
+ * @returns {NavigableTableState}
*/
-function createInitModel(ctx) {
+function syncTable(table, updates = {}) {
+ const rows = buildTableRows(updates.entries ?? [], updates.manifestCache ?? new Map());
+ const height = tableHeight(updates.rows ?? 24);
+ const focusRow = Math.max(0, Math.min(updates.focusRow ?? table.focusRow, rows.length - 1));
+ const scrollY = adjustTableScroll({
+ focusRow,
+ scrollY: updates.scrollY ?? table.scrollY,
+ height,
+ totalRows: rows.length,
+ });
return {
- status: 'loading',
- columns: ctx.runtime.columns ?? 80,
- rows: ctx.runtime.rows ?? 24,
- entries: [],
- filtered: [],
- cursor: 0,
- filterText: '',
- filtering: false,
- metadata: null,
- manifestCache: new Map(),
- loadingSlug: null,
- detailScroll: 0,
- error: null,
+ ...table,
+ rows,
+ height,
+ focusRow,
+ scrollY,
};
}
/**
- * Apply filter text to entries.
+ * Return true when two dashboard sources describe the same target.
*
- * @param {VaultEntry[]} entries
- * @param {string} text
- * @returns {VaultEntry[]}
+ * @param {DashSource} left
+ * @param {DashSource} right
+ * @returns {boolean}
*/
-function applyFilter(entries, text) {
- if (!text) { return entries; }
- return entries.filter((/** @type {VaultEntry} */ e) => e.slug.includes(text));
+function sourceEquals(left, right) {
+ if (left.type !== right.type) {
+ return false;
+ }
+ if (left.type === 'vault') {
+ return true;
+ }
+ if (left.type === 'ref' && right.type === 'ref') {
+ return left.ref === right.ref;
+ }
+ return left.type === 'oid' && right.type === 'oid' && left.treeOid === right.treeOid;
}
/**
- * Handle the loaded-entries message.
+ * Return true when two treemap drill paths describe the same level.
+ *
+ * @param {TreemapPathNode[]} left
+ * @param {TreemapPathNode[]} right
+ * @returns {boolean}
+ */
+function treemapPathEquals(left, right) {
+ if (left.length !== right.length) {
+ return false;
+ }
+ return left.every((node, index) =>
+ node.kind === right[index]?.kind
+ && node.label === right[index]?.label
+ && node.segments.length === right[index]?.segments.length
+ && node.segments.every((segment, segmentIndex) => segment === right[index]?.segments[segmentIndex]));
+}
+
+/**
+ * Clamp treemap focus to the current tile list.
+ *
+ * @param {number} focus
+ * @param {RepoTreemapTile[]} tiles
+ * @returns {number}
+ */
+function clampTreemapFocus(focus, tiles) {
+ return Math.max(0, Math.min(focus, tiles.length - 1));
+}
+
+/**
+ * Return the selected treemap tile from the current report.
*
- * @param {DashMsg & { type: 'loaded-entries' }} msg
* @param {DashModel} model
- * @param {ContentAddressableStore} cas
+ * @returns {RepoTreemapTile | null}
+ */
+function selectedTreemapTile(model) {
+ return model.treemapReport?.tiles[clampTreemapFocus(model.treemapFocus, model.treemapReport?.tiles ?? [])] ?? null;
+}
+
+/**
+ * Build rows for the refs browser table.
+ *
+ * @param {RefInventoryItem[]} refs
+ * @returns {string[][]}
+ */
+function buildRefRows(refs) {
+ return refs.map((ref) => [
+ ref.namespace,
+ ref.ref,
+ ref.browsable ? ref.resolution : 'opaque',
+ String(ref.entryCount),
+ ref.oid.slice(0, 12),
+ ]);
+}
+
+/**
+ * Synchronize refs-browser rows and viewport metrics after a model change.
+ *
+ * @param {NavigableTableState} table
+ * @param {{
+ * refs?: RefInventoryItem[],
+ * rows?: number,
+ * focusRow?: number,
+ * scrollY?: number,
+ * }} updates
+ * @returns {NavigableTableState}
+ */
+function syncRefsTable(table, updates = {}) {
+ const rows = buildRefRows(updates.refs ?? []);
+ const height = tableHeight(updates.rows ?? 24);
+ const focusRow = Math.max(0, Math.min(updates.focusRow ?? table.focusRow, rows.length - 1));
+ const scrollY = adjustTableScroll({
+ focusRow,
+ scrollY: updates.scrollY ?? table.scrollY,
+ height,
+ totalRows: rows.length,
+ });
+ return {
+ ...table,
+ rows,
+ height,
+ focusRow,
+ scrollY,
+ };
+}
+
+/**
+ * Palette viewport height based on terminal rows.
+ *
+ * @param {number} rows
+ * @returns {number}
+ */
+function paletteHeight(rows) {
+ return Math.max(5, Math.min(10, rows - 10));
+}
+
+/**
+ * Create a fresh command palette state for the dashboard.
+ *
+ * @param {number} rows
+ * @returns {CommandPaletteState}
+ */
+function createPalette(rows) {
+ return createCommandPaletteState(PALETTE_ITEMS, paletteHeight(rows));
+}
+
+/**
+ * Replace the palette state on the model.
+ *
+ * @param {DashModel} model
+ * @param {CommandPaletteState | null} palette
* @returns {[DashModel, DashCmd[]]}
*/
-function handleLoadedEntries(msg, model, cas) {
- const filtered = applyFilter(msg.entries, model.filterText);
- const cursor = Math.max(0, Math.min(model.cursor, filtered.length - 1));
- const cmds = /** @type {DashCmd[]} */ (msg.entries.map((/** @type {VaultEntry} */ e) => loadManifestCmd(cas, e.slug, e.treeOid)));
+function setPalette(model, palette) {
+ return [{ ...model, palette }, []];
+}
+
+/**
+ * Add a toast notification and schedule its dismissal.
+ *
+ * @param {DashModel} model
+ * @param {{ level: ToastLevel, title: string, message: string }} toastSpec
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function addToast(model, toastSpec) {
+ const id = model.nextToastId;
+ const toast = { id, ...toastSpec, phase: 'entering', progress: 0 };
return [{
...model,
- status: 'ready',
- entries: msg.entries,
- filtered,
- cursor,
- metadata: msg.metadata,
- }, cmds];
+ nextToastId: id + 1,
+ toasts: [toast, ...model.toasts].slice(0, TOAST_LIMIT),
+ }, [
+ animateToast(id, 0, 1),
+ /** @type {DashCmd} */ (tick(TOAST_TTL_MS, { type: 'toast-expire', id })),
+ ]];
}
/**
- * Handle a loaded-manifest message.
+ * Dismiss a toast by id.
*
- * @param {DashMsg & { type: 'loaded-manifest' }} msg
* @param {DashModel} model
+ * @param {number} id
* @returns {[DashModel, DashCmd[]]}
*/
-function handleLoadedManifest(msg, model) {
- const cache = new Map(model.manifestCache);
- cache.set(msg.slug, msg.manifest);
- return [{ ...model, manifestCache: cache }, []];
+function dismissToast(model, id) {
+ return [{
+ ...model,
+ toasts: model.toasts.filter((toast) => toast.id !== id),
+ }, []];
}
/**
- * Handle cursor movement.
+ * Animate one toast progress value.
+ *
+ * @param {number} id
+ * @param {number} from
+ * @param {number} to
+ * @returns {DashCmd}
+ */
+function animateToast(id, from, to) {
+ const duration = from < to ? TOAST_ENTER_MS : TOAST_EXIT_MS;
+ return /** @type {DashCmd} */ (animate({
+ type: 'tween',
+ from,
+ to,
+ duration,
+ onFrame: (progress) => ({ type: 'toast-progress', id, progress }),
+ }));
+}
+
+/**
+ * Update one toast record by id.
*
- * @param {{ type: 'move', delta: number }} msg
* @param {DashModel} model
- * @returns {[DashModel, DashCmd[]]}
+ * @param {number} id
+ * @param {(toast: ToastRecord) => ToastRecord} updater
+ * @returns {DashModel}
*/
-function handleMove(msg, model) {
- const max = model.filtered.length - 1;
- const cursor = Math.max(0, Math.min(max, model.cursor + msg.delta));
- return [{ ...model, cursor, detailScroll: 0 }, []];
+function updateToast(model, id, updater) {
+ let changed = false;
+ const toasts = model.toasts.map((toast) => {
+ if (toast.id !== id) {
+ return toast;
+ }
+ changed = true;
+ return updater(toast);
+ });
+ return changed ? { ...model, toasts } : model;
}
/**
- * Handle filter key input in filter mode.
+ * Begin toast exit animation when a toast is dismissed or expires.
*
- * @param {KeyMsg} msg
* @param {DashModel} model
+ * @param {number} id
* @returns {[DashModel, DashCmd[]]}
*/
-function handleFilterKey(msg, model) {
- if (msg.key === 'escape' || msg.key === 'enter') {
- return [{ ...model, filtering: false }, []];
+function startToastExit(model, id) {
+ const toast = model.toasts.find((entry) => entry.id === id);
+ if (!toast) {
+ return [model, []];
}
- if (msg.key === 'backspace') {
- const text = model.filterText.slice(0, -1);
- const filtered = applyFilter(model.entries, text);
- return [{ ...model, filterText: text, filtered, cursor: 0 }, []];
+ if (toast.phase === 'exiting') {
+ return [model, []];
}
- if (msg.key.length === 1) {
- const text = model.filterText + msg.key;
- const filtered = applyFilter(model.entries, text);
- return [{ ...model, filterText: text, filtered, cursor: 0 }, []];
+ const nextModel = updateToast(model, id, (entry) => ({
+ ...entry,
+ phase: 'exiting',
+ }));
+ return [nextModel, [
+ animateToast(id, toast.progress, 0),
+ /** @type {DashCmd} */ (tick(TOAST_EXIT_MS + 16, { type: 'dismiss-toast', id })),
+ ]];
+}
+
+/**
+ * Return true when a treemap load message is stale for the current model.
+ *
+ * @param {{ scopeId?: TreemapScope, worktreeMode?: TreemapWorktreeMode, drillPath?: TreemapPathNode[] }} msg
+ * @param {DashModel} model
+ * @returns {boolean}
+ */
+function isStaleTreemapLoad(msg, model) {
+ if (msg.scopeId && msg.scopeId !== model.treemapScope) {
+ return true;
}
- return [model, []];
+ if (msg.drillPath && !treemapPathEquals(msg.drillPath, model.treemapPath)) {
+ return true;
+ }
+ return msg.scopeId === 'repository'
+ && Boolean(msg.worktreeMode)
+ && msg.worktreeMode !== model.treemapWorktreeMode;
}
/**
- * Handle select (enter key) to load manifest.
+ * Return true when a load/error message was emitted for a source that is no
+ * longer active on the dashboard.
*
+ * @param {{ forSource?: DashSource }} msg
* @param {DashModel} model
- * @param {DashDeps} deps
- * @returns {[DashModel, DashCmd[]]}
+ * @returns {boolean}
*/
-function handleSelect(model, deps) {
- const entry = model.filtered[model.cursor];
- if (!entry || model.manifestCache.has(entry.slug)) {
- return [model, []];
+function isStaleSourceLoad(msg, model) {
+ return Boolean(msg.forSource) && !sourceEquals(msg.forSource, model.source);
+}
+
+/**
+ * Apply load/error state for source-scoped async operations.
+ *
+ * @param {DashMsg & { type: 'load-error' }} msg
+ * @param {DashModel} model
+ * @returns {DashModel}
+ */
+function applySourceLoadErrorState(msg, model) {
+ if (msg.source === 'entries') {
+ return isStaleSourceLoad(msg, model) ? model : { ...model, status: 'error', error: msg.error };
}
- const cmd = /** @type {DashCmd} */ (loadManifestCmd(deps.cas, entry.slug, entry.treeOid));
- return [{ ...model, loadingSlug: entry.slug }, [cmd]];
+ if (msg.source === 'manifest') {
+ return isStaleSourceLoad(msg, model)
+ ? model
+ : { ...model, loadingSlug: model.loadingSlug === msg.slug ? null : model.loadingSlug };
+ }
+ if (msg.source === 'stats') {
+ return isStaleSourceLoad(msg, model) ? model : { ...model, statsStatus: 'error', statsError: msg.error };
+ }
+ if (msg.source === 'doctor') {
+ return isStaleSourceLoad(msg, model) ? model : { ...model, doctorStatus: 'error', doctorError: msg.error };
+ }
+ return model;
}
/**
- * Handle keymap actions.
+ * Apply state changes caused by an async load error.
+ *
+ * @param {DashMsg & { type: 'load-error' }} msg
+ * @param {DashModel} model
+ * @returns {DashModel}
+ */
+function applyLoadErrorState(msg, model) {
+ if (msg.source === 'refs') {
+ return { ...model, refsStatus: 'error', refsError: msg.error };
+ }
+ if (msg.source === 'treemap') {
+ return isStaleTreemapLoad(msg, model) ? model : { ...model, treemapStatus: 'error', treemapError: msg.error };
+ }
+ if (['entries', 'manifest', 'stats', 'doctor'].includes(msg.source)) {
+ return applySourceLoadErrorState(msg, model);
+ }
+ return { ...model, status: 'error', error: msg.error };
+}
+
+/**
+ * Human-readable toast title for async load errors.
+ *
+ * @param {DashMsg & { type: 'load-error' }} msg
+ * @returns {string}
+ */
+function loadErrorTitle(msg) {
+ if (msg.source === 'manifest') {
+ return msg.slug ? `Failed to load ${msg.slug}` : 'Failed to load manifest';
+ }
+ if (msg.source === 'stats') {
+ return 'Failed to load source stats';
+ }
+ if (msg.source === 'doctor') {
+ return 'Failed to load doctor report';
+ }
+ if (msg.source === 'refs') {
+ return 'Failed to load refs';
+ }
+ if (msg.source === 'treemap') {
+ return 'Failed to load repo treemap';
+ }
+ return 'Failed to load entries';
+}
+
+/**
+ * Create the initial explorer table state.
+ *
+ * @param {number} rows
+ * @returns {NavigableTableState}
+ */
+function createInitTable(rows) {
+ return createNavigableTableState({
+ columns: TABLE_COLUMNS,
+ rows: [],
+ height: tableHeight(rows),
+ });
+}
+
+/**
+ * Create the initial refs-browser table state.
+ *
+ * @param {number} rows
+ * @returns {NavigableTableState}
+ */
+function createInitRefsTable(rows) {
+ return createNavigableTableState({
+ columns: [
+ { header: 'Namespace', width: 14 },
+ { header: 'Ref', width: 34 },
+ { header: 'Kind', width: 10 },
+ { header: 'Entries', width: 7, align: 'right' },
+ { header: 'OID', width: 12 },
+ ],
+ rows: [],
+ height: tableHeight(rows),
+ });
+}
+
+/**
+ * Create the initial model.
+ *
+ * @param {BijouContext} ctx
+ * @param {DashSource} source
+ * @returns {DashModel}
+ */
+function createInitModel(ctx, source) {
+ const rows = ctx.runtime.rows ?? 24;
+ return {
+ status: 'loading',
+ columns: ctx.runtime.columns ?? 80,
+ rows,
+ source,
+ entries: [],
+ filtered: [],
+ filterText: '',
+ filtering: false,
+ metadata: null,
+ manifestCache: new Map(),
+ loadingSlug: null,
+ detailScroll: 0,
+ error: null,
+ table: createInitTable(rows),
+ refsTable: createInitRefsTable(rows),
+ refsItems: [],
+ splitPane: createSplitPaneState({ ratio: 0.37, focused: 'a' }),
+ palette: null,
+ activeDrawer: null,
+ refsStatus: 'idle',
+ refsError: null,
+ statsStatus: 'idle',
+ statsReport: null,
+ statsError: null,
+ doctorStatus: 'idle',
+ doctorReport: null,
+ doctorError: null,
+ treemapScope: 'repository',
+ treemapWorktreeMode: 'tracked',
+ treemapPath: [],
+ treemapFocus: 0,
+ treemapStatus: 'idle',
+ treemapReport: null,
+ treemapError: null,
+ toasts: [],
+ nextToastId: 1,
+ };
+}
+
+/**
+ * Handle actions that are specific to full-screen refs mode.
*
* @param {DashAction} action
* @param {DashModel} model
* @param {DashDeps} deps
- * @returns {[DashModel, DashCmd[]]}
+ * @returns {[DashModel, DashCmd[]] | null}
*/
-function handleAction(action, model, deps) {
- if (action.type === 'quit') { return [model, [quit()]]; }
- if (action.type === 'move') { return handleMove(action, model); }
- if (action.type === 'filter-start') {
- return [{ ...model, filtering: true, filterText: '', filtered: model.entries, cursor: 0 }, []];
+function handleRefsViewAction(action, model, deps) {
+ if (model.activeDrawer !== 'refs') {
+ return null;
}
- if (action.type === 'scroll-detail') {
- const scroll = Math.max(0, model.detailScroll + action.delta);
- return [{ ...model, detailScroll: scroll }, []];
+ if (action.type === 'move') {
+ return handleRefsMove(action, model);
}
- if (action.type === 'select') { return handleSelect(model, deps); }
- return [model, []];
+ if (action.type === 'page') {
+ return handleRefsPage(action, model);
+ }
+ if (action.type === 'select') {
+ return handleRefSelect(model, deps);
+ }
+ if (isBlockedByTreemapView(action)) {
+ return [model, []];
+ }
+ return null;
}
/**
- * Handle app-level messages (data loading results).
+ * Handle cursor movement inside the treemap view.
*
- * @param {DashMsg} msg
+ * @param {{ type: 'move', delta: number }} action
* @param {DashModel} model
- * @param {ContentAddressableStore} cas
* @returns {[DashModel, DashCmd[]]}
*/
-function handleAppMsg(msg, model, cas) {
- if (msg.type === 'loaded-entries') { return handleLoadedEntries(msg, model, cas); }
- if (msg.type === 'loaded-manifest') { return handleLoadedManifest(msg, model); }
- if (msg.type === 'load-error') {
- if (msg.source === 'manifest') {
- return [model, []];
- }
- return [{ ...model, status: 'error', error: msg.error }, []];
+function handleTreemapMove(action, model) {
+ const total = model.treemapReport?.tiles.length ?? 0;
+ if (total === 0) {
+ return [model, []];
}
- return [model, []];
+ const treemapFocus = (model.treemapFocus + action.delta + total) % total;
+ return [{ ...model, treemapFocus }, []];
}
/**
- * Route all update messages to the appropriate handler.
+ * Handle page-wise movement inside the treemap view.
*
- * @param {KeyMsg | ResizeMsg | DashMsg} msg
+ * @param {{ type: 'page', delta: number }} action
* @param {DashModel} model
- * @param {DashDeps} deps
* @returns {[DashModel, DashCmd[]]}
*/
-function handleUpdate(msg, model, deps) {
- if (msg.type === 'key' && model.filtering) {
- return handleFilterKey(msg, model);
- }
- if (msg.type === 'key') {
- const action = deps.keyMap.handle(msg);
- if (action) { return handleAction(action, model, deps); }
+function handleTreemapPage(action, model) {
+ const total = model.treemapReport?.tiles.length ?? 0;
+ if (total === 0) {
return [model, []];
}
- if (msg.type === 'resize') {
- return [{ ...model, columns: msg.columns, rows: msg.rows }, []];
- }
- return handleAppMsg(/** @type {DashMsg} */ (msg), model, deps.cas);
+ const pageSize = Math.max(1, Math.min(8, model.rows - 16));
+ const treemapFocus = clampTreemapFocus(model.treemapFocus + (action.delta * pageSize), model.treemapReport?.tiles ?? []);
+ return [{ ...model, treemapFocus }, []];
+}
+
+/**
+ * Descend into the focused treemap region when it has child nodes.
+ *
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handleTreemapDrillIn(model, deps) {
+ const tile = selectedTreemapTile(model);
+ if (!tile) {
+ return [model, []];
+ }
+ if (!tile.drillable || !tile.path) {
+ return addToast(model, {
+ level: 'info',
+ title: 'Leaf region',
+ message: `${tile.label} does not have a deeper treemap level.`,
+ });
+ }
+
+ const nextModel = {
+ ...model,
+ treemapPath: [...model.treemapPath, tile.path],
+ treemapFocus: 0,
+ activeDrawer: 'treemap',
+ palette: null,
+ };
+ if (treemapReportMatches(nextModel, model.treemapReport)) {
+ return [{
+ ...nextModel,
+ treemapStatus: 'ready',
+ treemapError: null,
+ }, []];
+ }
+ return [{
+ ...nextModel,
+ treemapStatus: 'loading',
+ treemapError: null,
+ }, [treemapLoad(nextModel, deps)]];
+}
+
+/**
+ * Ascend to the parent treemap level.
+ *
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handleTreemapDrillOut(model, deps) {
+ if (model.treemapPath.length === 0) {
+ return [model, []];
+ }
+ const nextModel = {
+ ...model,
+ treemapPath: model.treemapPath.slice(0, -1),
+ treemapFocus: 0,
+ activeDrawer: 'treemap',
+ palette: null,
+ };
+ if (treemapReportMatches(nextModel, model.treemapReport)) {
+ return [{
+ ...nextModel,
+ treemapStatus: 'ready',
+ treemapError: null,
+ }, []];
+ }
+ return [{
+ ...nextModel,
+ treemapStatus: 'loading',
+ treemapError: null,
+ }, [treemapLoad(nextModel, deps)]];
+}
+
+/**
+ * Handle actions that are specific to the full-screen treemap view.
+ *
+ * @param {DashAction} action
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @returns {[DashModel, DashCmd[]] | null}
+ */
+function handleTreemapViewAction(action, model, deps) {
+ if (model.activeDrawer !== 'treemap') {
+ return null;
+ }
+ if (action.type === 'move') {
+ return handleTreemapMove(action, model);
+ }
+ if (action.type === 'page') {
+ return handleTreemapPage(action, model);
+ }
+ if (action.type === 'select' || action.type === 'treemap-drill-in') {
+ return handleTreemapDrillIn(model, deps);
+ }
+ if (action.type === 'treemap-drill-out') {
+ return handleTreemapDrillOut(model, deps);
+ }
+ return null;
+}
+
+/**
+ * Apply filter text to entries.
+ *
+ * @param {VaultEntry[]} entries
+ * @param {string} text
+ * @returns {VaultEntry[]}
+ */
+function applyFilter(entries, text) {
+ if (!text) { return entries; }
+ return entries.filter((/** @type {VaultEntry} */ e) => e.slug.includes(text));
+}
+
+/**
+ * Handle the loaded-entries message.
+ *
+ * @param {DashMsg & { type: 'loaded-entries' }} msg
+ * @param {DashModel} model
+ * @param {ContentAddressableStore} cas
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handleLoadedEntries(msg, model, cas) {
+ if (!sourceEquals(msg.source, model.source)) {
+ return [model, []];
+ }
+ const filtered = applyFilter(msg.entries, model.filterText);
+ const table = syncTable(model.table, {
+ entries: filtered,
+ manifestCache: model.manifestCache,
+ rows: model.rows,
+ });
+ const cmds = /** @type {DashCmd[]} */ (msg.entries.map((/** @type {VaultEntry} */ e) => loadManifestCmd(cas, {
+ slug: e.slug,
+ treeOid: e.treeOid,
+ source: msg.source,
+ })));
+ return [{
+ ...model,
+ status: 'ready',
+ entries: msg.entries,
+ filtered,
+ metadata: msg.metadata,
+ loadingSlug: null,
+ table,
+ }, cmds];
+}
+
+/**
+ * Handle a loaded-manifest message.
+ *
+ * @param {DashMsg & { type: 'loaded-manifest' }} msg
+ * @param {DashModel} model
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handleLoadedManifest(msg, model) {
+ if (!sourceEquals(msg.source, model.source)) {
+ return [model, []];
+ }
+ const cache = new Map(model.manifestCache);
+ cache.set(msg.slug, msg.manifest);
+ const table = syncTable(model.table, {
+ entries: model.filtered,
+ manifestCache: cache,
+ rows: model.rows,
+ });
+ return [{
+ ...model,
+ manifestCache: cache,
+ loadingSlug: model.loadingSlug === msg.slug ? null : model.loadingSlug,
+ table,
+ }, []];
+}
+
+/**
+ * Handle cursor movement.
+ *
+ * @param {{ type: 'move', delta: number }} msg
+ * @param {DashModel} model
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handleMove(msg, model) {
+ const table = msg.delta > 0 ? navTableFocusNext(model.table) : navTableFocusPrev(model.table);
+ return [{ ...model, table, detailScroll: 0 }, []];
+}
+
+/**
+ * Handle page-wise table navigation.
+ *
+ * @param {{ type: 'page', delta: number }} msg
+ * @param {DashModel} model
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handlePage(msg, model) {
+ const table = msg.delta > 0 ? navTablePageDown(model.table) : navTablePageUp(model.table);
+ return [{ ...model, table, detailScroll: 0 }, []];
+}
+
+/**
+ * Handle filter key input in filter mode.
+ *
+ * @param {KeyMsg} msg
+ * @param {DashModel} model
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handleFilterKey(msg, model) {
+ if (msg.key === 'escape' || msg.key === 'enter') {
+ return [{ ...model, filtering: false }, []];
+ }
+ if (msg.key === 'backspace') {
+ const text = model.filterText.slice(0, -1);
+ const filtered = applyFilter(model.entries, text);
+ const table = syncTable(model.table, {
+ entries: filtered,
+ manifestCache: model.manifestCache,
+ rows: model.rows,
+ focusRow: 0,
+ scrollY: 0,
+ });
+ return [{ ...model, filterText: text, filtered, table }, []];
+ }
+ if (msg.key.length === 1) {
+ const text = model.filterText + msg.key;
+ const filtered = applyFilter(model.entries, text);
+ const table = syncTable(model.table, {
+ entries: filtered,
+ manifestCache: model.manifestCache,
+ rows: model.rows,
+ focusRow: 0,
+ scrollY: 0,
+ });
+ return [{ ...model, filterText: text, filtered, table }, []];
+ }
+ return [model, []];
+}
+
+/**
+ * Handle select (enter key) to load manifest.
+ *
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handleSelect(model, deps) {
+ const entry = model.filtered[model.table.focusRow];
+ if (!entry) {
+ return [model, []];
+ }
+ if (model.manifestCache.has(entry.slug)) {
+ return [{ ...model, splitPane: { ...model.splitPane, focused: 'b' } }, []];
+ }
+ const cmd = /** @type {DashCmd} */ (loadManifestCmd(deps.cas, {
+ slug: entry.slug,
+ treeOid: entry.treeOid,
+ source: model.source,
+ }));
+ return [{
+ ...model,
+ loadingSlug: entry.slug,
+ splitPane: { ...model.splitPane, focused: 'b' },
+ }, [cmd]];
+}
+
+/**
+ * Open the refs browser and trigger a load when needed.
+ *
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function openRefsDrawer(model, deps) {
+ if (model.refsStatus === 'ready' || model.refsStatus === 'loading') {
+ return [{
+ ...model,
+ activeDrawer: 'refs',
+ palette: null,
+ }, []];
+ }
+ return [{
+ ...model,
+ activeDrawer: 'refs',
+ palette: null,
+ refsStatus: 'loading',
+ refsError: null,
+ }, [/** @type {DashCmd} */ (loadRefsCmd(deps.cas))]];
+}
+
+/**
+ * Open the stats drawer and trigger a load when needed.
+ *
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function openStatsDrawer(model, deps) {
+ if (model.statsStatus === 'ready' || model.statsStatus === 'loading') {
+ return [{
+ ...model,
+ activeDrawer: 'stats',
+ palette: null,
+ }, []];
+ }
+ return [{
+ ...model,
+ activeDrawer: 'stats',
+ palette: null,
+ statsStatus: 'loading',
+ statsError: null,
+ }, [/** @type {DashCmd} */ (loadStatsCmd(deps.cas, model.entries, model.source))]];
+}
+
+/**
+ * Open the doctor drawer and trigger a load when needed.
+ *
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function openDoctorDrawer(model, deps) {
+ if (model.doctorStatus === 'ready' || model.doctorStatus === 'loading') {
+ return [{
+ ...model,
+ activeDrawer: 'doctor',
+ palette: null,
+ }, []];
+ }
+ return [{
+ ...model,
+ activeDrawer: 'doctor',
+ palette: null,
+ doctorStatus: 'loading',
+ doctorError: null,
+ }, [/** @type {DashCmd} */ (loadDoctorCmd(deps.cas, model.source, model.entries))]];
+}
+
+/**
+ * Build a treemap load command from the current dashboard state.
+ *
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @param {{ scope?: TreemapScope, worktreeMode?: TreemapWorktreeMode, drillPath?: TreemapPathNode[] }} [overrides]
+ * @returns {DashCmd}
+ */
+function treemapLoad(model, deps, overrides = {}) {
+ return /** @type {DashCmd} */ (loadTreemapCmd(deps.cas, {
+ source: model.source,
+ scope: overrides.scope ?? model.treemapScope,
+ worktreeMode: overrides.worktreeMode ?? model.treemapWorktreeMode,
+ drillPath: overrides.drillPath ?? model.treemapPath,
+ }));
+}
+
+/**
+ * Open the repo treemap view and trigger a load when needed.
+ *
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function openTreemapDrawer(model, deps) {
+ if (treemapReportMatches(model, model.treemapReport)) {
+ return [{
+ ...model,
+ activeDrawer: 'treemap',
+ palette: null,
+ }, []];
+ }
+ if (model.treemapStatus === 'loading') {
+ return [{
+ ...model,
+ activeDrawer: 'treemap',
+ palette: null,
+ }, []];
+ }
+ return [{
+ ...model,
+ activeDrawer: 'treemap',
+ palette: null,
+ treemapStatus: 'loading',
+ treemapError: null,
+ }, [treemapLoad(model, deps)]];
+}
+
+/**
+ * Keep the refs browser state stable while a source switch reloads entries.
+ *
+ * @param {DashModel} model
+ * @returns {{ refsStatus: LoadState, refsItems: RefInventoryItem[], refsTable: NavigableTableState }}
+ */
+function preserveRefsState(model) {
+ return {
+ refsStatus: model.refsStatus,
+ refsItems: model.refsItems,
+ refsTable: syncRefsTable(model.refsTable, {
+ refs: model.refsItems,
+ rows: model.rows,
+ focusRow: model.refsTable.focusRow,
+ scrollY: model.refsTable.scrollY,
+ }),
+ };
+}
+
+/**
+ * Reset source-scoped explorer state ahead of loading a different source.
+ *
+ * @param {DashModel} model
+ * @param {DashSource} source
+ * @returns {DashModel}
+ */
+function buildSourceSwitchModel(model, source) {
+ const clearedTable = syncTable(model.table, {
+ entries: [],
+ manifestCache: new Map(),
+ rows: model.rows,
+ focusRow: 0,
+ scrollY: 0,
+ });
+
+ return {
+ ...model,
+ ...preserveRefsState(model),
+ palette: null,
+ activeDrawer: null,
+ source,
+ status: 'loading',
+ entries: [],
+ filtered: [],
+ filterText: '',
+ filtering: false,
+ metadata: null,
+ manifestCache: new Map(),
+ loadingSlug: null,
+ detailScroll: 0,
+ error: null,
+ table: clearedTable,
+ splitPane: { ...model.splitPane, focused: 'a' },
+ statsStatus: 'idle',
+ statsReport: null,
+ statsError: null,
+ doctorStatus: 'idle',
+ doctorReport: null,
+ doctorError: null,
+ treemapStatus: 'idle',
+ treemapReport: null,
+ treemapError: null,
+ treemapPath: [],
+ treemapFocus: 0,
+ };
+}
+
+/**
+ * Toggle the treemap between repository and source scopes.
+ *
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function toggleTreemapScope(model, deps) {
+ const treemapScope = model.treemapScope === 'repository' ? 'source' : 'repository';
+ const nextModel = {
+ ...model,
+ treemapScope,
+ treemapPath: [],
+ treemapFocus: 0,
+ };
+ if (treemapReportMatches(nextModel, model.treemapReport)) {
+ return [{
+ ...nextModel,
+ activeDrawer: 'treemap',
+ palette: null,
+ treemapStatus: 'ready',
+ treemapError: null,
+ }, []];
+ }
+ return [{
+ ...nextModel,
+ activeDrawer: 'treemap',
+ palette: null,
+ treemapStatus: 'loading',
+ treemapError: null,
+ }, [treemapLoad(nextModel, deps)]];
+}
+
+/**
+ * Toggle repository treemap file visibility between tracked and ignored paths.
+ *
+ * This control is repository-specific, so switching visibility also returns the
+ * view to repository scope when needed.
+ *
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function toggleTreemapWorktreeMode(model, deps) {
+ const treemapWorktreeMode = model.treemapWorktreeMode === 'tracked' ? 'ignored' : 'tracked';
+ const nextModel = {
+ ...model,
+ treemapScope: 'repository',
+ treemapWorktreeMode,
+ treemapPath: [],
+ treemapFocus: 0,
+ activeDrawer: 'treemap',
+ palette: null,
+ };
+ if (treemapReportMatches(nextModel, model.treemapReport)) {
+ return [{
+ ...nextModel,
+ treemapStatus: 'ready',
+ treemapError: null,
+ }, []];
+ }
+ return [{
+ ...nextModel,
+ treemapStatus: 'loading',
+ treemapError: null,
+ }, [treemapLoad(nextModel, deps)]];
+}
+
+/**
+ * Close the command palette or active view, whichever is visible.
+ *
+ * @param {DashModel} model
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function closeOverlay(model) {
+ if (model.palette) {
+ return [{ ...model, palette: null }, []];
+ }
+ if (model.activeDrawer) {
+ return [{ ...model, activeDrawer: null }, []];
+ }
+ if (model.toasts.length > 0) {
+ return startToastExit(model, model.toasts[0].id);
+ }
+ return [model, []];
+}
+
+/**
+ * Focus a specific split pane from the command palette.
+ *
+ * @param {DashModel} model
+ * @param {'a' | 'b'} pane
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function focusPane(model, pane) {
+ return [{
+ ...model,
+ palette: null,
+ splitPane: { ...model.splitPane, focused: pane },
+ }, []];
+}
+
+/**
+ * Close the active view from the command palette.
+ *
+ * @param {DashModel} model
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function closeDrawerFromPalette(model) {
+ return [{
+ ...model,
+ palette: null,
+ activeDrawer: null,
+ }, []];
+}
+
+/**
+ * Switch the dashboard to a new source and reload explorer entries for it.
+ *
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @param {DashSource} source
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function switchSource(model, deps, source) {
+ if (sourceEquals(model.source, source)) {
+ return [{
+ ...model,
+ palette: null,
+ activeDrawer: null,
+ }, []];
+ }
+ return [buildSourceSwitchModel(model, source), [/** @type {DashCmd} */ (loadEntriesCmd(deps.cas, source))]];
+}
+
+/**
+ * Handle cursor movement inside the refs browser.
+ *
+ * @param {{ type: 'move', delta: number }} action
+ * @param {DashModel} model
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handleRefsMove(action, model) {
+ const refsTable = action.delta > 0 ? navTableFocusNext(model.refsTable) : navTableFocusPrev(model.refsTable);
+ return [{ ...model, refsTable }, []];
+}
+
+/**
+ * Handle page-wise movement inside the refs browser.
+ *
+ * @param {{ type: 'page', delta: number }} action
+ * @param {DashModel} model
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handleRefsPage(action, model) {
+ const refsTable = action.delta > 0 ? navTablePageDown(model.refsTable) : navTablePageUp(model.refsTable);
+ return [{ ...model, refsTable }, []];
+}
+
+/**
+ * Switch the dashboard source to the focused ref when it resolves to CAS data.
+ *
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handleRefSelect(model, deps) {
+ const ref = model.refsItems[model.refsTable.focusRow];
+ if (!ref) {
+ return [model, []];
+ }
+ if (!ref.browsable || !ref.source) {
+ return addToast(model, {
+ level: 'warning',
+ title: 'Ref is not browsable',
+ message: `${ref.ref} does not currently resolve to CAS entries.`,
+ });
+ }
+ return switchSource(model, deps, ref.source);
+}
+
+/**
+ * Apply the focused command palette item.
+ *
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handlePaletteSelect(model, deps) {
+ const item = model.palette ? cpSelectedItem(model.palette) : undefined;
+ if (!item) {
+ return [{ ...model, palette: null }, []];
+ }
+ const handlers = {
+ refs: () => openRefsDrawer(model, deps),
+ treemap: () => openTreemapDrawer(model, deps),
+ 'treemap-scope': () => toggleTreemapScope(model, deps),
+ 'treemap-worktree': () => toggleTreemapWorktreeMode(model, deps),
+ 'treemap-drill-in': () => handleTreemapDrillIn(model, deps),
+ 'treemap-drill-out': () => handleTreemapDrillOut(model, deps),
+ stats: () => openStatsDrawer(model, deps),
+ doctor: () => openDoctorDrawer(model, deps),
+ 'focus-entries': () => focusPane(model, 'a'),
+ 'focus-inspector': () => focusPane(model, 'b'),
+ 'close-drawer': () => closeDrawerFromPalette(model),
+ };
+ if (item.id in handlers) {
+ return handlers[item.id]();
+ }
+ return [{ ...model, palette: null }, []];
+}
+
+/**
+ * Apply palette navigation actions emitted by the palette keymap.
+ *
+ * @param {PaletteAction} action
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handlePaletteAction(action, model, deps) {
+ if (!model.palette) {
+ return [model, []];
+ }
+ switch (action.type) {
+ case 'focus-next':
+ return setPalette(model, cpFocusNext(model.palette));
+ case 'focus-prev':
+ return setPalette(model, cpFocusPrev(model.palette));
+ case 'page-down':
+ return setPalette(model, cpPageDown(model.palette));
+ case 'page-up':
+ return setPalette(model, cpPageUp(model.palette));
+ case 'select':
+ return handlePaletteSelect(model, deps);
+ case 'close':
+ return setPalette(model, null);
+ default:
+ return [model, []];
+ }
+}
+
+/**
+ * Update the palette query while keeping focus/scroll logic inside Bijou.
+ *
+ * @param {DashModel} model
+ * @param {string} query
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function filterPalette(model, query) {
+ if (!model.palette) {
+ return [model, []];
+ }
+ return setPalette(model, cpFilter(model.palette, query));
+}
+
+/**
+ * Return true when the key should append to the palette query.
+ *
+ * @param {KeyMsg} msg
+ * @returns {boolean}
+ */
+function isPaletteQueryKey(msg) {
+ return msg.key.length === 1 && !msg.ctrl && !msg.alt;
+}
+
+/**
+ * Route key input while the command palette is open.
+ *
+ * @param {KeyMsg} msg
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handlePaletteKey(msg, model, deps) {
+ if (!model.palette) {
+ return [model, []];
+ }
+ const action = /** @type {PaletteAction | undefined} */ (paletteKeyMap.handle(msg));
+ if (action) {
+ return handlePaletteAction(action, model, deps);
+ }
+ if (msg.key === 'backspace') {
+ return filterPalette(model, model.palette.query.slice(0, -1));
+ }
+ if (isPaletteQueryKey(msg)) {
+ return filterPalette(model, model.palette.query + msg.key);
+ }
+ return [model, []];
+}
+
+/**
+ * Start filter mode with the full entry set visible.
+ *
+ * @param {DashModel} model
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function startFilter(model) {
+ const filtered = model.entries;
+ const table = syncTable(model.table, {
+ entries: filtered,
+ manifestCache: model.manifestCache,
+ rows: model.rows,
+ focusRow: 0,
+ scrollY: 0,
+ });
+ return [{ ...model, filtering: true, filterText: '', filtered, table }, []];
+}
+
+/**
+ * Resize the currently focused split pane.
+ *
+ * @param {DashModel} model
+ * @param {number} delta
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function resizeSplitPane(model, delta) {
+ const signedDelta = model.splitPane.focused === 'a' ? delta : -delta;
+ const splitPane = splitPaneResizeBy(model.splitPane, signedDelta, {
+ total: model.columns,
+ minA: SPLIT_MIN_LIST_WIDTH,
+ minB: SPLIT_MIN_DETAIL_WIDTH,
+ dividerSize: SPLIT_DIVIDER_SIZE,
+ });
+ return [{ ...model, splitPane }, []];
+}
+
+/**
+ * Handle overlay-related actions.
+ *
+ * @param {DashAction} action
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @returns {[DashModel, DashCmd[]] | null}
+ */
+function handleOverlayAction(action, model, deps) {
+ const handlers = {
+ 'open-palette': () => setPalette(model, createPalette(model.rows)),
+ 'open-stats': () => openStatsDrawer(model, deps),
+ 'open-doctor': () => openDoctorDrawer(model, deps),
+ 'open-refs': () => openRefsDrawer(model, deps),
+ 'open-treemap': () => openTreemapDrawer(model, deps),
+ 'toggle-treemap-scope': () => toggleTreemapScope(model, deps),
+ 'toggle-treemap-worktree': () => toggleTreemapWorktreeMode(model, deps),
+ 'treemap-drill-in': () => handleTreemapDrillIn(model, deps),
+ 'treemap-drill-out': () => handleTreemapDrillOut(model, deps),
+ 'overlay-close': () => closeOverlay(model),
+ };
+ return action.type in handlers ? handlers[action.type]() : null;
+}
+
+/**
+ * Handle non-overlay layout and navigation actions.
+ *
+ * @param {DashAction} action
+ * @param {DashModel} model
+ * @returns {[DashModel, DashCmd[]] | null}
+ */
+function handleLayoutAction(action, model) {
+ if (action.type === 'filter-start') {
+ return startFilter(model);
+ }
+ if (action.type === 'scroll-detail') {
+ const scroll = Math.max(0, model.detailScroll + action.delta);
+ return [{ ...model, detailScroll: scroll }, []];
+ }
+ if (action.type === 'split-focus') {
+ return [{ ...model, splitPane: splitPaneFocusNext(model.splitPane) }, []];
+ }
+ if (action.type === 'split-resize') {
+ return resizeSplitPane(model, action.delta);
+ }
+ return null;
+}
+
+/**
+ * Return true when explorer-only actions should be ignored in treemap view.
+ *
+ * @param {DashAction} action
+ * @returns {boolean}
+ */
+function isBlockedByTreemapView(action) {
+ return action.type === 'move'
+ || action.type === 'page'
+ || action.type === 'select'
+ || action.type === 'filter-start'
+ || action.type === 'scroll-detail'
+ || action.type === 'split-focus'
+ || action.type === 'split-resize';
+}
+
+/**
+ * Handle the primary keymap actions that do not require further routing.
+ *
+ * @param {DashAction} action
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @returns {[DashModel, DashCmd[]] | null}
+ */
+function handlePrimaryAction(action, model, deps) {
+ if (action.type === 'quit') {
+ return [model, [quit()]];
+ }
+ if (action.type === 'move') {
+ return handleMove(action, model);
+ }
+ if (action.type === 'page') {
+ return handlePage(action, model);
+ }
+ if (action.type === 'select') {
+ return handleSelect(model, deps);
+ }
+ return null;
+}
+
+/**
+ * Handle keymap actions.
+ *
+ * @param {DashAction} action
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handleAction(action, model, deps) {
+ const refsResult = handleRefsViewAction(action, model, deps);
+ if (refsResult) {
+ return refsResult;
+ }
+ const treemapResult = handleTreemapViewAction(action, model, deps);
+ if (treemapResult) {
+ return treemapResult;
+ }
+ if (model.activeDrawer === 'treemap' && isBlockedByTreemapView(action)) {
+ return [model, []];
+ }
+ const primaryResult = handlePrimaryAction(action, model, deps);
+ if (primaryResult) {
+ return primaryResult;
+ }
+ const overlayResult = handleOverlayAction(action, model, deps);
+ if (overlayResult) { return overlayResult; }
+ const layoutResult = handleLayoutAction(action, model);
+ if (layoutResult) { return layoutResult; }
+ return [model, []];
+}
+
+/**
+ * Handle successful report loads.
+ *
+ * @param {DashMsg} msg
+ * @param {DashModel} model
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handleLoadedReport(msg, model) {
+ if (msg.type === 'loaded-refs') {
+ return handleLoadedRefs(msg, model);
+ }
+ if (msg.type === 'loaded-stats') {
+ return handleLoadedStats(msg, model);
+ }
+ if (msg.type === 'loaded-doctor') {
+ return handleLoadedDoctor(msg, model);
+ }
+ if (msg.type === 'loaded-treemap') {
+ return handleLoadedTreemap(msg, model);
+ }
+ return [model, []];
+}
+
+/**
+ * Store a loaded refs inventory.
+ *
+ * @param {Extract} msg
+ * @param {DashModel} model
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handleLoadedRefs(msg, model) {
+ return [{
+ ...model,
+ refsStatus: 'ready',
+ refsItems: msg.refs.refs,
+ refsError: null,
+ refsTable: syncRefsTable(model.refsTable, {
+ refs: msg.refs.refs,
+ rows: model.rows,
+ focusRow: 0,
+ scrollY: 0,
+ }),
+ }, []];
+}
+
+/**
+ * Store a loaded stats report if it still matches the active source.
+ *
+ * @param {Extract} msg
+ * @param {DashModel} model
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handleLoadedStats(msg, model) {
+ if (!sourceEquals(msg.source, model.source)) {
+ return [model, []];
+ }
+ return [{
+ ...model,
+ statsStatus: 'ready',
+ statsReport: msg.stats,
+ statsError: null,
+ }, []];
+}
+
+/**
+ * Store a loaded doctor report if it still matches the active source.
+ *
+ * @param {Extract} msg
+ * @param {DashModel} model
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handleLoadedDoctor(msg, model) {
+ if (!sourceEquals(msg.source, model.source)) {
+ return [model, []];
+ }
+ return [{
+ ...model,
+ doctorStatus: 'ready',
+ doctorReport: msg.report,
+ doctorError: null,
+ }, []];
+}
+
+/**
+ * Store a loaded treemap report if it still matches the active view state.
+ *
+ * @param {Extract} msg
+ * @param {DashModel} model
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handleLoadedTreemap(msg, model) {
+ if (!treemapReportMatches(model, msg.report)) {
+ return [model, []];
+ }
+ return [{
+ ...model,
+ treemapStatus: 'ready',
+ treemapReport: msg.report,
+ treemapError: null,
+ treemapFocus: clampTreemapFocus(model.treemapFocus, msg.report.tiles ?? []),
+ }, []];
+}
+
+/**
+ * Handle load errors from async dashboard commands.
+ *
+ * @param {DashMsg & { type: 'load-error' }} msg
+ * @param {DashModel} model
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handleLoadError(msg, model) {
+ if (isStaleSourceLoad(msg, model)) {
+ return [model, []];
+ }
+ if (msg.source === 'treemap' && isStaleTreemapLoad(msg, model)) {
+ return [model, []];
+ }
+ return addToast(applyLoadErrorState(msg, model), {
+ level: 'error',
+ title: loadErrorTitle(msg),
+ message: msg.error,
+ });
+}
+
+/**
+ * Handle app-level messages (data loading results).
+ *
+ * @param {DashMsg} msg
+ * @param {DashModel} model
+ * @param {ContentAddressableStore} cas
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handleAppMsg(msg, model, cas) {
+ if (msg.type === 'loaded-entries') { return handleLoadedEntries(msg, model, cas); }
+ if (msg.type === 'loaded-manifest') { return handleLoadedManifest(msg, model); }
+ if (msg.type === 'toast-progress') {
+ return [updateToast(model, msg.id, (toast) => ({
+ ...toast,
+ progress: Math.max(0, Math.min(1, msg.progress)),
+ phase: msg.progress >= 1 && toast.phase === 'entering' ? 'steady' : toast.phase,
+ })), []];
+ }
+ if (msg.type === 'toast-expire') {
+ return startToastExit(model, msg.id);
+ }
+ if (msg.type === 'dismiss-toast') { return dismissToast(model, msg.id); }
+ if (msg.type === 'load-error') { return handleLoadError(msg, model); }
+ return handleLoadedReport(msg, model);
+}
+
+/**
+ * Normalize punctuation key runtime differences across terminals.
+ *
+ * Bijou's descriptor parser can match `shift+=`, but some live terminals emit
+ * the printable `+` and `_` characters directly instead of the unshifted key
+ * plus a modifier flag. Accept both representations for treemap drill keys.
+ *
+ * @param {KeyMsg} msg
+ * @returns {DashAction | undefined}
+ */
+function runtimeSymbolAction(msg) {
+ if (msg.ctrl || msg.alt) {
+ return undefined;
+ }
+ if (msg.key === '+' || (msg.key === '=' && msg.shift)) {
+ return { type: 'treemap-drill-in' };
+ }
+ if (msg.key === '-' || msg.key === '_') {
+ return { type: 'treemap-drill-out' };
+ }
+ return undefined;
+}
+
+/**
+ * Route all update messages to the appropriate handler.
+ *
+ * @param {KeyMsg | ResizeMsg | DashMsg} msg
+ * @param {DashModel} model
+ * @param {DashDeps} deps
+ * @returns {[DashModel, DashCmd[]]}
+ */
+function handleUpdate(msg, model, deps) {
+ if (msg.type === 'key' && model.palette) {
+ return handlePaletteKey(msg, model, deps);
+ }
+ if (msg.type === 'key' && model.filtering) {
+ return handleFilterKey(msg, model);
+ }
+ if (msg.type === 'key') {
+ const action = runtimeSymbolAction(msg) ?? deps.keyMap.handle(msg);
+ if (action) { return handleAction(action, model, deps); }
+ return [model, []];
+ }
+ if (msg.type === 'resize') {
+ const table = syncTable(model.table, {
+ entries: model.filtered,
+ manifestCache: model.manifestCache,
+ rows: msg.rows,
+ });
+ const refsTable = syncRefsTable(model.refsTable, {
+ refs: model.refsItems,
+ rows: msg.rows,
+ });
+ const palette = model.palette
+ ? {
+ ...model.palette,
+ height: paletteHeight(msg.rows),
+ }
+ : null;
+ return [{ ...model, columns: msg.columns, rows: msg.rows, table, refsTable, palette }, []];
+ }
+ return handleAppMsg(/** @type {DashMsg} */ (msg), model, deps.cas);
+}
+
+/**
+ * Return true when a treemap report matches the current view state.
+ *
+ * @param {{ treemapScope: TreemapScope, treemapWorktreeMode: TreemapWorktreeMode }} model
+ * @param {{ scope?: TreemapScope, worktreeMode?: TreemapWorktreeMode } | null | undefined} report
+ * @returns {boolean}
+ */
+function treemapReportMatches(model, report) {
+ if (!report || report.scope !== model.treemapScope || !sourceEquals(report.source, model.source) || !treemapPathEquals(report.drillPath ?? [], model.treemapPath)) {
+ return false;
+ }
+ if (report.scope !== 'repository') {
+ return true;
+ }
+ return report.worktreeMode === model.treemapWorktreeMode;
}
/**
@@ -273,7 +1989,7 @@ function handleUpdate(msg, model, deps) {
*/
export function createDashboardApp(deps) {
return {
- init: () => /** @type {[DashModel, DashCmd[]]} */ ([createInitModel(deps.ctx), [/** @type {DashCmd} */ (loadEntriesCmd(deps.cas))]]),
+ init: () => /** @type {[DashModel, DashCmd[]]} */ ([createInitModel(deps.ctx, deps.source), [/** @type {DashCmd} */ (loadEntriesCmd(deps.cas, deps.source))]]),
update: (/** @type {KeyMsg | ResizeMsg | DashMsg} */ msg, /** @type {DashModel} */ model) => handleUpdate(msg, model, deps),
view: (/** @type {DashModel} */ model) => renderDashboard(model, deps),
};
@@ -283,10 +1999,11 @@ export function createDashboardApp(deps) {
* Print static list for non-TTY environments.
*
* @param {ContentAddressableStore} cas Content-addressable store read by printStaticList.
+ * @param {DashSource} source Dashboard source used by printStaticList to choose entries.
* @param {Pick | NodeJS.WriteStream} [output=process.stdout] Output stream used by printStaticList to write each entry.
*/
-async function printStaticList(cas, output = process.stdout) {
- const entries = await cas.listVault();
+async function printStaticList(cas, source, output = process.stdout) {
+ const { entries } = await readSourceEntries(cas, source);
for (const { slug, treeOid } of entries) {
output.write(`${slug}\t${treeOid}\n`);
}
@@ -319,16 +2036,19 @@ function normalizeLaunchContext(ctx) {
* @param {{
* ctx?: BijouContext,
* runApp?: typeof run,
+ * cwd?: string,
+ * source?: DashSource,
* output?: Pick,
* }} [options]
*/
export async function launchDashboard(cas, options = {}) {
const ctx = options.ctx ? normalizeLaunchContext(options.ctx) : createCliTuiContext();
+ const source = options.source ?? { type: 'vault' };
if (ctx.mode !== 'interactive') {
- return printStaticList(cas, options.output);
+ return printStaticList(cas, source, options.output);
}
const keyMap = createKeyBindings();
- const deps = { keyMap, cas, ctx };
+ const deps = { keyMap, cas, ctx, cwdLabel: options.cwd, source };
const runApp = options.runApp || run;
return runApp(createDashboardApp(deps), { ctx });
}
diff --git a/bin/ui/encryption-card.js b/bin/ui/encryption-card.js
index 1143617..5f01808 100644
--- a/bin/ui/encryption-card.js
+++ b/bin/ui/encryption-card.js
@@ -2,8 +2,9 @@
* Encryption info card — visual summary of vault crypto configuration.
*/
-import { box, badge, headerBox } from '@flyingrobots/bijou';
+import { box } from '@flyingrobots/bijou';
import { getCliContext } from './context.js';
+import { chipText, sectionHeading, themeText } from './theme.js';
/**
* Render an encryption info card for the vault.
@@ -24,8 +25,8 @@ export function renderEncryptionCard({ metadata, unlocked = false }) {
const { kdf } = encryption;
const status = unlocked
- ? badge('unlocked', { variant: 'success', ctx })
- : badge('locked', { variant: 'error', ctx });
+ ? chipText(ctx, 'unlocked', 'success')
+ : chipText(ctx, 'locked', 'danger');
const rows = [
` cipher ${encryption.cipher}`,
@@ -45,5 +46,10 @@ export function renderEncryptionCard({ metadata, unlocked = false }) {
rows.push(` status ${status}`);
const content = rows.join('\n');
- return `${headerBox('Encryption', { ctx })}\n${box(content, { ctx })}`;
+ return [
+ themeText(ctx, 'Vault Envelope', { tone: 'brand' }),
+ themeText(ctx, 'Cipher, KDF shape, and unlock posture.', { tone: 'subdued' }),
+ sectionHeading(ctx, 'Encryption Profile', 'warning'),
+ box(content, { ctx }),
+ ].join('\n');
}
diff --git a/bin/ui/manifest-view.js b/bin/ui/manifest-view.js
index 1be14aa..6c74fad 100644
--- a/bin/ui/manifest-view.js
+++ b/bin/ui/manifest-view.js
@@ -2,8 +2,9 @@
* Manifest anatomy view — rich visual breakdown of a manifest.
*/
-import { box, badge, table, tree, headerBox } from '@flyingrobots/bijou';
+import { box, table, tree } from '@flyingrobots/bijou';
import { getCliContext } from './context.js';
+import { chipText, sectionHeading, themeText } from './theme.js';
/**
* @typedef {import('../../src/domain/value-objects/Manifest.js').ManifestData} ManifestData
@@ -37,15 +38,19 @@ function formatBytes(bytes) {
* @returns {string}
*/
function renderBadges(m, ctx) {
- const badges = [badge(`v${m.version}`, { ctx })];
+ const renderBadge = (label, tone = 'neutral') => chipText(ctx, label, tone);
+ const badges = [];
+ if (Number.isFinite(m.version)) {
+ badges.push(renderBadge(`v${m.version}`, 'brand'));
+ }
if (m.encryption) {
- badges.push(badge('encrypted', { variant: 'warning', ctx }));
+ badges.push(renderBadge('encrypted', 'warning'));
}
if (m.compression) {
- badges.push(badge(m.compression.algorithm, { variant: 'info', ctx }));
+ badges.push(renderBadge(m.compression.algorithm, 'info'));
}
if (m.subManifests?.length) {
- badges.push(badge('merkle', { variant: 'info', ctx }));
+ badges.push(renderBadge('merkle', 'accent'));
}
return badges.join(' ');
}
@@ -74,7 +79,7 @@ function renderEncryptionSection(enc, ctx) {
if (enc.tag) {
rows.push(` tag ${enc.tag.slice(0, 16)}...`);
}
- return `${headerBox('Encryption', { ctx })}\n${box(rows.join('\n'), { ctx })}`;
+ return `${sectionHeading(ctx, 'Encryption Profile', 'warning')}\n${box(rows.join('\n'), { ctx })}`;
}
/**
@@ -100,7 +105,7 @@ function renderChunksSection(chunks, ctx) {
const suffix = chunks.length > 20
? `\n ...and ${chunks.length - 20} more`
: '';
- return `${headerBox(`Chunks (${chunks.length})`, { ctx })}\n${chunkTable}${suffix}`;
+ return `${sectionHeading(ctx, `Chunk Ledger (${chunks.length})`, 'info')}\n${chunkTable}${suffix}`;
}
/**
@@ -112,12 +117,12 @@ function renderChunksSection(chunks, ctx) {
*/
function renderMetadataSection(m, ctx) {
const meta = [
- ` slug ${m.slug}`,
- ` filename ${m.filename}`,
+ ` slug ${m.slug ?? '-'}`,
+ ` filename ${m.filename ?? '-'}`,
` size ${formatBytes(m.size)}`,
` chunks ${m.chunks?.length ?? 0}`,
];
- return `${headerBox('Metadata', { ctx })}\n${box(meta.join('\n'), { ctx })}`;
+ return `${sectionHeading(ctx, 'Asset Metadata', 'brand')}\n${box(meta.join('\n'), { ctx })}`;
}
/**
@@ -132,7 +137,7 @@ function renderSubManifestsSection(m, ctx) {
const nodes = subs.map((/** @type {import('../../src/domain/value-objects/Manifest.js').SubManifestRef} */ sm, /** @type {number} */ i) => ({
label: `sub-${i} ${sm.chunkCount} chunks start: ${sm.startIndex} oid: ${sm.oid.slice(0, 8)}...`,
}));
- return `${headerBox(`Sub-manifests (${subs.length})`, { ctx })}\n${tree(nodes, { ctx })}`;
+ return `${sectionHeading(ctx, `Merkle Branches (${subs.length})`, 'accent')}\n${tree(nodes, { ctx })}`;
}
/**
@@ -145,13 +150,18 @@ function renderSubManifestsSection(m, ctx) {
*/
export function renderManifestView({ manifest, ctx = getCliContext() }) {
const m = /** @type {ManifestData} */ ('toJSON' in manifest ? manifest.toJSON() : manifest);
- const sections = [renderBadges(m, ctx), renderMetadataSection(m, ctx)];
+ const badges = renderBadges(m, ctx);
+ const sections = [themeText(ctx, 'Manifest Ledger', { tone: 'brand' })];
+ if (badges.length > 0) {
+ sections.push(badges);
+ }
+ sections.push(renderMetadataSection(m, ctx));
if (m.encryption) {
sections.push(renderEncryptionSection(m.encryption, ctx));
}
if (m.compression) {
- sections.push(`${headerBox('Compression', { ctx })}\n${box(` algorithm ${m.compression.algorithm}`, { ctx })}`);
+ sections.push(`${sectionHeading(ctx, 'Compression Profile', 'info')}\n${box(` algorithm ${m.compression.algorithm}`, { ctx })}`);
}
if (m.subManifests?.length) {
sections.push(renderSubManifestsSection(m, ctx));
diff --git a/bin/ui/repo-treemap.js b/bin/ui/repo-treemap.js
new file mode 100644
index 0000000..e7d4070
--- /dev/null
+++ b/bin/ui/repo-treemap.js
@@ -0,0 +1,620 @@
+/**
+ * Render a semantic repository treemap for the dashboard.
+ */
+
+/**
+ * @typedef {import('./dashboard-cmds.js').RepoTreemapReport} RepoTreemapReport
+ * @typedef {import('./dashboard-cmds.js').RepoTreemapTile} RepoTreemapTile
+ * @typedef {import('./dashboard.js').DashSource} DashSource
+ * @typedef {import('@flyingrobots/bijou').BijouContext} BijouContext
+ */
+
+import { GIT_CAS_PALETTE } from './theme.js';
+
+const TILE_COLOR = {
+ worktree: GIT_CAS_PALETTE.teal,
+ git: GIT_CAS_PALETTE.copper,
+ ref: GIT_CAS_PALETTE.orchid,
+ vault: GIT_CAS_PALETTE.lime,
+ cas: GIT_CAS_PALETTE.sky,
+ meta: GIT_CAS_PALETTE.slate,
+};
+
+const TILE_FILL = {
+ worktree: '█',
+ git: '▓',
+ ref: '▒',
+ vault: '■',
+ cas: '▦',
+ meta: '░',
+};
+
+const TILE_LABEL = {
+ worktree: 'worktree',
+ git: 'git',
+ ref: 'refs',
+ vault: 'vault',
+ cas: 'source',
+ meta: 'other',
+};
+
+/**
+ * Format bytes as a compact human-readable string.
+ *
+ * @param {number} bytes
+ * @returns {string}
+ */
+function formatBytes(bytes) {
+ if (bytes < 1024) { return `${bytes}B`; }
+ if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)}K`; }
+ if (bytes < 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(1)}M`; }
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
+}
+
+/**
+ * Build a human-readable label for the active dashboard source.
+ *
+ * @param {DashSource} source
+ * @returns {string}
+ */
+function sourceLabel(source) {
+ if (source.type === 'vault') {
+ return 'vault';
+ }
+ if (source.type === 'ref') {
+ return `ref ${source.ref}`;
+ }
+ return `oid ${source.treeOid}`;
+}
+
+/**
+ * Clip long labels to fit a rectangle.
+ *
+ * @param {string} text
+ * @param {number} width
+ * @returns {string}
+ */
+function clip(text, width) {
+ if (width <= 0) {
+ return '';
+ }
+ if (text.length <= width) {
+ return text;
+ }
+ if (width <= 1) {
+ return text.slice(0, width);
+ }
+ return `${text.slice(0, width - 1)}…`;
+}
+
+/**
+ * Return the active breadcrumb label for the current map level.
+ *
+ * @param {RepoTreemapReport} report
+ * @returns {string}
+ */
+function currentLevelLabel(report) {
+ return report.breadcrumb.join(' > ');
+}
+
+/**
+ * Clip paths from the left so the suffix stays visible.
+ *
+ * @param {string} text
+ * @param {number} width
+ * @returns {string}
+ */
+function tailClip(text, width) {
+ if (width <= 0) {
+ return '';
+ }
+ if (text.length <= width) {
+ return text;
+ }
+ if (width <= 3) {
+ return clip(text, width);
+ }
+ return `...${text.slice(text.length - (width - 3))}`;
+}
+
+/**
+ * Find the next wrap position inside one line of plain text.
+ *
+ * @param {string} line
+ * @param {number} width
+ * @returns {number}
+ */
+function findWrapIndex(line, width) {
+ const splitAt = Math.min(width, line.length);
+ const boundaryChar = line[splitAt];
+ if (boundaryChar && /\s/u.test(boundaryChar)) {
+ return splitAt;
+ }
+ let backtrack = splitAt;
+ while (backtrack > 0 && !/\s/u.test(line[backtrack - 1])) {
+ backtrack--;
+ }
+ return backtrack > 0 ? backtrack : splitAt;
+}
+
+/**
+ * Wrap one plain-text line to the requested width.
+ *
+ * Prefer breaking on the last whitespace boundary that fits inside the
+ * available width. When a token is longer than the whole line budget, fall
+ * back to a hard break so rendering always makes forward progress.
+ *
+ * @param {string} line
+ * @param {number} width
+ * @returns {string[]}
+ */
+function wrapLine(line, width) {
+ if (line.length === 0) {
+ return [''];
+ }
+
+ const wrapped = [];
+ let remaining = line;
+
+ while (remaining.length > width) {
+ const splitAt = Math.min(width, remaining.length);
+ const wrapIndex = findWrapIndex(remaining, width);
+ const chunk = remaining.slice(0, wrapIndex).replace(/\s+$/u, '');
+ wrapped.push(chunk || remaining.slice(0, splitAt));
+
+ let nextStart = wrapIndex;
+ while (nextStart < remaining.length && /\s/u.test(remaining[nextStart])) {
+ nextStart++;
+ }
+ remaining = remaining.slice(nextStart);
+ }
+
+ if (remaining.length > 0 || wrapped.length === 0) {
+ wrapped.push(remaining);
+ }
+
+ return wrapped;
+}
+
+/**
+ * Wrap plain text into fixed-width chunks.
+ *
+ * @param {string} text
+ * @param {number} width
+ * @returns {string[]}
+ */
+function wrapText(text, width) {
+ if (width <= 0) {
+ return [''];
+ }
+ return text
+ .split('\n')
+ .flatMap((line) => wrapLine(line, width));
+}
+
+/**
+ * Clamp wrapped lines to a display budget.
+ *
+ * @param {string[]} lines
+ * @param {number} width
+ * @param {number} maxLines
+ * @returns {string[]}
+ */
+function limitLines(lines, width, maxLines) {
+ if (lines.length <= maxLines) {
+ return lines;
+ }
+ const capped = lines.slice(0, maxLines);
+ capped[maxLines - 1] = `${clip(capped[maxLines - 1], Math.max(1, width - 1))}…`;
+ return capped;
+}
+
+/**
+ * Format a percentage for a tile relative to the whole report.
+ *
+ * @param {number} value
+ * @param {number} total
+ * @returns {string}
+ */
+function formatPercent(value, total) {
+ if (total <= 0) {
+ return '0.0%';
+ }
+ return `${((value / total) * 100).toFixed(1)}%`;
+}
+
+/**
+ * Sort treemap tiles by value so the layout and detail list both reflect the
+ * most significant regions first.
+ *
+ * @param {RepoTreemapTile[]} tiles
+ * @returns {RepoTreemapTile[]}
+ */
+function sortTilesByValue(tiles) {
+ return [...tiles].sort((left, right) => right.value - left.value || left.label.localeCompare(right.label));
+}
+
+/**
+ * Group tiles into a binary split that approximates a treemap.
+ *
+ * @param {RepoTreemapTile[]} tiles
+ * @returns {[RepoTreemapTile[], RepoTreemapTile[]]}
+ */
+function splitTiles(tiles) {
+ if (tiles.length <= 1) {
+ return [tiles, []];
+ }
+
+ const total = tiles.reduce((sum, tile) => sum + tile.value, 0);
+ const target = total / 2;
+ let bestIndex = 1;
+ let bestDelta = Infinity;
+ let running = 0;
+
+ for (let index = 1; index < tiles.length; index++) {
+ running += tiles[index - 1].value;
+ const delta = Math.abs(target - running);
+ if (delta < bestDelta) {
+ bestDelta = delta;
+ bestIndex = index;
+ }
+ }
+
+ return [tiles.slice(0, bestIndex), tiles.slice(bestIndex)];
+}
+
+/**
+ * Recursively layout treemap rectangles.
+ *
+ * @param {RepoTreemapTile[]} tiles
+ * @param {{ x: number, y: number, width: number, height: number }} rect
+ * @param {boolean} vertical
+ * @returns {Array<{ tile: RepoTreemapTile, x: number, y: number, width: number, height: number }>}
+ */
+function layoutTreemap(tiles, rect, vertical = rect.width >= rect.height) {
+ if (tiles.length === 0 || rect.width <= 0 || rect.height <= 0) {
+ return [];
+ }
+ if (tiles.length === 1) {
+ return [{ tile: tiles[0], ...rect }];
+ }
+
+ const [groupA, groupB] = splitTiles(tiles);
+ if (groupB.length === 0) {
+ return [{ tile: groupA[0], ...rect }];
+ }
+
+ const total = tiles.reduce((sum, tile) => sum + tile.value, 0);
+ const weightA = groupA.reduce((sum, tile) => sum + tile.value, 0);
+ const ratio = total > 0 ? weightA / total : 0.5;
+
+ if (vertical) {
+ const widthA = Math.max(1, Math.min(rect.width - 1, Math.round(rect.width * ratio)));
+ return [
+ ...layoutTreemap(groupA, { x: rect.x, y: rect.y, width: widthA, height: rect.height }, !vertical),
+ ...layoutTreemap(groupB, { x: rect.x + widthA, y: rect.y, width: rect.width - widthA, height: rect.height }, !vertical),
+ ];
+ }
+
+ const heightA = Math.max(1, Math.min(rect.height - 1, Math.round(rect.height * ratio)));
+ return [
+ ...layoutTreemap(groupA, { x: rect.x, y: rect.y, width: rect.width, height: heightA }, !vertical),
+ ...layoutTreemap(groupB, { x: rect.x, y: rect.y + heightA, width: rect.width, height: rect.height - heightA }, !vertical),
+ ];
+}
+
+/**
+ * Create an empty cell grid.
+ *
+ * @param {number} width
+ * @param {number} height
+ * @returns {Array>}
+ */
+function createGrid(width, height) {
+ return Array.from({ length: height }, () => Array.from({ length: width }, () => ({ ch: ' ', kind: null })));
+}
+
+/**
+ * Write one cell when it falls inside the current grid.
+ *
+ * @param {ReturnType} grid
+ * @param {{ row: number, col: number, ch: string, kind: RepoTreemapTile['kind'], label?: boolean }} cell
+ */
+function putCell(grid, cell) {
+ if (grid[cell.row]?.[cell.col]) {
+ grid[cell.row][cell.col] = { ch: cell.ch, kind: cell.kind, label: cell.label ?? false };
+ }
+}
+
+const LABEL_FOREGROUND = [255, 255, 255];
+
+/**
+ * Border glyphs for normal or focused treemap outlines.
+ *
+ * @param {boolean} focused
+ * @returns {{ h: string, v: string, tl: string, tr: string, bl: string, br: string }}
+ */
+function outlineGlyphs(focused) {
+ if (focused) {
+ return { h: '═', v: '║', tl: '╔', tr: '╗', bl: '╚', br: '╝' };
+ }
+ return { h: '─', v: '│', tl: '┌', tr: '┐', bl: '└', br: '┘' };
+}
+
+/**
+ * Paint a visible outline around a tile rectangle.
+ *
+ * Using box-drawing characters keeps same-kind regions readable in the map,
+ * which matters most for repository scope where multiple worktree tiles can
+ * otherwise blend into one solid field.
+ *
+ * @param {ReturnType} grid
+ * @param {{ tile: RepoTreemapTile, x: number, y: number, width: number, height: number }} rect
+ * @param {boolean} focused
+ */
+function outlineRect(grid, rect, focused = false) {
+ if (rect.width < 2 || rect.height < 2) {
+ return;
+ }
+ const { h, v, tl, tr, bl, br } = outlineGlyphs(focused);
+ const top = rect.y;
+ const bottom = rect.y + rect.height - 1;
+ const left = rect.x;
+ const right = rect.x + rect.width - 1;
+
+ for (let col = left + 1; col < right; col++) {
+ putCell(grid, { row: top, col, ch: h, kind: rect.tile.kind });
+ putCell(grid, { row: bottom, col, ch: h, kind: rect.tile.kind });
+ }
+ for (let row = top + 1; row < bottom; row++) {
+ putCell(grid, { row, col: left, ch: v, kind: rect.tile.kind });
+ putCell(grid, { row, col: right, ch: v, kind: rect.tile.kind });
+ }
+ putCell(grid, { row: top, col: left, ch: tl, kind: rect.tile.kind });
+ putCell(grid, { row: top, col: right, ch: tr, kind: rect.tile.kind });
+ putCell(grid, { row: bottom, col: left, ch: bl, kind: rect.tile.kind });
+ putCell(grid, { row: bottom, col: right, ch: br, kind: rect.tile.kind });
+}
+
+/**
+ * Overlay a centered tile label on a painted rectangle.
+ *
+ * @param {ReturnType} grid
+ * @param {{ tile: RepoTreemapTile, x: number, y: number, width: number, height: number }} rect
+ */
+function paintLabel(grid, rect) {
+ if (rect.width < 6 || rect.height < 2) {
+ return;
+ }
+ const label = clip(rect.tile.label, rect.width - 2);
+ const labelRow = rect.y + Math.floor((rect.height - 1) / 2);
+ const startCol = rect.x + Math.max(0, Math.floor((rect.width - label.length) / 2));
+ for (let index = 0; index < label.length; index++) {
+ const cell = grid[labelRow]?.[startCol + index];
+ if (cell) {
+ grid[labelRow][startCol + index] = { ch: label[index], kind: rect.tile.kind, label: true };
+ }
+ }
+}
+
+/**
+ * Paint a rectangle into the cell grid.
+ *
+ * @param {ReturnType} grid
+ * @param {{ tile: RepoTreemapTile, x: number, y: number, width: number, height: number }} rect
+ * @param {string | null} selectedTileId
+ */
+function paintRect(grid, rect, selectedTileId) {
+ const fill = TILE_FILL[rect.tile.kind] ?? TILE_FILL.meta;
+
+ for (let row = rect.y; row < rect.y + rect.height; row++) {
+ for (let col = rect.x; col < rect.x + rect.width; col++) {
+ if (grid[row]?.[col]) {
+ grid[row][col] = { ch: fill, kind: rect.tile.kind };
+ }
+ }
+ }
+ outlineRect(grid, rect, rect.tile.id === selectedTileId);
+ paintLabel(grid, rect);
+}
+
+/**
+ * Convert the cell grid into display lines.
+ *
+ * @param {ReturnType} grid
+ * @param {BijouContext} ctx
+ * @returns {string[]}
+ */
+function renderGrid(grid, ctx) {
+ return grid.map((row) => row.map((cell) => {
+ if (!cell.kind) {
+ return cell.ch;
+ }
+ const color = TILE_COLOR[cell.kind] ?? TILE_COLOR.meta;
+ if (cell.label) {
+ return ctx.style.bold(
+ ctx.style.rgb(LABEL_FOREGROUND[0], LABEL_FOREGROUND[1], LABEL_FOREGROUND[2], cell.ch),
+ );
+ }
+ return ctx.style.rgb(color[0], color[1], color[2], cell.ch);
+ }).join(''));
+}
+
+/**
+ * Render the legend line for the treemap kinds.
+ *
+ * @param {BijouContext} ctx
+ * @param {number} width
+ * @returns {string}
+ */
+function renderLegendLines(ctx, width) {
+ return /** @type {Array} */ (['worktree', 'git', 'ref', 'vault', 'cas', 'meta'])
+ .map((kind) => {
+ const fill = TILE_FILL[kind];
+ const color = TILE_COLOR[kind];
+ return clip(ctx.style.rgb(color[0], color[1], color[2], `${fill} ${TILE_LABEL[kind]}`), width);
+ });
+}
+
+/**
+ * Render the most important tile details.
+ *
+ * @param {RepoTreemapTile[]} tiles
+ * @param {{ totalValue: number, width: number, lines: number }} options
+ * @returns {string[]}
+ */
+function renderDetails(tiles, options) {
+ return sortTilesByValue(tiles)
+ .slice(0, Math.max(0, options.lines))
+ .map((tile, index) => clip(
+ `${index + 1}. ${tile.label} [${TILE_LABEL[tile.kind]}] ${formatPercent(tile.value, options.totalValue)} · ${tile.detail}`,
+ options.width,
+ ));
+}
+
+/**
+ * Render repo/source overview lines for the sidebar.
+ *
+ * @param {RepoTreemapReport} report
+ * @param {number} width
+ * @returns {string[]}
+ */
+function renderOverview(report, width) {
+ if (report.scope === 'source') {
+ return [
+ clip(`scope ${report.scope}`, width),
+ clip(`source ${sourceLabel(report.source)}`, width),
+ clip(`level ${currentLevelLabel(report)}`, width),
+ clip(`root ${tailClip(report.cwd, Math.max(1, width - 5))}`, width),
+ clip(`total ${formatBytes(report.totalValue)}`, width),
+ clip('logical source weighting', width),
+ clip(`current regions ${report.tiles.length}`, width),
+ clip(`source entries ${report.summary.sourceEntries}`, width),
+ clip(`vault entries ${report.summary.vaultEntries}`, width),
+ ];
+ }
+
+ return [
+ clip(`scope ${report.scope}`, width),
+ clip(`source ${sourceLabel(report.source)}`, width),
+ clip(`level ${currentLevelLabel(report)}`, width),
+ clip(`root ${tailClip(report.cwd, Math.max(1, width - 5))}`, width),
+ clip(`total ${formatBytes(report.totalValue)}`, width),
+ clip(`${report.worktreeMode} paths ${report.summary.worktreePaths}`, width),
+ clip(`current regions ${report.tiles.length}`, width),
+ clip(`worktree regions ${report.summary.worktreeItems}`, width),
+ clip(`refs ${report.summary.refCount} in ${report.summary.refNamespaces} namespaces`, width),
+ clip(`vault ${report.summary.vaultEntries} source ${report.summary.sourceEntries}`, width),
+ ];
+}
+
+/**
+ * Find the selected tile in the current report.
+ *
+ * @param {RepoTreemapReport} report
+ * @param {string | null | undefined} selectedTileId
+ * @returns {RepoTreemapTile | null}
+ */
+function selectedTile(report, selectedTileId) {
+ if (!selectedTileId) {
+ return report.tiles[0] ?? null;
+ }
+ return report.tiles.find((tile) => tile.id === selectedTileId) ?? report.tiles[0] ?? null;
+}
+
+/**
+ * Render the focused tile details for the sidebar.
+ *
+ * @param {RepoTreemapReport} report
+ * @param {string | null | undefined} selectedTileId
+ * @param {number} width
+ * @returns {string[]}
+ */
+function renderFocusedTile(report, selectedTileId, width) {
+ const tile = selectedTile(report, selectedTileId);
+ if (!tile) {
+ return ['No region selected.'];
+ }
+ return [
+ clip(tile.label, width),
+ clip(`${TILE_LABEL[tile.kind]} · ${formatPercent(tile.value, report.totalValue)}`, width),
+ clip(tile.detail, width),
+ clip(tile.drillable ? 'Press + to descend.' : 'Leaf tile.', width),
+ ];
+}
+
+/**
+ * Render wrapped note lines for the sidebar.
+ *
+ * @param {RepoTreemapReport} report
+ * @param {number} width
+ * @param {number} lines
+ * @returns {string[]}
+ */
+function renderNotes(report, width, lines) {
+ return limitLines(report.notes.flatMap((note) => wrapText(note, width)), width, lines);
+}
+
+/**
+ * Render only the treemap grid.
+ *
+ * @param {RepoTreemapReport} report
+ * @param {{ ctx: BijouContext, width: number, height: number, selectedTileId?: string | null }} options
+ * @returns {string}
+ */
+export function renderRepoTreemapMap(report, options) {
+ const width = Math.max(12, options.width);
+ const height = Math.max(4, options.height);
+ const grid = createGrid(width, height);
+ const layout = layoutTreemap(sortTilesByValue(report.tiles), { x: 0, y: 0, width, height });
+ for (const rect of layout) {
+ paintRect(grid, rect, options.selectedTileId ?? null);
+ }
+ return renderGrid(grid, options.ctx).join('\n');
+}
+
+/**
+ * Build text sections for the treemap sidebar.
+ *
+ * @param {RepoTreemapReport} report
+ * @param {{ ctx: BijouContext, width: number, height: number, selectedTileId?: string | null }} options
+ * @returns {{ overview: string, legend: string, focused: string, regions: string, notes: string }}
+ */
+export function renderRepoTreemapSidebar(report, options) {
+ const width = Math.max(16, options.width);
+ return {
+ overview: renderOverview(report, width).join('\n'),
+ legend: renderLegendLines(options.ctx, width).join('\n'),
+ focused: renderFocusedTile(report, options.selectedTileId, width).join('\n'),
+ regions: renderDetails(report.tiles, {
+ totalValue: report.totalValue,
+ width,
+ lines: Math.max(3, Math.min(10, options.height - 20)),
+ }).join('\n'),
+ notes: renderNotes(report, width, Math.max(2, Math.min(8, options.height - 24))).join('\n'),
+ };
+}
+
+/**
+ * Render a repository treemap as ANSI-aware text.
+ *
+ * @param {RepoTreemapReport} report
+ * @param {{ ctx: BijouContext, width: number, height: number, selectedTileId?: string | null }} options
+ * @returns {string}
+ */
+export function renderRepoTreemap(report, options) {
+ const width = Math.max(24, options.width);
+ const height = Math.max(10, options.height);
+ const summaryLines = renderOverview(report, width);
+ const legendLines = renderLegendLines(options.ctx, width);
+ const detailRows = Math.min(4, Math.max(1, height - 10));
+ const noteLines = renderNotes(report, width, Math.max(1, height - summaryLines.length - legendLines.length - detailRows - 3));
+ const gridHeight = Math.max(4, height - summaryLines.length - legendLines.length - detailRows - noteLines.length - 3);
+ return [
+ ...summaryLines,
+ ...renderRepoTreemapMap(report, { ...options, width, height: gridHeight }).split('\n'),
+ ...legendLines,
+ ...renderDetails(report.tiles, { totalValue: report.totalValue, width, lines: detailRows }),
+ ...noteLines,
+ ].slice(0, height).join('\n');
+}
diff --git a/bin/ui/theme.js b/bin/ui/theme.js
new file mode 100644
index 0000000..8ca0155
--- /dev/null
+++ b/bin/ui/theme.js
@@ -0,0 +1,168 @@
+/**
+ * Shared visual language for git-cas terminal surfaces.
+ *
+ * The goal is not to paint everything. It is to give the shell a recognizable
+ * voice with a small, consistent set of semantic color roles.
+ */
+
+import { parseAnsiToSurface } from '@flyingrobots/bijou';
+
+export const GIT_CAS_PALETTE = {
+ ivory: [246, 239, 221],
+ sand: [224, 212, 186],
+ brass: [247, 196, 90],
+ copper: [224, 123, 57],
+ ember: [109, 48, 20],
+ teal: [50, 205, 194],
+ deepTeal: [18, 96, 96],
+ orchid: [235, 92, 172],
+ plum: [104, 38, 84],
+ lime: [182, 224, 78],
+ moss: [52, 110, 57],
+ sky: [123, 170, 247],
+ indigo: [40, 74, 126],
+ ruby: [230, 89, 111],
+ wine: [117, 29, 45],
+ slate: [148, 163, 184],
+ smoke: [92, 104, 125],
+ ink: [12, 16, 24],
+};
+
+const TEXT_TONES = {
+ brand: { fg: GIT_CAS_PALETTE.brass, bold: true },
+ accent: { fg: GIT_CAS_PALETTE.teal, bold: true },
+ primary: { fg: GIT_CAS_PALETTE.ivory },
+ secondary: { fg: GIT_CAS_PALETTE.sand },
+ subdued: { fg: GIT_CAS_PALETTE.slate },
+ info: { fg: GIT_CAS_PALETTE.sky, bold: true },
+ success: { fg: GIT_CAS_PALETTE.lime, bold: true },
+ warning: { fg: GIT_CAS_PALETTE.brass, bold: true },
+ danger: { fg: GIT_CAS_PALETTE.ruby, bold: true },
+};
+
+const CHIP_TONES = {
+ brand: { fg: GIT_CAS_PALETTE.ivory, bg: GIT_CAS_PALETTE.ember, bold: true },
+ info: { fg: GIT_CAS_PALETTE.ivory, bg: GIT_CAS_PALETTE.deepTeal, bold: true },
+ accent: { fg: GIT_CAS_PALETTE.ivory, bg: GIT_CAS_PALETTE.plum, bold: true },
+ warning: { fg: GIT_CAS_PALETTE.ivory, bg: [148, 82, 23], bold: true },
+ success: { fg: GIT_CAS_PALETTE.ivory, bg: GIT_CAS_PALETTE.moss, bold: true },
+ danger: { fg: GIT_CAS_PALETTE.ivory, bg: GIT_CAS_PALETTE.wine, bold: true },
+ neutral: { fg: GIT_CAS_PALETTE.ivory, bg: [51, 65, 85], bold: true },
+};
+
+/**
+ * Apply semantic git-cas styling to one text fragment.
+ *
+ * @param {import('@flyingrobots/bijou').BijouContext} ctx
+ * @param {string} text
+ * @param {{
+ * tone?: keyof typeof TEXT_TONES,
+ * fg?: [number, number, number],
+ * bg?: [number, number, number],
+ * bold?: boolean,
+ * }} [options]
+ * @returns {string}
+ */
+export function themeText(ctx, text, options = {}) {
+ return applyThemeSpec(ctx, text, resolveSpec(options));
+}
+
+/**
+ * Create a one-line surface for inline shell chrome.
+ *
+ * @param {import('@flyingrobots/bijou').BijouContext} ctx
+ * @param {string} text
+ * @param {Parameters[2]} [options]
+ * @returns {import('@flyingrobots/bijou').Surface}
+ */
+export function inlineSurface(ctx, text, options = {}) {
+ return parseAnsiToSurface(themeText(ctx, text, options), Math.max(1, text.length), 1);
+}
+
+/**
+ * Create a compact filled chip surface.
+ *
+ * @param {import('@flyingrobots/bijou').BijouContext} ctx
+ * @param {string} label
+ * @param {keyof typeof CHIP_TONES} [tone]
+ * @returns {import('@flyingrobots/bijou').Surface}
+ */
+export function chipSurface(ctx, label, tone = 'neutral') {
+ const text = ` ${label} `;
+ const spec = CHIP_TONES[tone] ?? CHIP_TONES.neutral;
+ return inlineSurface(ctx, text, spec);
+}
+
+/**
+ * Create a compact filled chip as ANSI text for string-based renderers.
+ *
+ * @param {import('@flyingrobots/bijou').BijouContext} ctx
+ * @param {string} label
+ * @param {keyof typeof CHIP_TONES} [tone]
+ * @returns {string}
+ */
+export function chipText(ctx, label, tone = 'neutral') {
+ const text = ` ${label} `;
+ const spec = CHIP_TONES[tone] ?? CHIP_TONES.neutral;
+ return themeText(ctx, text, spec);
+}
+
+/**
+ * Render a section-eyebrow line used inside panels and drawers.
+ *
+ * @param {import('@flyingrobots/bijou').BijouContext} ctx
+ * @param {string} label
+ * @param {keyof typeof TEXT_TONES} [tone]
+ * @returns {string}
+ */
+export function sectionHeading(ctx, label, tone = 'brand') {
+ return themeText(ctx, `◆ ${label}`, { tone, bold: true });
+}
+
+/**
+ * Render a subdued shell rule.
+ *
+ * @param {import('@flyingrobots/bijou').BijouContext} ctx
+ * @param {number} width
+ * @returns {string}
+ */
+export function shellRule(ctx, width) {
+ return themeText(ctx, '─'.repeat(Math.max(1, width)), { tone: 'subdued' });
+}
+
+/**
+ * Resolve a semantic text spec from a tone and optional overrides.
+ *
+ * @param {Parameters[2]} [options]
+ * @returns {{ fg?: [number, number, number], bg?: [number, number, number], bold: boolean }}
+ */
+function resolveSpec(options = {}) {
+ const tone = options.tone ? TEXT_TONES[options.tone] : null;
+ return {
+ fg: options.fg ?? tone?.fg,
+ bg: options.bg ?? tone?.bg,
+ bold: options.bold ?? tone?.bold ?? false,
+ };
+}
+
+/**
+ * Apply resolved foreground/background/bold styling.
+ *
+ * @param {import('@flyingrobots/bijou').BijouContext} ctx
+ * @param {string} text
+ * @param {{ fg?: [number, number, number], bg?: [number, number, number], bold: boolean }} spec
+ * @returns {string}
+ */
+function applyThemeSpec(ctx, text, spec) {
+ let styled = text;
+ if (spec.fg) {
+ styled = ctx.style.rgb(spec.fg[0], spec.fg[1], spec.fg[2], styled);
+ }
+ if (spec.bg) {
+ styled = ctx.style.bgRgb(spec.bg[0], spec.bg[1], spec.bg[2], styled);
+ }
+ if (spec.bold) {
+ styled = ctx.style.bold(styled);
+ }
+ return styled;
+}
diff --git a/bin/ui/vault-report.js b/bin/ui/vault-report.js
index 4491cf3..1971e47 100644
--- a/bin/ui/vault-report.js
+++ b/bin/ui/vault-report.js
@@ -72,6 +72,17 @@ function formatBytes(bytes) {
return `${value.toFixed(1)} ${units[unitIndex]}`;
}
+/**
+ * Render aligned key/value pairs without tabs so TUI panels stay stable.
+ *
+ * @param {Array<[string, string | number]>} pairs
+ * @returns {string[]}
+ */
+function renderKeyValueLines(pairs) {
+ const labelWidth = pairs.reduce((max, [label]) => Math.max(max, label.length), 0);
+ return pairs.map(([label, value]) => `${label.padEnd(labelWidth)} ${value}`);
+}
+
/**
* Create an empty stats payload.
*
@@ -226,17 +237,19 @@ export function renderVaultStats(stats) {
: '-';
return [
- `entries\t${stats.entries}`,
- `logical-size\t${formatBytes(stats.totalLogicalSize)} (${stats.totalLogicalSize} bytes)`,
- `chunk-refs\t${stats.totalChunkRefs}`,
- `unique-chunks\t${stats.uniqueChunks}`,
- `duplicate-refs\t${stats.duplicateChunkRefs}`,
- `dedup-ratio\t${stats.dedupRatio.toFixed(2)}x`,
- `encrypted\t${stats.encryptedEntries}`,
- `envelope\t${stats.envelopeEntries}`,
- `compressed\t${stats.compressedEntries}`,
- `chunking\t${chunking}`,
- `largest\t${largest}`,
+ ...renderKeyValueLines([
+ ['entries', stats.entries],
+ ['logical-size', `${formatBytes(stats.totalLogicalSize)} (${stats.totalLogicalSize} bytes)`],
+ ['chunk-refs', stats.totalChunkRefs],
+ ['unique-chunks', stats.uniqueChunks],
+ ['duplicate-refs', stats.duplicateChunkRefs],
+ ['dedup-ratio', `${stats.dedupRatio.toFixed(2)}x`],
+ ['encrypted', stats.encryptedEntries],
+ ['envelope', stats.envelopeEntries],
+ ['compressed', stats.compressedEntries],
+ ['chunking', chunking],
+ ['largest', largest],
+ ]),
'',
].join('\n');
}
@@ -387,18 +400,20 @@ export async function inspectVaultHealth(cas) {
*/
export function renderDoctorReport(report) {
const lines = [
- `status\t${report.status}`,
- `vault\t${report.hasVault ? 'present' : 'missing'}`,
- `commit\t${report.commitOid ?? '-'}`,
- `entries\t${report.entryCount}`,
- `checked\t${report.checkedEntries}`,
- `valid\t${report.validEntries}`,
- `invalid\t${report.invalidEntries}`,
- `metadata\t${report.metadataEncrypted ? 'encrypted' : 'plain'}`,
- `issues\t${report.issues.length}`,
- `logical-size\t${formatBytes(report.stats.totalLogicalSize)} (${report.stats.totalLogicalSize} bytes)`,
- `chunk-refs\t${report.stats.totalChunkRefs}`,
- `unique-chunks\t${report.stats.uniqueChunks}`,
+ ...renderKeyValueLines([
+ ['status', report.status],
+ ['vault', report.hasVault ? 'present' : 'missing'],
+ ['commit', report.commitOid ?? '-'],
+ ['entries', report.entryCount],
+ ['checked', report.checkedEntries],
+ ['valid', report.validEntries],
+ ['invalid', report.invalidEntries],
+ ['metadata', report.metadataEncrypted ? 'encrypted' : 'plain'],
+ ['issues', report.issues.length],
+ ['logical-size', `${formatBytes(report.stats.totalLogicalSize)} (${report.stats.totalLogicalSize} bytes)`],
+ ['chunk-refs', report.stats.totalChunkRefs],
+ ['unique-chunks', report.stats.uniqueChunks],
+ ]),
'',
];
diff --git a/package.json b/package.json
index b6b17bc..dc2f127 100644
--- a/package.json
+++ b/package.json
@@ -67,9 +67,9 @@
"format": "prettier --write ."
},
"dependencies": {
- "@flyingrobots/bijou": "^0.2.0",
- "@flyingrobots/bijou-node": "^0.2.0",
- "@flyingrobots/bijou-tui": "^0.2.0",
+ "@flyingrobots/bijou": "^3.0.0",
+ "@flyingrobots/bijou-node": "^3.0.0",
+ "@flyingrobots/bijou-tui": "^3.0.0",
"@git-stunts/alfred": "^0.10.0",
"@git-stunts/plumbing": "^2.8.0",
"cbor-x": "^1.6.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7a55c39..6c568ce 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -9,14 +9,14 @@ importers:
.:
dependencies:
'@flyingrobots/bijou':
- specifier: ^0.2.0
- version: 0.2.0
+ specifier: ^3.0.0
+ version: 3.0.0
'@flyingrobots/bijou-node':
- specifier: ^0.2.0
- version: 0.2.0
+ specifier: ^3.0.0
+ version: 3.0.0(@flyingrobots/bijou@3.0.0)
'@flyingrobots/bijou-tui':
- specifier: ^0.2.0
- version: 0.2.0
+ specifier: ^3.0.0
+ version: 3.0.0(@flyingrobots/bijou@3.0.0)
'@git-stunts/alfred':
specifier: ^0.10.0
version: 0.10.0
@@ -263,16 +263,20 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@flyingrobots/bijou-node@0.2.0':
- resolution: {integrity: sha512-QaIaoBF0OMRHGtLsga1knplfFEmAeC6Lt4SxWkCKIJahMdNqXatCWM3RdzXcbjfcXqRIXyeEpm1agmmwi4gneQ==}
+ '@flyingrobots/bijou-node@3.0.0':
+ resolution: {integrity: sha512-1aO81Cx27hk7ThelDUGWpDSjmhrXfyGxs93GZM1pF520CMCDv70kESFJkm1DLQ4JnTFNj9YiLTUbeGfBCsAzKg==}
engines: {node: '>=18'}
+ peerDependencies:
+ '@flyingrobots/bijou': 3.0.0
- '@flyingrobots/bijou-tui@0.2.0':
- resolution: {integrity: sha512-pXEo/Am6svRIKvez7926avdGUbfVndlSOpidBPc42YjCQHU5ZQrEuJpjI7niJb63N0ruxu0VXHci8N0wzBYSow==}
+ '@flyingrobots/bijou-tui@3.0.0':
+ resolution: {integrity: sha512-762rerCgGD9RvMGg/MV6QzEJK9DwWAo+fZOG22IJdhJWGK/5/H91pQCFOBEhwVgTZ9vdZ7wu12P0ztRgkikQSA==}
engines: {node: '>=18'}
+ peerDependencies:
+ '@flyingrobots/bijou': 3.0.0
- '@flyingrobots/bijou@0.2.0':
- resolution: {integrity: sha512-Oix2Kqq4w87KCkyK2W+8u4E4aGVQiraUy8BF3Bk/NRtT+UlUI0ETs+E7GwpwOyOvHvt0cIOjcMmVPxzKa52P4A==}
+ '@flyingrobots/bijou@3.0.0':
+ resolution: {integrity: sha512-08MdIuzURjNQ4Nu2mc2m0kdWUemRIxFMJjNXnJOde671KFDqBzw5fZQ3PS0uijJVGVnkdmU/ASajFFb8sbFr+w==}
engines: {node: '>=18'}
'@git-stunts/alfred@0.10.0':
@@ -671,6 +675,9 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
+ gifenc@1.0.3:
+ resolution: {integrity: sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==}
+
glob-parent@6.0.2:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
@@ -765,6 +772,9 @@ packages:
resolution: {integrity: sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==}
engines: {node: '>=0.12.0'}
+ oled-font-5x7@1.0.3:
+ resolution: {integrity: sha512-l25WvKft8CgXYxtaqKdYrAS1P91rnUUUIiOXojAOvjNCsfFzIl1aEsE2JuaRgMh1Euo7slm5lX0w+1qNkL8PpQ==}
+
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -1106,16 +1116,19 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
- '@flyingrobots/bijou-node@0.2.0':
+ '@flyingrobots/bijou-node@3.0.0(@flyingrobots/bijou@3.0.0)':
dependencies:
- '@flyingrobots/bijou': 0.2.0
+ '@flyingrobots/bijou': 3.0.0
+ '@flyingrobots/bijou-tui': 3.0.0(@flyingrobots/bijou@3.0.0)
chalk: 5.6.2
+ gifenc: 1.0.3
+ oled-font-5x7: 1.0.3
- '@flyingrobots/bijou-tui@0.2.0':
+ '@flyingrobots/bijou-tui@3.0.0(@flyingrobots/bijou@3.0.0)':
dependencies:
- '@flyingrobots/bijou': 0.2.0
+ '@flyingrobots/bijou': 3.0.0
- '@flyingrobots/bijou@0.2.0': {}
+ '@flyingrobots/bijou@3.0.0': {}
'@git-stunts/alfred@0.10.0': {}
@@ -1482,6 +1495,8 @@ snapshots:
fsevents@2.3.3:
optional: true
+ gifenc@1.0.3: {}
+
glob-parent@6.0.2:
dependencies:
is-glob: 4.0.3
@@ -1560,6 +1575,8 @@ snapshots:
node-stream-zip@1.15.0: {}
+ oled-font-5x7@1.0.3: {}
+
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
diff --git a/test/unit/cli/dashboard-cmds.test.js b/test/unit/cli/dashboard-cmds.test.js
new file mode 100644
index 0000000..23d4b32
--- /dev/null
+++ b/test/unit/cli/dashboard-cmds.test.js
@@ -0,0 +1,416 @@
+import { describe, it, expect, vi } from 'vitest';
+import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { buildRepoTreemapReport, readRefInventory, readSourceEntries } from '../../../bin/ui/dashboard-cmds.js';
+
+function makePersistence(overrides = {}) {
+ return {
+ readBlob: vi.fn(),
+ plumbing: { execute: vi.fn() },
+ ...overrides,
+ };
+}
+
+function makeRefPort(overrides = {}) {
+ return {
+ resolveRef: vi.fn(),
+ resolveTree: vi.fn(),
+ ...overrides,
+ };
+}
+
+function makePlumbing(cwd, execute) {
+ return {
+ cwd,
+ execute: vi.fn(execute),
+ };
+}
+
+async function withTempRepo(run) {
+ const repoDir = await mkdtemp(path.join(os.tmpdir(), 'git-cas-dashboard-'));
+ try {
+ return await run(repoDir);
+ } finally {
+ await rm(repoDir, { recursive: true, force: true });
+ }
+}
+
+function revParseResult(repoDir, args) {
+ if (args[0] !== 'rev-parse') {
+ return null;
+ }
+ if (args[1] === '--git-dir') {
+ throw new Error('Prohibited git flag detected: --git-dir');
+ }
+ if (args[1] === '--is-bare-repository') {
+ return 'false';
+ }
+ if (args[1] === '--show-toplevel') {
+ return repoDir;
+ }
+ return null;
+}
+
+function repoExecResult(repoDir, options, args) {
+ const {
+ showRefOutput = '',
+ trackedPaths = [],
+ ignoredPaths = [],
+ } = options;
+ const revParse = revParseResult(repoDir, args);
+ if (revParse !== null) {
+ return revParse;
+ }
+ if (args[0] === 'show-ref') {
+ return showRefOutput;
+ }
+ if (args[0] === 'ls-files' && args.includes('--others') && args.includes('--ignored')) {
+ return ignoredPaths.join('\0');
+ }
+ if (args[0] === 'ls-files') {
+ return trackedPaths.join('\0');
+ }
+ throw new Error(`unexpected git command: ${args.join(' ')}`);
+}
+
+function makeRepoExec(repoDir, options = {}) {
+ return async ({ args }) => {
+ return repoExecResult(repoDir, options, args);
+ };
+}
+
+async function seedRepoLayout(repoDir) {
+ await mkdir(path.join(repoDir, '.git', 'objects'), { recursive: true });
+ await mkdir(path.join(repoDir, '.git', 'refs', 'heads'), { recursive: true });
+ await mkdir(path.join(repoDir, 'src'), { recursive: true });
+ await mkdir(path.join(repoDir, 'node_modules', 'leftpad'), { recursive: true });
+ await mkdir(path.join(repoDir, 'coverage'), { recursive: true });
+ await writeFile(path.join(repoDir, 'README.md'), 'hello world');
+ await writeFile(path.join(repoDir, 'src', 'app.js'), 'export const app = true;\n');
+ await writeFile(path.join(repoDir, 'node_modules', 'leftpad', 'index.js'), 'module.exports = () => 42;\n');
+ await writeFile(path.join(repoDir, 'coverage', 'summary.txt'), 'ignored coverage\n');
+ await writeFile(path.join(repoDir, '.git', 'objects', 'pack-1'), 'packdata');
+ await writeFile(path.join(repoDir, '.git', 'refs', 'heads', 'main'), 'deadbeef');
+}
+
+function readTreemapManifest(treeOid) {
+ if (treeOid === 'source-tree') {
+ return { toJSON: () => ({ size: 4096, chunks: [{ size: 2048 }, { size: 2048 }] }) };
+ }
+ if (treeOid === 'vault-tree') {
+ return { toJSON: () => ({ size: 2048, chunks: [{ size: 2048 }], encryption: { algorithm: 'aes-256-gcm' } }) };
+ }
+ if (treeOid === 'feedfacecafebeef') {
+ return { toJSON: () => ({ size: 3072, chunks: [{ size: 1024 }, { size: 2048 }] }) };
+ }
+ throw new Error(`unknown tree ${treeOid}`);
+}
+
+function makeRepositoryTreemapCas(plumbing) {
+ return {
+ listVault: vi.fn().mockResolvedValue([{ slug: 'vault:alpha', treeOid: 'vault-tree' }]),
+ getVaultMetadata: vi.fn().mockResolvedValue(null),
+ readManifest: vi.fn().mockImplementation(async ({ treeOid }) => readTreemapManifest(treeOid)),
+ getService: vi.fn().mockResolvedValue({
+ persistence: {
+ plumbing,
+ readBlob: vi.fn(async (oid) => {
+ if (oid === 'index-blob') {
+ return Buffer.from(JSON.stringify({
+ entries: {
+ alpha: { treeOid: 'source-tree' },
+ bravo: { treeOid: 'vault-tree' },
+ },
+ }));
+ }
+ throw new Error(`unknown blob ${oid}`);
+ }),
+ },
+ }),
+ getVaultService: vi.fn().mockResolvedValue({
+ ref: {
+ resolveRef: vi.fn(async (ref) => {
+ if (ref === 'refs/heads/main') {
+ return 'plain-commit';
+ }
+ if (ref === 'refs/warp/demo/seek-cache') {
+ return 'index-blob';
+ }
+ throw new Error(`unknown ref ${ref}`);
+ }),
+ resolveTree: vi.fn().mockRejectedValue(new Error('not a cas tree')),
+ },
+ }),
+ };
+}
+
+function makeSourceTreemapCas(plumbing) {
+ return {
+ readManifest: vi.fn().mockImplementation(async ({ treeOid }) => readTreemapManifest(treeOid)),
+ getService: vi.fn().mockResolvedValue({ persistence: { plumbing } }),
+ };
+}
+
+async function buildRepositoryReport({ source = { type: 'oid', treeOid: 'source-tree' }, worktreeMode = 'tracked' } = {}) {
+ return withRepositoryTreemapCase(async ({ cas, repoDir }) => ({
+ report: await buildRepoTreemapReport(cas, { source, scope: 'repository', worktreeMode }),
+ repoDir,
+ }));
+}
+
+async function buildSourceReport() {
+ return withTempRepo(async (repoDir) => {
+ const plumbing = makePlumbing(repoDir, makeRepoExec(repoDir));
+ const cas = makeSourceTreemapCas(plumbing);
+ return buildRepoTreemapReport(cas, {
+ source: { type: 'oid', treeOid: 'feedfacecafebeef' },
+ scope: 'source',
+ });
+ });
+}
+
+async function withRepositoryTreemapCase(run) {
+ return withTempRepo(async (repoDir) => {
+ await seedRepoLayout(repoDir);
+ const plumbing = makePlumbing(repoDir, makeRepoExec(repoDir, {
+ showRefOutput: [
+ '1111111111111111111111111111111111111111 refs/heads/main',
+ '2222222222222222222222222222222222222222 refs/warp/demo/seek-cache',
+ ].join('\n'),
+ trackedPaths: ['README.md', 'src/app.js'],
+ ignoredPaths: ['node_modules/', 'coverage/'],
+ }));
+ const cas = makeRepositoryTreemapCas(plumbing);
+ return run({ repoDir, plumbing, cas });
+ });
+}
+
+describe('readSourceEntries vault and oid modes', () => {
+ it('loads vault entries through the vault service facade', async () => {
+ const entries = [{ slug: 'alpha', treeOid: 'deadbeef' }];
+ const metadata = { version: 1 };
+ const cas = {
+ listVault: vi.fn().mockResolvedValue(entries),
+ getVaultMetadata: vi.fn().mockResolvedValue(metadata),
+ };
+
+ await expect(readSourceEntries(cas, { type: 'vault' })).resolves.toEqual({ entries, metadata });
+ });
+
+ it('builds a single entry for a direct tree oid source', async () => {
+ const cas = {};
+
+ await expect(
+ readSourceEntries(cas, { type: 'oid', treeOid: '0123456789abcdef' }),
+ ).resolves.toEqual({
+ entries: [{ slug: 'oid:0123456789ab', treeOid: '0123456789abcdef' }],
+ metadata: null,
+ });
+ });
+});
+
+describe('readSourceEntries ref tree resolution', () => {
+ it('treats a ref that resolves directly to a CAS tree as a single source entry', async () => {
+ const persistence = makePersistence();
+ const ref = makeRefPort({
+ resolveRef: vi.fn().mockResolvedValue('tree-oid-123'),
+ });
+ const cas = {
+ readManifest: vi.fn().mockResolvedValue({ slug: 'alpha' }),
+ getService: vi.fn().mockResolvedValue({ persistence }),
+ getVaultService: vi.fn().mockResolvedValue({ ref }),
+ };
+
+ await expect(
+ readSourceEntries(cas, { type: 'ref', ref: 'refs/apps/direct' }),
+ ).resolves.toEqual({
+ entries: [{ slug: 'refs/apps/direct', treeOid: 'tree-oid-123' }],
+ metadata: null,
+ });
+ expect(persistence.readBlob).not.toHaveBeenCalled();
+ });
+});
+
+describe('readSourceEntries ref-backed JSON indexes', () => {
+ it('extracts tree oids from a ref-backed JSON index blob', async () => {
+ const persistence = makePersistence({
+ readBlob: vi.fn().mockResolvedValue(Buffer.from(JSON.stringify({
+ schemaVersion: 1,
+ entries: {
+ 'v1:t10-bbb': { treeOid: 'tree-bbb' },
+ 'v1:t20-aaa': { treeOid: 'tree-aaa' },
+ },
+ }))),
+ });
+ const ref = makeRefPort({
+ resolveRef: vi.fn().mockResolvedValue('blob-oid'),
+ resolveTree: vi.fn().mockRejectedValue(new Error('not a commit')),
+ });
+ const cas = {
+ readManifest: vi.fn().mockRejectedValue(new Error('not a manifest')),
+ getService: vi.fn().mockResolvedValue({ persistence }),
+ getVaultService: vi.fn().mockResolvedValue({ ref }),
+ };
+
+ await expect(
+ readSourceEntries(cas, { type: 'ref', ref: 'refs/warp/demo/seek-cache' }),
+ ).resolves.toEqual({
+ entries: [
+ { slug: 'v1:t10-bbb', treeOid: 'tree-bbb' },
+ { slug: 'v1:t20-aaa', treeOid: 'tree-aaa' },
+ ],
+ metadata: null,
+ });
+ });
+});
+
+describe('readSourceEntries commit message hints', () => {
+ it('extracts a manifest tree hint from a ref-target commit message', async () => {
+ const persistence = makePersistence({
+ readBlob: vi.fn().mockRejectedValue(new Error('not a blob')),
+ plumbing: {
+ execute: vi.fn().mockResolvedValue('asset:image.png\n\nmanifest: feedfacecafebeef\n'),
+ },
+ });
+ const ref = makeRefPort({
+ resolveRef: vi.fn().mockResolvedValue('commit-oid'),
+ resolveTree: vi.fn().mockRejectedValue(new Error('not a cas tree')),
+ });
+ const cas = {
+ readManifest: vi.fn().mockRejectedValue(new Error('not a manifest')),
+ getService: vi.fn().mockResolvedValue({ persistence }),
+ getVaultService: vi.fn().mockResolvedValue({ ref }),
+ };
+
+ await expect(
+ readSourceEntries(cas, { type: 'ref', ref: 'refs/git-cms/chunks/logo@current' }),
+ ).resolves.toEqual({
+ entries: [{ slug: 'refs/git-cms/chunks/logo@current', treeOid: 'feedfacecafebeef' }],
+ metadata: null,
+ });
+ });
+});
+
+describe('buildRepoTreemapReport repository scope', () => {
+ it('builds a repository-scope atlas from git ls-files instead of raw disk children', async () => {
+ const { report, repoDir } = await buildRepositoryReport();
+ expect(report.scope).toBe('repository');
+ expect(report.worktreeMode).toBe('tracked');
+ expect(report.cwd).toBe(repoDir);
+ expect(report.summary.worktreeItems).toBeGreaterThan(0);
+ expect(report.summary.worktreePaths).toBe(2);
+ expect(report.summary.refCount).toBe(2);
+ expect(report.summary.vaultEntries).toBe(1);
+ expect(report.summary.sourceEntries).toBe(1);
+ const labels = report.tiles.map((tile) => tile.label);
+ expect(labels).toEqual(expect.arrayContaining([
+ 'README.md',
+ 'src',
+ '.git/objects',
+ 'refs/heads',
+ 'refs/warp',
+ 'vault:alpha',
+ 'oid:source-tree',
+ ]));
+ expect(labels).not.toContain('node_modules');
+ expect(report.notes).toEqual(expect.arrayContaining([
+ expect.stringContaining('Press r to browse refs'),
+ expect.stringContaining('git ls-files'),
+ ]));
+ });
+
+ it('can switch repository scope to ignored worktree paths', async () => {
+ const { report } = await buildRepositoryReport({
+ source: { type: 'vault' },
+ worktreeMode: 'ignored',
+ });
+ const labels = report.tiles.map((tile) => tile.label);
+ expect(report.worktreeMode).toBe('ignored');
+ expect(report.summary.worktreePaths).toBe(2);
+ expect(labels).toEqual(expect.arrayContaining(['node_modules', 'coverage']));
+ expect(labels).not.toContain('README.md');
+ expect(report.notes).toEqual(expect.arrayContaining([
+ expect.stringContaining('--others --ignored --exclude-standard'),
+ ]));
+ });
+});
+
+describe('buildRepoTreemapReport repository drilldown', () => {
+ it('can drill into git objects and ref namespaces', async () => {
+ await withRepositoryTreemapCase(async ({ cas }) => {
+ const objectsReport = await buildRepoTreemapReport(cas, {
+ source: { type: 'oid', treeOid: 'source-tree' },
+ scope: 'repository',
+ drillPath: [{ kind: 'git', segments: ['.git/objects'], label: '.git/objects' }],
+ });
+ expect(objectsReport.breadcrumb).toEqual(['repository', '.git/objects']);
+ expect(objectsReport.tiles).toEqual(expect.arrayContaining([
+ expect.objectContaining({ label: 'pack-1', kind: 'git' }),
+ ]));
+
+ const refsReport = await buildRepoTreemapReport(cas, {
+ source: { type: 'oid', treeOid: 'source-tree' },
+ scope: 'repository',
+ drillPath: [{ kind: 'ref', segments: ['refs/warp'], label: 'refs/warp' }],
+ });
+ expect(refsReport.tiles).toEqual(expect.arrayContaining([
+ expect.objectContaining({ label: 'demo', kind: 'ref', drillable: true }),
+ ]));
+ });
+ });
+});
+
+describe('buildRepoTreemapReport source scope', () => {
+ it('builds a source-scope treemap from logical source entries', async () => {
+ const report = await buildSourceReport();
+ expect(report.scope).toBe('source');
+ expect(report.worktreeMode).toBe('tracked');
+ expect(report.summary.sourceEntries).toBe(1);
+ expect(report.tiles).toEqual([
+ expect.objectContaining({
+ label: 'oid:feedfacecafe',
+ kind: 'cas',
+ }),
+ ]);
+ expect(report.notes).toEqual(expect.arrayContaining([
+ expect.stringContaining('Loaded 1 source entry'),
+ expect.stringContaining('logical manifest size'),
+ ]));
+ });
+});
+
+describe('readRefInventory', () => {
+ it('classifies refs by namespace and marks CAS-backed refs as browsable', async () => {
+ const { report } = await buildRepositoryReport();
+ const repoDir = report.cwd;
+ const plumbing = makePlumbing(repoDir, makeRepoExec(repoDir, {
+ showRefOutput: [
+ '1111111111111111111111111111111111111111 refs/heads/main',
+ '2222222222222222222222222222222222222222 refs/warp/demo/seek-cache',
+ ].join('\n'),
+ trackedPaths: ['README.md', 'src/app.js'],
+ ignoredPaths: ['node_modules/', 'coverage/'],
+ }));
+ const cas = makeRepositoryTreemapCas(plumbing);
+
+ const inventory = await readRefInventory(cas);
+
+ expect(inventory.namespaces).toEqual(expect.arrayContaining([
+ expect.objectContaining({ namespace: 'refs/heads', count: 1 }),
+ expect.objectContaining({ namespace: 'refs/warp', count: 1, browsable: 1 }),
+ ]));
+ expect(inventory.refs).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ ref: 'refs/warp/demo/seek-cache',
+ browsable: true,
+ resolution: 'index',
+ entryCount: 2,
+ }),
+ expect.objectContaining({
+ ref: 'refs/heads/main',
+ browsable: false,
+ }),
+ ]));
+ });
+});
diff --git a/test/unit/cli/dashboard.launch.test.js b/test/unit/cli/dashboard.launch.test.js
index 1ad143a..cf6e11c 100644
--- a/test/unit/cli/dashboard.launch.test.js
+++ b/test/unit/cli/dashboard.launch.test.js
@@ -84,4 +84,21 @@ describe('launchDashboard mode branching', () => {
expect(cas.listVault).toHaveBeenCalledTimes(1);
expect(output.write).toHaveBeenCalledWith('alpha\tdeadbeef\n');
});
+
+ it('prints a direct oid source when the context is non-interactive', async () => {
+ const cas = mockCas();
+ const ctx = makeCtx('pipe');
+ const output = { write: vi.fn() };
+
+ await launchDashboard(cas, {
+ ctx,
+ runApp: runMock,
+ output,
+ source: { type: 'oid', treeOid: '0123456789abcdef' },
+ });
+
+ expect(runMock).not.toHaveBeenCalled();
+ expect(cas.listVault).not.toHaveBeenCalled();
+ expect(output.write).toHaveBeenCalledWith('oid:0123456789ab\t0123456789abcdef\n');
+ });
});
diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js
index c4c60a3..59f8c45 100644
--- a/test/unit/cli/dashboard.test.js
+++ b/test/unit/cli/dashboard.test.js
@@ -1,4 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
+import { surfaceToString } from '@flyingrobots/bijou';
+import { createNavigableTableState, createSplitPaneState } from '@flyingrobots/bijou-tui';
import { makeCtx } from './_testContext.js';
vi.mock('../../../bin/ui/context.js', () => ({
@@ -16,25 +18,105 @@ function mockCas() {
};
}
-function makeDeps() {
- return { keyMap: createKeyBindings(), cas: mockCas(), ctx: makeCtx() };
+function makeDeps(overrides = {}) {
+ return {
+ keyMap: createKeyBindings(),
+ cas: mockCas(),
+ ctx: makeCtx(),
+ cwdLabel: '/tmp/git-cas-fixture',
+ source: { type: 'vault' },
+ ...overrides,
+ };
+}
+
+function renderView(output, ctx) {
+ return typeof output === 'string' ? output : surfaceToString(output, ctx.style);
+}
+
+function buildTableRows(entries, manifestCache = new Map()) {
+ return entries.map((entry) => {
+ const manifest = manifestCache.get(entry.slug);
+ if (!manifest) {
+ return [entry.slug, '...', '...', '...', '...', 'loading'];
+ }
+ const m = manifest.toJSON ? manifest.toJSON() : manifest;
+ return [
+ entry.slug,
+ String(m.size ?? 0),
+ String(m.chunks?.length ?? 0),
+ m.encryption ? 'enc' : 'plain',
+ m.compression ? m.compression.algorithm : 'raw',
+ m.subManifests?.length ? 'merkle' : 'single',
+ ];
+ });
+}
+
+function makeTable(filtered = [], options = {}) {
+ const rows = options.rows || 24;
+ const manifestCache = options.manifestCache || new Map();
+ return {
+ ...createNavigableTableState({
+ columns: [{ header: 'Slug', width: 20 }],
+ rows: buildTableRows(filtered, manifestCache),
+ height: Math.max(1, rows - 12),
+ }),
+ ...(options.overrides || {}),
+ };
+}
+
+function makeRefsTable(items = [], rows = 24, overrides = {}) {
+ return {
+ ...createNavigableTableState({
+ columns: [{ header: 'Ref', width: 32 }],
+ rows: items.map((item) => [item.ref, item.resolution, String(item.entryCount)]),
+ height: Math.max(1, rows - 12),
+ }),
+ ...overrides,
+ };
}
function makeModel(overrides = {}) {
+ const manifestCache = overrides.manifestCache || new Map();
+ const filtered = overrides.filtered || overrides.entries || [];
+ const rows = overrides.rows || 24;
+ const refsItems = overrides.refsItems || [];
return {
status: 'ready',
columns: 80,
rows: 24,
+ source: { type: 'vault' },
entries: [],
filtered: [],
- cursor: 0,
filterText: '',
filtering: false,
metadata: null,
- manifestCache: new Map(),
+ manifestCache,
loadingSlug: null,
detailScroll: 0,
error: null,
+ table: makeTable(filtered, { rows, manifestCache }),
+ refsTable: makeRefsTable(refsItems, rows),
+ refsItems,
+ splitPane: createSplitPaneState({ ratio: 0.37, focused: 'a' }),
+ palette: null,
+ activeDrawer: null,
+ refsStatus: 'idle',
+ refsError: null,
+ statsStatus: 'idle',
+ statsReport: null,
+ statsError: null,
+ doctorStatus: 'idle',
+ doctorReport: null,
+ doctorError: null,
+ treemapScope: 'repository',
+ treemapWorktreeMode: 'tracked',
+ treemapPath: [],
+ treemapFocus: 0,
+ treemapStatus: 'idle',
+ treemapReport: null,
+ treemapError: null,
+ toasts: [],
+ nextToastId: 1,
...overrides,
};
}
@@ -48,26 +130,200 @@ const entries = [
{ slug: 'bravo', treeOid: 'bbb222' },
];
-describe('dashboard init and navigation', () => {
+function makeStatsReport() {
+ return {
+ entries: 2,
+ totalLogicalSize: 4096,
+ totalChunkRefs: 3,
+ uniqueChunks: 2,
+ duplicateChunkRefs: 1,
+ dedupRatio: 1.5,
+ encryptedEntries: 1,
+ envelopeEntries: 0,
+ compressedEntries: 1,
+ chunkingStrategies: { fixed: 2 },
+ largestEntry: { slug: 'alpha', size: 2048 },
+ };
+}
+
+function makeDoctorReport() {
+ return {
+ status: 'warn',
+ hasVault: true,
+ commitOid: 'abc123',
+ entryCount: 2,
+ checkedEntries: 2,
+ validEntries: 1,
+ invalidEntries: 1,
+ metadataEncrypted: false,
+ stats: makeStatsReport(),
+ issues: [{ scope: 'vault', code: 'BROKEN', message: 'bad chunk' }],
+ };
+}
+
+function makeRefItems() {
+ return [
+ {
+ ref: 'refs/warp/demo/seek-cache',
+ oid: '2222222222222222222222222222222222222222',
+ namespace: 'refs/warp',
+ browsable: true,
+ resolution: 'index',
+ entryCount: 2,
+ detail: '2 CAS entries from index blob',
+ previewSlugs: ['alpha', 'bravo'],
+ source: { type: 'ref', ref: 'refs/warp/demo/seek-cache' },
+ },
+ {
+ ref: 'refs/heads/main',
+ oid: '1111111111111111111111111111111111111111',
+ namespace: 'refs/heads',
+ browsable: false,
+ resolution: 'opaque',
+ entryCount: 0,
+ detail: 'does not resolve to CAS entries',
+ previewSlugs: [],
+ source: null,
+ },
+ ];
+}
+
+function makeTreemapTile(options) {
+ const {
+ kind,
+ label,
+ value,
+ detail,
+ drillable = true,
+ segments = [label],
+ id = `${kind}:${segments.join(':')}`,
+ } = options;
+ return {
+ id,
+ label,
+ kind,
+ value,
+ detail,
+ drillable,
+ path: drillable && segments ? { kind, segments, label } : null,
+ };
+}
+
+function makeTreemapSummary(overrides = {}) {
+ return {
+ bare: false,
+ gitDir: '/tmp/git-cas-fixture/.git',
+ worktreeItems: 1,
+ worktreePaths: 2,
+ refNamespaces: 1,
+ refCount: 3,
+ vaultEntries: 2,
+ sourceEntries: 2,
+ ...overrides,
+ };
+}
+
+function makeTreemapReport(overrides = {}) {
+ return {
+ scope: 'repository',
+ worktreeMode: 'tracked',
+ cwd: '/tmp/git-cas-fixture',
+ source: { type: 'vault' },
+ drillPath: [],
+ breadcrumb: ['repository'],
+ totalValue: 8192,
+ tiles: [
+ makeTreemapTile({ kind: 'worktree', label: 'src', value: 4096, detail: '2 tracked paths · 4.0K on disk' }),
+ makeTreemapTile({ kind: 'git', label: '.git/objects', value: 2048, detail: '2 git items · 2.0K on disk',
+ segments: ['.git/objects'],
+ }),
+ makeTreemapTile({ kind: 'vault', label: 'docs', value: 2048, detail: '2 entries · 2.0K logical' }),
+ ],
+ notes: [
+ 'Repository view mixes Git-reported worktree paths, .git on-disk bytes, ref namespaces, and logical CAS region sizes.',
+ 'Worktree mode tracked via git ls-files.',
+ ],
+ summary: makeTreemapSummary(),
+ ...overrides,
+ };
+}
+
+function makeToast(overrides = {}) {
+ return {
+ id: 1,
+ level: 'info',
+ title: 'Toast title',
+ message: 'toast body',
+ phase: 'steady',
+ progress: 1,
+ ...overrides,
+ };
+}
+
+function renderDashboardWithModel(modelOverrides = {}, depsOverrides = {}) {
+ const deps = makeDeps(depsOverrides);
+ const app = createDashboardApp(deps);
+ return {
+ deps,
+ app,
+ rendered: renderView(app.view(makeModel(modelOverrides)), deps.ctx),
+ };
+}
+
+function makeFullScreenTreemapModel() {
+ return {
+ activeDrawer: 'treemap',
+ treemapScope: 'repository',
+ treemapStatus: 'ready',
+ treemapReport: makeTreemapReport({
+ tiles: [
+ makeTreemapTile({ kind: 'worktree', label: 'src', value: 4096, detail: '2 tracked paths · 4.0K on disk' }),
+ makeTreemapTile({ kind: 'git', label: '.git/objects', value: 2048, detail: '2 git items · 2.0K on disk',
+ segments: ['.git/objects'],
+ }),
+ makeTreemapTile({ kind: 'meta', label: 'other', value: 1024, detail: '2 smaller regions',
+ id: 'meta:other',
+ drillable: false,
+ segments: null,
+ }),
+ ],
+ }),
+ columns: 120,
+ rows: 36,
+ };
+}
+
+describe('dashboard initialization', () => {
it('init returns loading model with one cmd', () => {
const app = createDashboardApp(makeDeps());
const [model, cmds] = app.init();
expect(model.status).toBe('loading');
expect(cmds).toHaveLength(1);
+ expect(model.splitPane.focused).toBe('a');
});
+});
- it('move cursor down', () => {
+describe('dashboard navigation', () => {
+ it('move table focus down', () => {
const app = createDashboardApp(makeDeps());
const model = makeModel({ filtered: entries, entries });
const [next] = app.update(keyMsg('j'), model);
- expect(next.cursor).toBe(1);
+ expect(next.table.focusRow).toBe(1);
});
- it('move cursor up clamps at 0', () => {
+ it('move table focus up wraps to the last row', () => {
const app = createDashboardApp(makeDeps());
const model = makeModel({ filtered: entries, entries });
const [next] = app.update(keyMsg('k'), model);
- expect(next.cursor).toBe(0);
+ expect(next.table.focusRow).toBe(1);
+ });
+
+ it('pages table focus down', () => {
+ const app = createDashboardApp(makeDeps());
+ const manyEntries = Array.from({ length: 20 }, (_, index) => ({ slug: `asset-${index}`, treeOid: `oid-${index}` }));
+ const model = makeModel({ filtered: manyEntries, entries: manyEntries });
+ const [next] = app.update(keyMsg('d'), model);
+ expect(next.table.focusRow).toBeGreaterThan(0);
});
it('quit returns quit command', () => {
@@ -81,38 +337,361 @@ describe('dashboard init and navigation', () => {
const [next] = app.update(keyMsg('j', { shift: true }), makeModel());
expect(next.detailScroll).toBe(3);
});
+});
+describe('dashboard pane controls', () => {
it('resize updates dimensions', () => {
const app = createDashboardApp(makeDeps());
const [next] = app.update({ type: 'resize', columns: 120, rows: 40 }, makeModel());
expect(next.columns).toBe(120);
expect(next.rows).toBe(40);
+ expect(next.table.height).toBe(28);
+ });
+
+ it('tab toggles the focused pane', () => {
+ const app = createDashboardApp(makeDeps());
+ const [next] = app.update(keyMsg('tab'), makeModel());
+ expect(next.splitPane.focused).toBe('b');
+ });
+
+ it('shift+l widens the focused pane', () => {
+ const app = createDashboardApp(makeDeps());
+ const [next] = app.update(keyMsg('l', { shift: true }), makeModel());
+ expect(next.splitPane.ratio).toBeGreaterThan(0.37);
+ });
+});
+
+describe('dashboard palette and overlay commands', () => {
+ it('ctrl+p opens the command palette', () => {
+ const deps = makeDeps();
+ const app = createDashboardApp(deps);
+ const [next] = app.update(keyMsg('p', { ctrl: true }), makeModel());
+ expect(next.palette).not.toBeNull();
+ const rendered = renderView(app.view(next), deps.ctx);
+ expect(rendered).toContain('Command Deck');
+ expect(rendered).toContain('Open Repo Treemap');
+ expect(rendered).toContain('Open Source Stats');
+ });
+
+ it('palette selection opens the stats drawer and queues a load', () => {
+ const app = createDashboardApp(makeDeps());
+ const [withPalette] = app.update(keyMsg('p', { ctrl: true }), makeModel());
+ const [onTreemap] = app.update(keyMsg('down'), withPalette);
+ const [onTreemapScope] = app.update(keyMsg('down'), onTreemap);
+ const [onTreemapWorktree] = app.update(keyMsg('down'), onTreemapScope);
+ const [onTreemapDrillIn] = app.update(keyMsg('down'), onTreemapWorktree);
+ const [onTreemapDrillOut] = app.update(keyMsg('down'), onTreemapDrillIn);
+ const [onStats] = app.update(keyMsg('down'), onTreemapDrillOut);
+ const [next, cmds] = app.update(keyMsg('enter'), onStats);
+ expect(next.palette).toBeNull();
+ expect(next.activeDrawer).toBe('stats');
+ expect(next.statsStatus).toBe('loading');
+ expect(cmds).toHaveLength(1);
+ });
+});
+
+describe('dashboard drawer shortcuts', () => {
+ it('doctor key opens the doctor drawer and queues a load', () => {
+ const app = createDashboardApp(makeDeps());
+ const [next, cmds] = app.update(keyMsg('g'), makeModel());
+ expect(next.activeDrawer).toBe('doctor');
+ expect(next.doctorStatus).toBe('loading');
+ expect(cmds).toHaveLength(1);
+ });
+
+ it('escape closes the active overlay', () => {
+ const app = createDashboardApp(makeDeps());
+ const [next] = app.update(keyMsg('escape'), makeModel({ activeDrawer: 'stats', statsStatus: 'ready' }));
+ expect(next.activeDrawer).toBeNull();
+ });
+});
+
+describe('dashboard toast dismissal', () => {
+ it('escape starts the latest toast exit animation when no overlay is open', () => {
+ const app = createDashboardApp(makeDeps());
+ const [next, cmds] = app.update(keyMsg('escape'), makeModel({
+ toasts: [
+ makeToast({ id: 2, level: 'warning', title: 'Heads up', message: 'yellow alert' }),
+ makeToast({ id: 1, level: 'error', title: 'Failed to load repo treemap', message: 'boom' }),
+ ],
+ }));
+ expect(next.toasts).toHaveLength(2);
+ expect(next.toasts[0]).toMatchObject({
+ id: 2,
+ title: 'Heads up',
+ phase: 'exiting',
+ progress: 1,
+ });
+ expect(next.toasts[1]).toMatchObject({
+ id: 1,
+ title: 'Failed to load repo treemap',
+ phase: 'steady',
+ });
+ expect(cmds).toHaveLength(2);
+ });
+});
+
+describe('dashboard treemap launch shortcuts', () => {
+ it('t opens the treemap view and queues a load', () => {
+ const app = createDashboardApp(makeDeps());
+ const [next, cmds] = app.update(keyMsg('t'), makeModel());
+ expect(next.activeDrawer).toBe('treemap');
+ expect(next.treemapStatus).toBe('loading');
+ expect(cmds).toHaveLength(1);
+ });
+});
+
+describe('dashboard treemap view shortcuts', () => {
+ it('shift+t toggles the treemap scope and triggers a load', () => {
+ const app = createDashboardApp(makeDeps());
+ const model = makeModel({
+ activeDrawer: 'treemap',
+ treemapScope: 'repository',
+ treemapStatus: 'ready',
+ treemapReport: { scope: 'repository' },
+ });
+ const [next, cmds] = app.update(keyMsg('t', { shift: true }), model);
+ expect(next.treemapScope).toBe('source');
+ expect(next.activeDrawer).toBe('treemap');
+ expect(next.treemapStatus).toBe('loading');
+ expect(cmds).toHaveLength(1);
+ });
+
+ it('i toggles repository treemap files between tracked and ignored', () => {
+ const app = createDashboardApp(makeDeps());
+ const model = makeModel({
+ activeDrawer: 'treemap',
+ treemapScope: 'repository',
+ treemapWorktreeMode: 'tracked',
+ treemapStatus: 'ready',
+ treemapReport: makeTreemapReport(),
+ });
+ const [next, cmds] = app.update(keyMsg('i'), model);
+ expect(next.treemapScope).toBe('repository');
+ expect(next.treemapWorktreeMode).toBe('ignored');
+ expect(next.treemapStatus).toBe('loading');
+ expect(next.activeDrawer).toBe('treemap');
+ expect(cmds).toHaveLength(1);
+ });
+
+ it('j moves treemap focus between regions', () => {
+ const app = createDashboardApp(makeDeps());
+ const model = makeModel({
+ activeDrawer: 'treemap',
+ treemapStatus: 'ready',
+ treemapReport: makeTreemapReport(),
+ });
+ const [next] = app.update(keyMsg('j'), model);
+ expect(next.treemapFocus).toBe(1);
+ });
+});
+
+function makeDrilledTreemapModel() {
+ return makeModel({
+ activeDrawer: 'treemap',
+ treemapScope: 'repository',
+ treemapStatus: 'ready',
+ treemapPath: [{ kind: 'git', segments: ['.git/objects'], label: '.git/objects' }],
+ treemapReport: makeTreemapReport({
+ drillPath: [{ kind: 'git', segments: ['.git/objects'], label: '.git/objects' }],
+ breadcrumb: ['repository', '.git/objects'],
+ tiles: [{
+ id: 'git:.git/objects/pack',
+ label: 'pack',
+ kind: 'git',
+ value: 2048,
+ detail: '2 git items · 2.0K on disk',
+ drillable: true,
+ path: { kind: 'git', segments: ['.git/objects', 'pack'], label: 'pack' },
+ }],
+ }),
+ });
+}
+
+describe('dashboard treemap descend shortcuts', () => {
+ it('+ drills into the focused treemap region', () => {
+ const app = createDashboardApp(makeDeps());
+ const model = makeModel({
+ activeDrawer: 'treemap',
+ treemapStatus: 'ready',
+ treemapReport: makeTreemapReport(),
+ });
+ const [next, cmds] = app.update(keyMsg('=', { shift: true }), model);
+ expect(next.treemapPath).toEqual([{ kind: 'worktree', segments: ['src'], label: 'src' }]);
+ expect(next.treemapStatus).toBe('loading');
+ expect(cmds).toHaveLength(1);
+ });
+
+ it('raw + key events also drill into the focused treemap region', () => {
+ const app = createDashboardApp(makeDeps());
+ const model = makeModel({
+ activeDrawer: 'treemap',
+ treemapStatus: 'ready',
+ treemapReport: makeTreemapReport(),
+ });
+ const [next, cmds] = app.update(keyMsg('+'), model);
+ expect(next.treemapPath).toEqual([{ kind: 'worktree', segments: ['src'], label: 'src' }]);
+ expect(next.treemapStatus).toBe('loading');
+ expect(cmds).toHaveLength(1);
+ });
+});
+
+describe('dashboard treemap ascend shortcuts', () => {
+ it('- ascends to the parent treemap level', () => {
+ const app = createDashboardApp(makeDeps());
+ const model = makeDrilledTreemapModel();
+ const [next, cmds] = app.update(keyMsg('-'), model);
+ expect(next.treemapPath).toEqual([]);
+ expect(next.treemapStatus).toBe('loading');
+ expect(cmds).toHaveLength(1);
+ });
+
+ it('raw _ key events also ascend to the parent treemap level', () => {
+ const app = createDashboardApp(makeDeps());
+ const model = makeDrilledTreemapModel();
+ const [next, cmds] = app.update(keyMsg('_', { shift: true }), model);
+ expect(next.treemapPath).toEqual([]);
+ expect(next.treemapStatus).toBe('loading');
+ expect(cmds).toHaveLength(1);
+ });
+});
+
+describe('dashboard refs shortcuts', () => {
+ it('r opens the refs view and queues a load', () => {
+ const app = createDashboardApp(makeDeps());
+ const [next, cmds] = app.update(keyMsg('r'), makeModel());
+ expect(next.activeDrawer).toBe('refs');
+ expect(next.refsStatus).toBe('loading');
+ expect(cmds).toHaveLength(1);
+ });
+
+ it('loaded-refs stores the inventory and enter switches the source', () => {
+ const app = createDashboardApp(makeDeps());
+ const refs = { namespaces: [{ namespace: 'refs/warp', count: 1, browsable: 1 }], refs: makeRefItems() };
+ const [withRefs] = app.update({ type: 'loaded-refs', refs }, makeModel({ activeDrawer: 'refs', refsStatus: 'loading' }));
+ const [next, cmds] = app.update(keyMsg('enter'), withRefs);
+ expect(next.source).toEqual({ type: 'ref', ref: 'refs/warp/demo/seek-cache' });
+ expect(next.activeDrawer).toBeNull();
+ expect(next.status).toBe('loading');
+ expect(cmds).toHaveLength(1);
});
});
describe('dashboard data loading', () => {
it('loaded-entries sets entries and fires manifest loads', () => {
const app = createDashboardApp(makeDeps());
- const msg = { type: 'loaded-entries', entries, metadata: null };
+ const msg = { type: 'loaded-entries', entries, metadata: null, source: { type: 'vault' } };
const [next, cmds] = app.update(msg, makeModel({ status: 'loading' }));
expect(next.status).toBe('ready');
expect(next.entries).toEqual(entries);
+ expect(next.table.rows).toHaveLength(2);
expect(cmds).toHaveLength(2);
});
it('loaded-manifest caches manifest', () => {
const app = createDashboardApp(makeDeps());
const manifest = { slug: 'alpha', size: 100, chunks: [] };
- const [next] = app.update({ type: 'loaded-manifest', slug: 'alpha', manifest }, makeModel());
+ const [next] = app.update({ type: 'loaded-manifest', slug: 'alpha', manifest, source: { type: 'vault' } }, makeModel());
expect(next.manifestCache.get('alpha')).toBe(manifest);
});
+ it('ignores stale entry loads for a source that is no longer active', () => {
+ const app = createDashboardApp(makeDeps());
+ const model = makeModel({ source: { type: 'ref', ref: 'refs/warp/demo/seek-cache' } });
+ const [next] = app.update({ type: 'loaded-entries', entries, metadata: null, source: { type: 'vault' } }, model);
+ expect(next.entries).toEqual([]);
+ expect(next.source).toEqual({ type: 'ref', ref: 'refs/warp/demo/seek-cache' });
+ });
+});
+
+describe('dashboard report loading', () => {
+ it('loaded-stats stores the stats report', () => {
+ const app = createDashboardApp(makeDeps());
+ const stats = makeStatsReport();
+ const [next] = app.update({ type: 'loaded-stats', stats, source: { type: 'vault' } }, makeModel({ activeDrawer: 'stats', statsStatus: 'loading' }));
+ expect(next.statsStatus).toBe('ready');
+ expect(next.statsReport).toEqual(stats);
+ expect(next.statsError).toBeNull();
+ });
+
+ it('loaded-doctor stores the doctor report', () => {
+ const app = createDashboardApp(makeDeps());
+ const report = makeDoctorReport();
+ const [next] = app.update({ type: 'loaded-doctor', report, source: { type: 'vault' } }, makeModel({ activeDrawer: 'doctor', doctorStatus: 'loading' }));
+ expect(next.doctorStatus).toBe('ready');
+ expect(next.doctorReport).toEqual(report);
+ expect(next.doctorError).toBeNull();
+ });
+});
+
+describe('dashboard treemap reports', () => {
+ it('loaded-treemap stores the report for the active scope', () => {
+ const app = createDashboardApp(makeDeps());
+ const report = makeTreemapReport({
+ totalValue: 2048,
+ tiles: [{
+ id: 'worktree:src',
+ label: 'src',
+ kind: 'worktree',
+ value: 2048,
+ detail: '2 tracked paths · 2.0K on disk',
+ drillable: true,
+ path: { kind: 'worktree', segments: ['src'], label: 'src' },
+ }],
+ notes: [],
+ summary: {
+ bare: false,
+ gitDir: '/tmp/git-cas-fixture/.git',
+ worktreeItems: 1,
+ worktreePaths: 1,
+ refNamespaces: 1,
+ refCount: 2,
+ vaultEntries: 1,
+ sourceEntries: 1,
+ },
+ });
+ const [next] = app.update({ type: 'loaded-treemap', report }, makeModel({ activeDrawer: 'treemap', treemapStatus: 'loading' }));
+ expect(next.treemapStatus).toBe('ready');
+ expect(next.treemapReport).toEqual(report);
+ expect(next.treemapError).toBeNull();
+ });
+});
+
+describe('dashboard toast messages', () => {
+ it('dismiss-toast removes the matching toast', () => {
+ const app = createDashboardApp(makeDeps());
+ const model = makeModel({
+ toasts: [
+ makeToast({ id: 1, level: 'error', title: 'Failed to load entries', message: 'boom' }),
+ makeToast({ id: 2, level: 'warning', title: 'Heads up', message: 'careful' }),
+ ],
+ });
+ const [next] = app.update({ type: 'dismiss-toast', id: 1 }, model);
+ expect(next.toasts).toEqual([
+ makeToast({ id: 2, level: 'warning', title: 'Heads up', message: 'careful' }),
+ ]);
+ });
+
+ it('toast-progress promotes entering toasts to steady once animation completes', () => {
+ const app = createDashboardApp(makeDeps());
+ const model = makeModel({
+ toasts: [makeToast({ id: 3, title: 'Loaded', phase: 'entering', progress: 0.4 })],
+ });
+ const [next] = app.update({ type: 'toast-progress', id: 3, progress: 1 }, model);
+ expect(next.toasts).toEqual([
+ makeToast({ id: 3, title: 'Loaded', phase: 'steady', progress: 1 }),
+ ]);
+ });
+});
+
+describe('dashboard filter mode', () => {
it('filter mode captures characters and filters entries', () => {
const app = createDashboardApp(makeDeps());
const model = makeModel({ filtering: true, entries, filtered: entries });
const [next] = app.update(keyMsg('l'), model);
expect(next.filterText).toBe('l');
expect(next.filtered).toHaveLength(1);
+ expect(next.table.rows).toHaveLength(1);
expect(next.filtered[0].slug).toBe('alpha');
});
@@ -126,44 +705,58 @@ describe('dashboard data loading', () => {
it('loaded-entries applies active filter', () => {
const app = createDashboardApp(makeDeps());
const model = makeModel({ status: 'loading', filterText: 'al', filtering: true });
- const msg = { type: 'loaded-entries', entries, metadata: null };
+ const msg = { type: 'loaded-entries', entries, metadata: null, source: { type: 'vault' } };
const [next] = app.update(msg, model);
expect(next.filtered).toHaveLength(1);
expect(next.filtered[0].slug).toBe('alpha');
});
});
-describe('dashboard edge cases', () => {
+describe('dashboard filter edge cases', () => {
it('filter-backspace removes last char and re-filters', () => {
const app = createDashboardApp(makeDeps());
const model = makeModel({ filtering: true, filterText: 'al', entries, filtered: [entries[0]] });
const [next] = app.update(keyMsg('backspace'), model);
expect(next.filterText).toBe('a');
expect(next.filtered).toHaveLength(2);
- expect(next.cursor).toBe(0);
+ expect(next.table.focusRow).toBe(0);
});
it('load-error from entries sets error and status on model', () => {
const app = createDashboardApp(makeDeps());
- const [next] = app.update({ type: 'load-error', source: 'entries', error: 'boom' }, makeModel());
+ const [next, cmds] = app.update({ type: 'load-error', source: 'entries', forSource: { type: 'vault' }, error: 'boom' }, makeModel());
expect(next.error).toBe('boom');
expect(next.status).toBe('error');
+ expect(next.toasts).toHaveLength(1);
+ expect(next.toasts[0].title).toBe('Failed to load entries');
+ expect(next.toasts[0]).toMatchObject({ phase: 'entering', progress: 0 });
+ expect(cmds).toHaveLength(2);
});
+});
+describe('dashboard loading edge cases', () => {
it('load-error from manifest does not set global error', () => {
const app = createDashboardApp(makeDeps());
const model = makeModel({ status: 'ready', entries, filtered: entries });
- const [next] = app.update({ type: 'load-error', source: 'manifest', slug: 'alpha', error: 'oops' }, model);
+ const [next, cmds] = app.update({ type: 'load-error', source: 'manifest', slug: 'alpha', forSource: { type: 'vault' }, error: 'oops' }, model);
expect(next.status).toBe('ready');
expect(next.error).toBeNull();
+ expect(next.toasts).toHaveLength(1);
+ expect(next.toasts[0].title).toBe('Failed to load alpha');
+ expect(next.toasts[0]).toMatchObject({ phase: 'entering', progress: 0 });
+ expect(cmds).toHaveLength(2);
});
- it('loaded-entries clamps cursor to filtered bounds', () => {
+ it('loaded-entries clamps table focus to filtered bounds', () => {
const app = createDashboardApp(makeDeps());
- const model = makeModel({ status: 'loading', cursor: 5, filterText: 'al' });
- const msg = { type: 'loaded-entries', entries, metadata: null };
+ const model = makeModel({
+ status: 'loading',
+ filterText: 'al',
+ table: makeTable([], { overrides: { focusRow: 5 } }),
+ });
+ const msg = { type: 'loaded-entries', entries, metadata: null, source: { type: 'vault' } };
const [next] = app.update(msg, model);
- expect(next.cursor).toBe(0);
+ expect(next.table.focusRow).toBe(0);
expect(next.filtered).toHaveLength(1);
});
@@ -177,42 +770,276 @@ describe('dashboard edge cases', () => {
it('select on uncached entry returns loadManifestCmd', () => {
const app = createDashboardApp(makeDeps());
- const model = makeModel({ entries, filtered: entries, cursor: 0 });
+ const model = makeModel({ entries, filtered: entries });
const [next, cmds] = app.update(keyMsg('enter'), model);
expect(next.loadingSlug).toBe('alpha');
+ expect(next.splitPane.focused).toBe('b');
expect(cmds).toHaveLength(1);
});
});
describe('dashboard view rendering', () => {
- it('renders without errors on empty model', () => {
- const app = createDashboardApp(makeDeps());
+ it('renders a surface-native explorer layout on empty model', () => {
+ const deps = makeDeps();
+ const app = createDashboardApp(deps);
const model = makeModel();
const output = app.view(model);
- expect(typeof output).toBe('string');
- expect(output).toContain('0 entries');
+ expect(typeof output).toBe('object');
+ expect(output.width).toBe(model.columns);
+ const rendered = renderView(output, deps.ctx);
+ expect(rendered).toContain('git-cas repository explorer');
+ expect(rendered).toContain('cwd /tmp/git-cas-fixture');
+ expect(rendered).toContain('source vault refs/cas/vault');
+ expect(rendered).toContain('Entries');
+ expect(rendered).toContain('Manifest Inspector');
});
it('renders entry list when entries exist', () => {
- const app = createDashboardApp(makeDeps());
+ const deps = makeDeps();
+ const app = createDashboardApp(deps);
const model = makeModel({ entries, filtered: entries });
- const output = app.view(model);
- expect(output).toContain('alpha');
- expect(output).toContain('bravo');
+ const rendered = renderView(app.view(model), deps.ctx);
+ expect(rendered).toContain('Slug');
+ expect(rendered).toContain('alpha');
+ expect(rendered).toContain('bravo');
+ });
+
+ it('renders encrypted header badge text without object coercion', () => {
+ const deps = makeDeps();
+ const app = createDashboardApp(deps);
+ const model = makeModel({ metadata: { encryption: { cipher: 'aes-256-gcm' } } });
+ const rendered = renderView(app.view(model), deps.ctx);
+ expect(rendered).toContain('encrypted');
+ expect(rendered).not.toContain('[object Object]');
});
it('renders error message on error status', () => {
- const app = createDashboardApp(makeDeps());
+ const deps = makeDeps();
+ const app = createDashboardApp(deps);
const model = makeModel({ status: 'error', error: 'connection failed' });
- const output = app.view(model);
- expect(output).toContain('Error: connection failed');
+ const rendered = renderView(app.view(model), deps.ctx);
+ expect(rendered).toContain('Error: connection failed');
});
+});
+describe('dashboard footer rendering', () => {
it('renders footer keybinding hints', () => {
- const app = createDashboardApp(makeDeps());
- const model = makeModel();
- const output = app.view(model);
- expect(output).toContain('Navigate');
- expect(output).toContain('Quit');
+ const deps = makeDeps();
+ const app = createDashboardApp(deps);
+ const model = makeModel({ columns: 120 });
+ const rendered = renderView(app.view(model), deps.ctx);
+ expect(rendered).toContain('inspect');
+ expect(rendered).toContain('resize');
+ expect(rendered).toContain('pane');
+ expect(rendered).toContain('palette');
+ expect(rendered).toContain('stats');
+ expect(rendered).toContain('doctor');
+ expect(rendered).toContain('treemap');
+ expect(rendered).toContain('scope');
+ expect(rendered).toContain('files');
+ expect(rendered).toContain('refs');
+ expect(rendered).toContain('clos');
+ expect(rendered).toContain('quit');
+ });
+
+ it('renders treemap-specific footer hints in treemap mode', () => {
+ const { rendered } = renderDashboardWithModel({
+ activeDrawer: 'treemap',
+ treemapStatus: 'ready',
+ treemapReport: makeTreemapReport(),
+ columns: 120,
+ });
+ expect(rendered).toContain('back');
+ expect(rendered).toContain('descend');
+ expect(rendered).toContain('ascend');
+ });
+});
+
+describe('dashboard inspector rendering', () => {
+ it('renders selected asset summary in the inspector pane', () => {
+ const deps = makeDeps();
+ const app = createDashboardApp(deps);
+ const manifest = { slug: 'alpha', size: 1536, chunks: [{ index: 0, size: 1536, digest: 'abcd1234efgh5678' }] };
+ const model = makeModel({
+ entries,
+ filtered: entries,
+ manifestCache: new Map([['alpha', manifest]]),
+ });
+ const rendered = renderView(app.view(model), deps.ctx);
+ expect(rendered).toContain('asset alpha');
+ expect(rendered).toContain('chunks 1');
+ });
+
+ it('renders inspector focus chrome when pane b is active', () => {
+ const deps = makeDeps();
+ const app = createDashboardApp(deps);
+ const model = makeModel({ splitPane: createSplitPaneState({ ratio: 0.37, focused: 'b' }) });
+ const rendered = renderView(app.view(model), deps.ctx);
+ expect(rendered).toContain('Manifest Inspector *');
+ });
+});
+
+describe('dashboard report overlay rendering', () => {
+ it('renders the stats drawer overlay', () => {
+ const deps = makeDeps();
+ const app = createDashboardApp(deps);
+ const model = makeModel({
+ activeDrawer: 'stats',
+ statsStatus: 'ready',
+ statsReport: makeStatsReport(),
+ });
+ const rendered = renderView(app.view(model), deps.ctx);
+ expect(rendered).toContain('Vault Metrics');
+ expect(rendered).toContain('dedup-ratio');
+ expect(rendered).not.toContain('\t');
+ });
+
+ it('renders the doctor drawer loading state', () => {
+ const deps = makeDeps();
+ const app = createDashboardApp(deps);
+ const rendered = renderView(app.view(makeModel({ activeDrawer: 'doctor', doctorStatus: 'loading' })), deps.ctx);
+ expect(rendered).toContain('Vault Doctor');
+ expect(rendered).toContain('Loading doctor report');
+ });
+});
+
+describe('dashboard treemap rendering', () => {
+ it('renders the treemap as a full-screen view with a details sidebar', () => {
+ const { rendered } = renderDashboardWithModel(makeFullScreenTreemapModel());
+ expect(rendered).toContain('atlas view');
+ expect(rendered).toContain('Repository Atlas');
+ expect(rendered).toContain('Atlas Briefing');
+ expect(rendered).toContain('Overview');
+ expect(rendered).toContain('Focused Region');
+ expect(rendered).toContain('Legend');
+ expect(rendered).toContain('Largest Regions');
+ expect(rendered).toContain('scope repository');
+ expect(rendered).toContain('files tracked');
+ expect(rendered).toContain('level repository');
+ expect(rendered).toContain('focus s');
+ expect(rendered).toContain('other');
+ expect(rendered).toContain('2 tracked paths');
+ expect(rendered).toContain('Repository view mixes Git-reported');
+ });
+
+ it('renders an actionable empty-source message in source treemap mode', () => {
+ const { rendered } = renderDashboardWithModel({
+ activeDrawer: 'treemap',
+ treemapScope: 'source',
+ treemapStatus: 'ready',
+ treemapReport: makeTreemapReport({
+ scope: 'source',
+ breadcrumb: ['source'],
+ totalValue: 0,
+ tiles: [{ id: 'meta:empty-source', label: 'empty source', kind: 'meta', value: 1, detail: 'No CAS entries resolved for this source', drillable: false, path: null }],
+ notes: [
+ 'No CAS entries resolved for the vault. Press r to browse refs or T to return to repository scope.',
+ 'Source view weights tiles by logical manifest size.',
+ ],
+ summary: {
+ bare: false,
+ gitDir: '/tmp/git-cas-fixture/.git',
+ worktreeItems: 0,
+ worktreePaths: 0,
+ refNamespaces: 0,
+ refCount: 0,
+ vaultEntries: 0,
+ sourceEntries: 0,
+ },
+ }),
+ });
+ expect(rendered).toContain('No CAS entries were resolved for the current source.');
+ expect(rendered).toContain('Press r to browse refs');
+ });
+});
+
+describe('dashboard refs rendering', () => {
+ it('renders the refs browser as a full-screen view', () => {
+ const { rendered } = renderDashboardWithModel({
+ activeDrawer: 'refs',
+ refsStatus: 'ready',
+ refsItems: makeRefItems(),
+ columns: 120,
+ rows: 36,
+ });
+ expect(rendered).toContain('ref index');
+ expect(rendered).toContain('Ref Index');
+ expect(rendered).toContain('Ref Dispatch');
+ expect(rendered).toContain('refs/warp/demo/seek-cache');
+ expect(rendered).toContain('Press enter to switch source');
+ });
+
+ it('wraps refs list metadata and sidebar prose on narrow layouts', () => {
+ const refsItems = [
+ {
+ ref: 'refs/heads/main',
+ oid: '69956e82efb7f6fb21fd0749d0d83a13c14068b7',
+ namespace: 'refs/heads',
+ browsable: false,
+ resolution: 'opaque',
+ entryCount: 0,
+ detail: 'Ref refs/heads/main did not resolve to a vault manifest or index blob.',
+ previewSlugs: [],
+ source: null,
+ },
+ ];
+ const { rendered } = renderDashboardWithModel({
+ activeDrawer: 'refs',
+ refsStatus: 'ready',
+ refsItems,
+ refsTable: makeRefsTable(refsItems, 20, { focusRow: 0 }),
+ columns: 84,
+ rows: 20,
+ });
+ expect(rendered).toContain('69956e82efb7');
+ expect(rendered).toContain('resolve to a vault manifest');
+ expect(rendered).toContain('or index blob.');
+ });
+});
+
+describe('dashboard palette rendering', () => {
+ it('renders the palette badge when the command palette is open', () => {
+ const deps = makeDeps();
+ const app = createDashboardApp(deps);
+ const [withPalette] = app.update(keyMsg('p', { ctrl: true }), makeModel());
+ const rendered = renderView(app.view(withPalette), deps.ctx);
+ expect(rendered).toContain('palette');
+ expect(rendered).toContain('Command Deck');
+ expect(rendered).toContain('Open Repo Treemap');
+ expect(rendered).toContain('Open Source Stats');
+ });
+
+ it('renders stacked toast notifications', () => {
+ const deps = makeDeps();
+ const app = createDashboardApp(deps);
+ const rendered = renderView(app.view(makeModel({
+ toasts: [
+ makeToast({ id: 2, level: 'warning', title: 'Heads up', message: 'yellow alert with more words to wrap cleanly' }),
+ makeToast({ id: 1, level: 'error', title: 'Failed to load repo treemap', message: 'boom' }),
+ ],
+ })), deps.ctx);
+ expect(rendered).toContain('alerts 2');
+ expect(rendered).toContain('ERROR // Failed to load repo treemap');
+ expect(rendered).toContain('WARNING // Heads up');
+ expect(rendered).toContain('yellow alert with more words');
+ });
+
+ it('renders exiting toasts near completion without crashing', () => {
+ const deps = makeDeps();
+ const app = createDashboardApp(deps);
+ const rendered = renderView(app.view(makeModel({
+ toasts: [
+ makeToast({
+ id: 7,
+ level: 'error',
+ title: 'Opaque ref',
+ message: 'refs/heads/main does not resolve to CAS entries',
+ phase: 'exiting',
+ progress: 0.08,
+ }),
+ ],
+ })), deps.ctx);
+ expect(rendered).toContain('║ERROR');
+ expect(rendered).not.toContain('TypeError');
});
});
diff --git a/test/unit/cli/manifest-view.test.js b/test/unit/cli/manifest-view.test.js
index a60e2ae..31d6160 100644
--- a/test/unit/cli/manifest-view.test.js
+++ b/test/unit/cli/manifest-view.test.js
@@ -28,11 +28,12 @@ describe('renderManifestView', () => {
expect(output).toContain('test-asset');
expect(output).toContain('photo.jpg');
expect(output).toContain('Metadata');
+ expect(output).toContain('v1');
});
it('renders chunk table', () => {
const output = renderManifestView({ manifest: makeManifest() });
- expect(output).toContain('Chunks (2)');
+ expect(output).toContain('Chunk Ledger (2)');
expect(output).toContain('aaaaaaaaaaaa...');
});
@@ -41,22 +42,25 @@ describe('renderManifestView', () => {
const output = renderManifestView({ manifest: makeManifest({ encryption: enc }) });
expect(output).toContain('Encryption');
expect(output).toContain('aes-256-gcm');
+ expect(output).toContain('encrypted');
});
it('renders compression section', () => {
const output = renderManifestView({ manifest: makeManifest({ compression: { algorithm: 'gzip' } }) });
expect(output).toContain('Compression');
+ expect(output).toContain('gzip');
});
it('renders sub-manifests', () => {
const subs = [{ oid: 'aaaa1111bbbb2222', chunkCount: 1000, startIndex: 0 }, { oid: 'cccc3333dddd4444', chunkCount: 500, startIndex: 1000 }];
const output = renderManifestView({ manifest: makeManifest({ version: 2, subManifests: subs }) });
- expect(output).toContain('Sub-manifests (2)');
+ expect(output).toContain('Merkle Branches (2)');
+ expect(output).toContain('merkle');
});
it('truncates chunks beyond 20', () => {
const chunks = Array.from({ length: 30 }, (_, i) => ({ index: i, size: 262144, digest: 'a'.repeat(64), blob: 'b'.repeat(40) }));
const output = renderManifestView({ manifest: makeManifest({ chunks }) });
- expect(output).toContain('Chunks (30)');
+ expect(output).toContain('Chunk Ledger (30)');
});
});
diff --git a/test/unit/cli/repo-treemap.test.js b/test/unit/cli/repo-treemap.test.js
new file mode 100644
index 0000000..87b0630
--- /dev/null
+++ b/test/unit/cli/repo-treemap.test.js
@@ -0,0 +1,132 @@
+import { describe, it, expect } from 'vitest';
+import { renderRepoTreemapMap, renderRepoTreemapSidebar } from '../../../bin/ui/repo-treemap.js';
+import { makeCtx } from './_testContext.js';
+
+function makeReport(overrides = {}) {
+ return {
+ scope: 'repository',
+ worktreeMode: 'tracked',
+ cwd: '/tmp/git-cas-fixture',
+ source: { type: 'vault' },
+ drillPath: [],
+ breadcrumb: ['repository'],
+ totalValue: 21_400_000,
+ tiles: [
+ { id: 'worktree:docs', label: 'docs', kind: 'worktree', value: 3_638_000, detail: '33 tracked paths · 3.5M on disk', drillable: true, path: { kind: 'worktree', segments: ['docs'], label: 'docs' } },
+ { id: 'worktree:public', label: 'public', kind: 'worktree', value: 107_000, detail: '5 tracked paths · 104.5K on disk', drillable: true, path: { kind: 'worktree', segments: ['public'], label: 'public' } },
+ { id: 'worktree:package-lock.json', label: 'package-lock.json', kind: 'worktree', value: 107_000, detail: '1 tracked path · 104.5K on disk', drillable: false, path: { kind: 'worktree', segments: ['package-lock.json'], label: 'package-lock.json' } },
+ { id: 'worktree:test', label: 'test', kind: 'worktree', value: 64_000, detail: '17 tracked paths · 62.5K on disk', drillable: true, path: { kind: 'worktree', segments: ['test'], label: 'test' } },
+ { id: 'worktree:pnpm-lock.yaml', label: 'pnpm-lock.yaml', kind: 'worktree', value: 64_000, detail: '1 tracked path · 62.5K on disk', drillable: false, path: { kind: 'worktree', segments: ['pnpm-lock.yaml'], label: 'pnpm-lock.yaml' } },
+ { id: 'worktree:src', label: 'src', kind: 'worktree', value: 43_000, detail: '6 tracked paths · 42.0K on disk', drillable: true, path: { kind: 'worktree', segments: ['src'], label: 'src' } },
+ { id: 'worktree:scripts', label: 'scripts', kind: 'worktree', value: 21_000, detail: '13 tracked paths · 20.5K on disk', drillable: true, path: { kind: 'worktree', segments: ['scripts'], label: 'scripts' } },
+ { id: 'git:.git/objects', label: '.git/objects', kind: 'git', value: 8_000_000, detail: '7.6M on disk', drillable: true, path: { kind: 'git', segments: ['.git/objects'], label: '.git/objects' } },
+ { id: 'ref:refs/git-cms', label: 'refs/git-cms', kind: 'ref', value: 2_000_000, detail: '17 refs', drillable: true, path: { kind: 'ref', segments: ['refs/git-cms'], label: 'refs/git-cms' } },
+ { id: 'vault:docs', label: 'docs', kind: 'vault', value: 1_500_000, detail: '2 entries · 1.4M logical', drillable: true, path: { kind: 'vault', segments: ['docs'], label: 'docs' } },
+ ],
+ notes: [
+ 'Repository view mixes Git-reported worktree paths, .git on-disk bytes, ref namespaces, and logical CAS region sizes.',
+ ],
+ summary: {
+ bare: false,
+ gitDir: '/tmp/git-cas-fixture/.git',
+ worktreeItems: 7,
+ worktreePaths: 102,
+ refNamespaces: 1,
+ refCount: 17,
+ vaultEntries: 2,
+ sourceEntries: 0,
+ },
+ ...overrides,
+ };
+}
+
+function makeStyledCtx() {
+ return /** @type {any} */ ({
+ style: {
+ rgb: (...args) => {
+ const [red, green, blue, text] = args;
+ return `[fg:${red},${green},${blue}]${text}[/fg]`;
+ },
+ bgRgb: (...args) => `[bg]${args[3]}[/bg]`,
+ bold: (text) => `[bold]${text}[/bold]`,
+ },
+ });
+}
+
+describe('repo treemap map rendering', () => {
+ it('renders multiple large regions when the half-split crosses on the last item', () => {
+ const output = renderRepoTreemapMap(makeReport(), {
+ ctx: makeCtx(),
+ width: 120,
+ height: 28,
+ });
+
+ expect(output).toContain('docs');
+ expect(output).toContain('.git/objects');
+ expect(output).toContain('refs/git-cms');
+ });
+
+ it('renders label text as bold white without painting stripe backgrounds', () => {
+ const output = renderRepoTreemapMap(makeReport({
+ totalValue: 10,
+ tiles: [{ id: 'worktree:docs', label: 'docs', kind: 'worktree', value: 10, detail: '10 tracked paths', drillable: true, path: { kind: 'worktree', segments: ['docs'], label: 'docs' } }],
+ }), {
+ ctx: makeStyledCtx(),
+ width: 24,
+ height: 8,
+ });
+
+ expect(output).toContain('[bold][fg:255,255,255]d[/fg][/bold]');
+ expect(output).toContain('[bold][fg:255,255,255]o[/fg][/bold]');
+ expect(output).not.toContain('[bg]');
+ });
+});
+
+describe('repo treemap sidebar rendering', () => {
+
+ it('sorts sidebar largest regions by value instead of source construction order', () => {
+ const sidebar = renderRepoTreemapSidebar(makeReport(), {
+ ctx: makeCtx(),
+ width: 60,
+ height: 28,
+ });
+ const regionLines = sidebar.regions.split('\n');
+
+ expect(regionLines[0]).toContain('.git/objects');
+ expect(regionLines[1]).toContain('docs');
+ expect(regionLines[2]).toContain('refs/git-cms');
+ });
+
+ it('includes the current level and focused region in the sidebar', () => {
+ const sidebar = renderRepoTreemapSidebar(makeReport({
+ drillPath: [{ kind: 'git', segments: ['.git/objects'], label: '.git/objects' }],
+ breadcrumb: ['repository', '.git/objects'],
+ }), {
+ ctx: makeCtx(),
+ width: 60,
+ height: 28,
+ selectedTileId: 'git:.git/objects',
+ });
+
+ expect(sidebar.overview).toContain('level repository > .git/objects');
+ expect(sidebar.focused).toContain('.git/objects');
+ expect(sidebar.focused).toContain('Press + to descend.');
+ });
+
+ it('wraps notes on whitespace before falling back to hard character breaks', () => {
+ const sidebar = renderRepoTreemapSidebar(makeReport({
+ notes: ['alpha beta longword delta', 'supercalifragilistic'],
+ }), {
+ ctx: makeCtx(),
+ width: 16,
+ height: 32,
+ });
+
+ expect(sidebar.notes.split('\n')).toEqual([
+ 'alpha beta',
+ 'longword delta',
+ 'supercalifragili',
+ 'stic',
+ ]);
+ });
+});
diff --git a/test/unit/cli/vault-report.test.js b/test/unit/cli/vault-report.test.js
index d5fba03..f16745c 100644
--- a/test/unit/cli/vault-report.test.js
+++ b/test/unit/cli/vault-report.test.js
@@ -117,11 +117,12 @@ describe('renderVaultStats', () => {
largestEntry: { slug: 'photos/hero.jpg', size: 1000 },
});
- expect(output).toContain('entries\t2');
- expect(output).toContain('logical-size\t1.6 KiB (1600 bytes)');
- expect(output).toContain('dedup-ratio\t1.33x');
- expect(output).toContain('chunking\tcdc:1, fixed:1');
- expect(output).toContain('largest\tphotos/hero.jpg (1000 bytes)');
+ expect(output).toMatch(/entries\s+2/);
+ expect(output).toMatch(/logical-size\s+1\.6 KiB \(1600 bytes\)/);
+ expect(output).toMatch(/dedup-ratio\s+1\.33x/);
+ expect(output).toMatch(/chunking\s+cdc:1, fixed:1/);
+ expect(output).toMatch(/largest\s+photos\/hero\.jpg \(1000 bytes\)/);
+ expect(output).not.toContain('\t');
});
});
@@ -211,9 +212,10 @@ describe('renderDoctorReport', () => {
],
});
- expect(output).toContain('status\tfail');
- expect(output).toContain('vault\tpresent');
- expect(output).toContain('issues\t1');
+ expect(output).toMatch(/status\s+fail/);
+ expect(output).toMatch(/vault\s+present/);
+ expect(output).toMatch(/issues\s+1/);
expect(output).toContain('[entry] bad/asset (tree-2) MANIFEST_NOT_FOUND: manifest missing');
+ expect(output).not.toContain('\t');
});
});