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'); }); });