From dc2c9c53a1db07656b966bfe227391cdc9ae87b3 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 18 Mar 2026 03:15:06 -0700 Subject: [PATCH 01/22] build(ui): upgrade bijou to v3 --- bin/ui/dashboard-view.js | 4 +-- bin/ui/encryption-card.js | 6 ++-- bin/ui/manifest-view.js | 11 ++++--- package.json | 6 ++-- pnpm-lock.yaml | 51 +++++++++++++++++++---------- test/unit/cli/dashboard.test.js | 8 +++++ test/unit/cli/manifest-view.test.js | 4 +++ 7 files changed, 60 insertions(+), 30 deletions(-) diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index 09cb5b4..e3a814f 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -2,7 +2,7 @@ * Pure render functions for the vault dashboard. */ -import { badge } from '@flyingrobots/bijou'; +import { badge, surfaceToString } from '@flyingrobots/bijou'; import { flex, viewport } from '@flyingrobots/bijou-tui'; import { renderManifestView } from './manifest-view.js'; @@ -76,7 +76,7 @@ function visibleRange(cursor, total, height) { function renderHeader(model, ctx) { const parts = []; if (model.metadata?.encryption) { - parts.push(badge('encrypted', { variant: 'warning', ctx })); + parts.push(surfaceToString(badge('encrypted', { variant: 'warning', ctx }), ctx.style)); } parts.push(`${model.entries.length} entries`); parts.push('refs/cas/vault'); diff --git a/bin/ui/encryption-card.js b/bin/ui/encryption-card.js index 1143617..e5961aa 100644 --- a/bin/ui/encryption-card.js +++ b/bin/ui/encryption-card.js @@ -2,7 +2,7 @@ * Encryption info card — visual summary of vault crypto configuration. */ -import { box, badge, headerBox } from '@flyingrobots/bijou'; +import { box, badge, headerBox, surfaceToString } from '@flyingrobots/bijou'; import { getCliContext } from './context.js'; /** @@ -24,8 +24,8 @@ export function renderEncryptionCard({ metadata, unlocked = false }) { const { kdf } = encryption; const status = unlocked - ? badge('unlocked', { variant: 'success', ctx }) - : badge('locked', { variant: 'error', ctx }); + ? surfaceToString(badge('unlocked', { variant: 'success', ctx }), ctx.style) + : surfaceToString(badge('locked', { variant: 'error', ctx }), ctx.style); const rows = [ ` cipher ${encryption.cipher}`, diff --git a/bin/ui/manifest-view.js b/bin/ui/manifest-view.js index 1be14aa..ad6e043 100644 --- a/bin/ui/manifest-view.js +++ b/bin/ui/manifest-view.js @@ -2,7 +2,7 @@ * Manifest anatomy view — rich visual breakdown of a manifest. */ -import { box, badge, table, tree, headerBox } from '@flyingrobots/bijou'; +import { box, badge, table, tree, headerBox, surfaceToString } from '@flyingrobots/bijou'; import { getCliContext } from './context.js'; /** @@ -37,15 +37,16 @@ function formatBytes(bytes) { * @returns {string} */ function renderBadges(m, ctx) { - const badges = [badge(`v${m.version}`, { ctx })]; + const renderBadge = (label, options = {}) => surfaceToString(badge(label, { ...options, ctx }), ctx.style); + const badges = [renderBadge(`v${m.version}`)]; if (m.encryption) { - badges.push(badge('encrypted', { variant: 'warning', ctx })); + badges.push(renderBadge('encrypted', { variant: 'warning' })); } if (m.compression) { - badges.push(badge(m.compression.algorithm, { variant: 'info', ctx })); + badges.push(renderBadge(m.compression.algorithm, { variant: 'info' })); } if (m.subManifests?.length) { - badges.push(badge('merkle', { variant: 'info', ctx })); + badges.push(renderBadge('merkle', { variant: 'info' })); } return badges.join(' '); } 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.test.js b/test/unit/cli/dashboard.test.js index c4c60a3..8f1bc84 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -201,6 +201,14 @@ describe('dashboard view rendering', () => { expect(output).toContain('bravo'); }); + it('renders encrypted header badge text without object coercion', () => { + const app = createDashboardApp(makeDeps()); + const model = makeModel({ metadata: { encryption: { cipher: 'aes-256-gcm' } } }); + const output = app.view(model); + expect(output).toContain('encrypted'); + expect(output).not.toContain('[object Object]'); + }); + it('renders error message on error status', () => { const app = createDashboardApp(makeDeps()); const model = makeModel({ status: 'error', error: 'connection failed' }); diff --git a/test/unit/cli/manifest-view.test.js b/test/unit/cli/manifest-view.test.js index a60e2ae..c9fea91 100644 --- a/test/unit/cli/manifest-view.test.js +++ b/test/unit/cli/manifest-view.test.js @@ -28,6 +28,7 @@ describe('renderManifestView', () => { expect(output).toContain('test-asset'); expect(output).toContain('photo.jpg'); expect(output).toContain('Metadata'); + expect(output).toContain('v1'); }); it('renders chunk table', () => { @@ -41,17 +42,20 @@ 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'); }); it('truncates chunks beyond 20', () => { From 7e039a54a5c040b8c52176983ca2a231654ba43f Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 18 Mar 2026 04:37:06 -0700 Subject: [PATCH 02/22] feat(ui): add surface-native vault explorer --- bin/ui/dashboard-view.js | 257 ++++++++++++++++++++++---------- test/unit/cli/dashboard.test.js | 68 ++++++--- 2 files changed, 226 insertions(+), 99 deletions(-) diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index e3a814f..729d290 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -2,14 +2,14 @@ * Pure render functions for the vault dashboard. */ -import { badge, surfaceToString } from '@flyingrobots/bijou'; -import { flex, viewport } from '@flyingrobots/bijou-tui'; +import { badge, boxV3, createSurface, parseAnsiToSurface, kbd } from '@flyingrobots/bijou'; import { renderManifestView } from './manifest-view.js'; /** * @typedef {import('./dashboard.js').DashModel} DashModel * @typedef {import('./dashboard.js').DashDeps} DashDeps * @typedef {import('@flyingrobots/bijou').BijouContext} BijouContext + * @typedef {import('@flyingrobots/bijou').Surface} Surface * @typedef {import('../../src/domain/value-objects/Manifest.js').default} Manifest */ @@ -38,19 +38,12 @@ function formatStats(manifest) { } /** - * Render a single list item. + * Safely clip text to a pane width. * - * @param {{ slug: string, treeOid: string }} entry - * @param {number} index - * @param {{ model: DashModel, width?: number }} opts * @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 clip(text, width) { + return width > 0 ? text.slice(0, width) : ''; } /** @@ -67,115 +60,219 @@ function visibleRange(cursor, total, height) { } /** - * Render the header line. + * Convert text to a fixed-size surface. + * + * @param {string} text + * @param {number} width + * @param {number} height + * @returns {Surface} + */ +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 surface. * * @param {DashModel} model * @param {BijouContext} ctx - * @returns {string} + * @returns {Surface} */ -function renderHeader(model, ctx) { - const parts = []; +function renderHeaderSurface(model, ctx) { + const surface = createSurface(Math.max(1, model.columns), 3); + surface.blit(textSurface('git-cas vault explorer', surface.width, 1), 0, 0); + + const parts = [ + badge(`${model.filtered.length}/${model.entries.length || model.filtered.length} visible`, { variant: 'info', ctx }), + ]; if (model.metadata?.encryption) { - parts.push(surfaceToString(badge('encrypted', { variant: 'warning', ctx }), ctx.style)); + parts.push(badge('encrypted', { variant: 'warning', ctx })); } - parts.push(`${model.entries.length} entries`); - parts.push('refs/cas/vault'); - return parts.join(' '); + if (model.filtering || model.filterText) { + parts.push(badge(model.filtering ? 'filtering' : `filter ${model.filterText}`, { variant: 'accent', ctx })); + } + const selected = model.filtered[model.cursor]; + if (selected) { + parts.push(badge(`selected ${selected.slug}`, { variant: 'primary', ctx })); + } + blitInline(surface, { + x: 0, + y: 1, + parts: ['refs/cas/vault', ...parts], + maxWidth: surface.width, + }); + surface.blit(textSurface('─'.repeat(surface.width), surface.width, 1), 0, 2); + return surface; } /** - * Render the list pane. + * Format list rows for the explorer pane. * + * @param {{ slug: string, treeOid: string }} entry + * @param {number} index * @param {DashModel} model - * @param {{ height: number, width?: number }} size * @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; - - if (items.length === 0) { - const msg = clamp( - model.status === 'loading' - ? 'Loading...' - : model.error - ? `Error: ${model.error}` - : 'No entries', - ); - return padToHeight(msg, listHeight, filterLine); - } - - const { start, end } = visibleRange(model.cursor, items.length, listHeight); - const lines = []; - for (let i = start; i < end; i++) { - lines.push(renderListItem(items[i], i, { model, width: size.width })); - } - return padToHeight(lines.join('\n'), listHeight, filterLine); +function renderListItem(entry, index, model) { + const manifest = model.manifestCache.get(entry.slug); + const m = manifest && (manifest.toJSON ? manifest.toJSON() : manifest); + const prefix = index === model.cursor ? '>' : ' '; + const status = manifest + ? [ + m.encryption ? 'enc' : 'clr', + m.compression ? m.compression.algorithm : 'raw', + m.subManifests?.length ? 'merkle' : 'single', + ].join(' ') + : 'loading'; + return `${prefix} ${entry.slug} ${manifest ? formatStats(manifest) : '...'} ${status}`; } /** - * Pad content to target height, optionally appending a suffix line. + * Render the explorer list pane. * - * @param {string} content - * @param {number} height - * @param {string} suffix - * @returns {string} + * @param {DashModel} model + * @param {{ width: number, height: number, ctx: BijouContext }} opts + * @returns {Surface} */ -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 renderListPane(model, opts) { + const innerWidth = Math.max(1, opts.width - 2); + const innerHeight = Math.max(1, opts.height - 2); + const infoLine = model.filtering ? `filter /${model.filterText}\u2588` : model.filterText ? `filter ${model.filterText}` : 'filter all'; + const lines = [clip(infoLine, innerWidth), '']; + const visibleHeight = Math.max(0, innerHeight - lines.length); + + if (model.filtered.length === 0) { + lines.push(model.status === 'loading' ? 'Loading...' : model.error ? `Error: ${model.error}` : 'No entries'); + } else { + const { start, end } = visibleRange(model.cursor, model.filtered.length, Math.max(1, visibleHeight)); + for (let i = start; i < end; i++) { + lines.push(clip(renderListItem(model.filtered[i], i, model), innerWidth)); + } + } + + return boxV3(textSurface(lines.join('\n'), innerWidth, innerHeight), { + ctx: opts.ctx, + title: 'Entries', + width: opts.width, + }); } /** - * Render the detail pane with viewport scrolling. + * Render the explorer detail pane. * * @param {DashModel} model * @param {{ width: number, height: number, ctx: BijouContext }} opts - * @returns {string} + * @returns {Surface} */ function renderDetailPane(model, opts) { + const innerWidth = Math.max(1, opts.width - 2); + const innerHeight = Math.max(1, opts.height - 2); + const content = createSurface(innerWidth, innerHeight); const entry = model.filtered[model.cursor]; - if (!entry) { return ''; } + + if (!entry) { + content.blit(textSurface('Select an entry to inspect it.', innerWidth, innerHeight), 0, 0); + return boxV3(content, { ctx: opts.ctx, title: '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 = [ + `asset ${entry.slug}`, + `tree ${entry.treeOid.slice(0, 12)}...`, + ]; + content.blit(textSurface(summary.join('\n'), innerWidth, Math.min(2, innerHeight)), 0, 0); + + if (!manifest) { + const loadingText = entry.slug === model.loadingSlug ? 'Loading manifest...' : 'Manifest not loaded yet.'; + content.blit(textSurface(loadingText, innerWidth, Math.max(1, innerHeight - 3)), 0, 3); + return boxV3(content, { ctx: opts.ctx, title: '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: 'Inspector', width: opts.width }); +} + +/** + * Render the footer help surface. + * + * @param {BijouContext} ctx + * @param {number} width + * @returns {Surface} + */ +function renderFooterSurface(ctx, width) { + const lines = [ + '─'.repeat(Math.max(1, width)), + `${kbd('j/k', { ctx })} move ${kbd('enter', { ctx })} inspect ${kbd('/', { ctx })} filter ${kbd('J/K', { ctx })} scroll ${kbd('q', { ctx })} quit`, + ]; + return textSurface(lines.join('\n'), width, 2); } /** - * Render the body with list and detail panes. + * Render the body with a split explorer layout. * * @param {DashModel} model * @param {DashDeps} deps - * @param {{ width: number, height: number }} size - * @returns {string} + * @param {{ top: number, height: number, screen: Surface }} options */ -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 renderBody(model, deps, options) { + const gap = model.columns >= 72 ? 1 : 0; + const listWidth = Math.max(24, Math.min(Math.floor(model.columns * 0.37), model.columns - 28 - gap)); + const detailWidth = Math.max(24, model.columns - listWidth - gap); + const listPane = renderListPane(model, { width: listWidth, height: options.height, ctx: deps.ctx }); + const detailPane = renderDetailPane(model, { width: detailWidth, height: options.height, ctx: deps.ctx }); + options.screen.blit(listPane, 0, options.top); + options.screen.blit(detailPane, listWidth + gap, options.top); } /** - * Render the full dashboard layout. + * Render the full dashboard explorer layout. * * @param {DashModel} model * @param {DashDeps} deps - * @returns {string} + * @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.ctx); + const footer = renderFooterSurface(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 }); + screen.blit(footer, 0, Math.max(0, height - footer.height)); + + return screen; } diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index 8f1bc84..82dea85 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; +import { surfaceToString } from '@flyingrobots/bijou'; import { makeCtx } from './_testContext.js'; vi.mock('../../../bin/ui/context.js', () => ({ @@ -20,6 +21,10 @@ function makeDeps() { return { keyMap: createKeyBindings(), cas: mockCas(), ctx: makeCtx() }; } +function renderView(output, ctx) { + return typeof output === 'string' ? output : surfaceToString(output, ctx.style); +} + function makeModel(overrides = {}) { return { status: 'ready', @@ -185,42 +190,67 @@ describe('dashboard edge cases', () => { }); 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 vault explorer'); + expect(rendered).toContain('Entries'); + expect(rendered).toContain('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('alpha'); + expect(rendered).toContain('bravo'); }); it('renders encrypted header badge text without object coercion', () => { - const app = createDashboardApp(makeDeps()); + const deps = makeDeps(); + const app = createDashboardApp(deps); const model = makeModel({ metadata: { encryption: { cipher: 'aes-256-gcm' } } }); - const output = app.view(model); - expect(output).toContain('encrypted'); - expect(output).not.toContain('[object Object]'); + 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 explorer details', () => { it('renders footer keybinding hints', () => { - const app = createDashboardApp(makeDeps()); + const deps = makeDeps(); + const app = createDashboardApp(deps); const model = makeModel(); - const output = app.view(model); - expect(output).toContain('Navigate'); - expect(output).toContain('Quit'); + const rendered = renderView(app.view(model), deps.ctx); + expect(rendered).toContain('inspect'); + expect(rendered).toContain('quit'); + }); + + 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)'); }); }); From ee55fd3de5ad0ea689487445bf7724ae20d8497a Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 18 Mar 2026 04:56:35 -0700 Subject: [PATCH 03/22] feat(ui): add table navigation and split resize --- bin/ui/dashboard-view.js | 211 +++++++++++++++++---------- bin/ui/dashboard.js | 247 +++++++++++++++++++++++++++++--- test/unit/cli/dashboard.test.js | 110 ++++++++++++-- 3 files changed, 458 insertions(+), 110 deletions(-) diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index 729d290..c4ba272 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -3,6 +3,7 @@ */ import { badge, boxV3, createSurface, parseAnsiToSurface, kbd } from '@flyingrobots/bijou'; +import { navigableTable, splitPaneLayout } from '@flyingrobots/bijou-tui'; import { renderManifestView } from './manifest-view.js'; /** @@ -10,32 +11,11 @@ import { renderManifestView } from './manifest-view.js'; * @typedef {import('./dashboard.js').DashDeps} DashDeps * @typedef {import('@flyingrobots/bijou').BijouContext} BijouContext * @typedef {import('@flyingrobots/bijou').Surface} Surface - * @typedef {import('../../src/domain/value-objects/Manifest.js').default} Manifest */ -/** - * 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`; -} - -/** - * Format manifest stats for the list. - * - * @param {Manifest} manifest - * @returns {string} - */ -function formatStats(manifest) { - const m = manifest.toJSON ? manifest.toJSON() : manifest; - return `${formatSize(m.size)} ${m.chunks?.length ?? 0}c`; -} +const SPLIT_MIN_LIST_WIDTH = 28; +const SPLIT_MIN_DETAIL_WIDTH = 32; +const SPLIT_DIVIDER_SIZE = 1; /** * Safely clip text to a pane width. @@ -54,19 +34,6 @@ function clip(text, width) { * @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) }; -} - -/** - * Convert text to a fixed-size surface. - * - * @param {string} text - * @param {number} width - * @param {number} height - * @returns {Surface} - */ function textSurface(text, width, height) { return parseAnsiToSurface(text, Math.max(1, width), Math.max(1, height)); } @@ -115,9 +82,10 @@ function renderHeaderSurface(model, ctx) { if (model.filtering || model.filterText) { parts.push(badge(model.filtering ? 'filtering' : `filter ${model.filterText}`, { variant: 'accent', ctx })); } - const selected = model.filtered[model.cursor]; + parts.push(badge(`pane ${model.splitPane.focused === 'a' ? 'entries' : 'inspector'}`, { variant: 'primary', ctx })); + const selected = model.filtered[model.table.focusRow]; if (selected) { - parts.push(badge(`selected ${selected.slug}`, { variant: 'primary', ctx })); + parts.push(badge(`selected ${selected.slug}`, { variant: 'accent', ctx })); } blitInline(surface, { x: 0, @@ -130,25 +98,88 @@ function renderHeaderSurface(model, ctx) { } /** - * Format list rows for the explorer pane. + * Select the current vault entry from table focus. * - * @param {{ slug: string, treeOid: string }} entry - * @param {number} index * @param {DashModel} model - * @returns {string} + * @returns {{ slug: string, treeOid: string } | undefined} */ -function renderListItem(entry, index, model) { - const manifest = model.manifestCache.get(entry.slug); - const m = manifest && (manifest.toJSON ? manifest.toJSON() : manifest); - const prefix = index === model.cursor ? '>' : ' '; - const status = manifest - ? [ - m.encryption ? 'enc' : 'clr', - m.compression ? m.compression.algorithm : 'raw', - m.subManifests?.length ? 'merkle' : 'single', - ].join(' ') - : 'loading'; - return `${prefix} ${entry.slug} ${manifest ? formatStats(manifest) : '...'} ${status}`; +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)); } /** @@ -161,22 +192,25 @@ function renderListItem(entry, index, model) { function renderListPane(model, opts) { const innerWidth = Math.max(1, opts.width - 2); const innerHeight = Math.max(1, opts.height - 2); - const infoLine = model.filtering ? `filter /${model.filterText}\u2588` : model.filterText ? `filter ${model.filterText}` : 'filter all'; - const lines = [clip(infoLine, innerWidth), '']; - const visibleHeight = Math.max(0, innerHeight - lines.length); + const metaLines = [ + clip(model.filtering ? `filter /${model.filterText}\u2588` : model.filterText ? `filter ${model.filterText}` : 'filter all', innerWidth), + clip(`${model.filtered.length} assets focus row ${model.table.rows.length ? model.table.focusRow + 1 : 0}`, innerWidth), + ]; + const tableHeight = Math.max(1, innerHeight - metaLines.length); - if (model.filtered.length === 0) { - lines.push(model.status === 'loading' ? 'Loading...' : model.error ? `Error: ${model.error}` : 'No entries'); + if (model.table.rows.length === 0) { + metaLines.push(model.status === 'loading' ? 'Loading...' : model.error ? `Error: ${model.error}` : 'No entries'); } else { - const { start, end } = visibleRange(model.cursor, model.filtered.length, Math.max(1, visibleHeight)); - for (let i = start; i < end; i++) { - lines.push(clip(renderListItem(model.filtered[i], i, model), innerWidth)); - } + const tableText = navigableTable(tableViewState(model, { width: innerWidth, height: tableHeight }), { + ctx: opts.ctx, + focusIndicator: model.splitPane.focused === 'a' ? '▸' : '·', + }); + metaLines.push(tableText); } - return boxV3(textSurface(lines.join('\n'), innerWidth, innerHeight), { + return boxV3(textSurface(metaLines.join('\n'), innerWidth, innerHeight), { ctx: opts.ctx, - title: 'Entries', + title: model.splitPane.focused === 'a' ? 'Entries *' : 'Entries', width: opts.width, }); } @@ -192,11 +226,15 @@ function renderDetailPane(model, opts) { const innerWidth = Math.max(1, opts.width - 2); const innerHeight = Math.max(1, opts.height - 2); const content = createSurface(innerWidth, innerHeight); - const entry = model.filtered[model.cursor]; + 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: 'Inspector', width: opts.width }); + return boxV3(content, { + ctx: opts.ctx, + title: model.splitPane.focused === 'b' ? 'Inspector *' : 'Inspector', + width: opts.width, + }); } const manifest = model.manifestCache.get(entry.slug); @@ -209,7 +247,11 @@ function renderDetailPane(model, opts) { if (!manifest) { const loadingText = entry.slug === model.loadingSlug ? 'Loading manifest...' : 'Manifest not loaded yet.'; content.blit(textSurface(loadingText, innerWidth, Math.max(1, innerHeight - 3)), 0, 3); - return boxV3(content, { ctx: opts.ctx, title: 'Inspector', width: opts.width }); + return boxV3(content, { + ctx: opts.ctx, + title: model.splitPane.focused === 'b' ? 'Inspector *' : 'Inspector', + width: opts.width, + }); } const manifestBody = renderManifestView({ manifest, ctx: opts.ctx }); @@ -219,7 +261,11 @@ function renderDetailPane(model, opts) { const bodyHeight = Math.max(1, innerHeight - bodyTop); content.blit(manifestSurface, 0, bodyTop, 0, model.detailScroll, innerWidth, bodyHeight); - return boxV3(content, { ctx: opts.ctx, title: 'Inspector', width: opts.width }); + return boxV3(content, { + ctx: opts.ctx, + title: model.splitPane.focused === 'b' ? 'Inspector *' : 'Inspector', + width: opts.width, + }); } /** @@ -232,9 +278,10 @@ function renderDetailPane(model, opts) { function renderFooterSurface(ctx, width) { const lines = [ '─'.repeat(Math.max(1, width)), - `${kbd('j/k', { ctx })} move ${kbd('enter', { ctx })} inspect ${kbd('/', { ctx })} filter ${kbd('J/K', { ctx })} scroll ${kbd('q', { ctx })} quit`, + `${kbd('j/k', { ctx })} rows ${kbd('d/u', { ctx })} page ${kbd('J/K', { ctx })} scroll ${kbd('enter', { ctx })} inspect`, + `${kbd('tab', { ctx })} pane ${kbd('H/L', { ctx })} resize ${kbd('q', { ctx })} quit`, ]; - return textSurface(lines.join('\n'), width, 2); + return textSurface(lines.join('\n'), width, 3); } /** @@ -245,13 +292,19 @@ function renderFooterSurface(ctx, width) { * @param {{ top: number, height: number, screen: Surface }} options */ function renderBody(model, deps, options) { - const gap = model.columns >= 72 ? 1 : 0; - const listWidth = Math.max(24, Math.min(Math.floor(model.columns * 0.37), model.columns - 28 - gap)); - const detailWidth = Math.max(24, model.columns - listWidth - gap); - const listPane = renderListPane(model, { width: listWidth, height: options.height, ctx: deps.ctx }); - const detailPane = renderDetailPane(model, { width: detailWidth, height: options.height, ctx: deps.ctx }); - options.screen.blit(listPane, 0, options.top); - options.screen.blit(detailPane, listWidth + gap, options.top); + 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); } /** diff --git a/bin/ui/dashboard.js b/bin/ui/dashboard.js index 6b5cf8d..a5ec591 100644 --- a/bin/ui/dashboard.js +++ b/bin/ui/dashboard.js @@ -2,7 +2,11 @@ * TEA app shell for the vault dashboard. */ -import { run, quit, createKeyMap } from '@flyingrobots/bijou-tui'; +import { + run, quit, createKeyMap, + createNavigableTableState, navTableFocusNext, navTableFocusPrev, navTablePageDown, navTablePageUp, + createSplitPaneState, splitPaneFocusNext, splitPaneResizeBy, +} from '@flyingrobots/bijou-tui'; import { loadEntriesCmd, loadManifestCmd } from './dashboard-cmds.js'; import { createCliTuiContext, detectCliTuiMode } from './context.js'; import { renderDashboard } from './dashboard-view.js'; @@ -13,6 +17,8 @@ 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('../../index.js').default} ContentAddressableStore * @typedef {import('../../src/domain/value-objects/Manifest.js').default} Manifest * @typedef {{ slug: string, treeOid: string }} VaultEntry @@ -21,9 +27,12 @@ import { renderDashboard } from './dashboard-view.js'; /** * @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 } * } DashAction */ @@ -41,7 +50,6 @@ import { renderDashboard } from './dashboard-view.js'; * @property {number} rows * @property {VaultEntry[]} entries * @property {VaultEntry[]} filtered - * @property {number} cursor * @property {string} filterText * @property {boolean} filtering * @property {any} metadata @@ -49,6 +57,8 @@ import { renderDashboard } from './dashboard-view.js'; * @property {string | null} loadingSlug * @property {number} detailScroll * @property {string | null} error + * @property {NavigableTableState} table + * @property {SplitPaneState} splitPane */ /** @@ -70,12 +80,135 @@ 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('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 = 3; +const DASH_FOOTER_ROWS = 3; +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; + +/** + * 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`; +} + +/** + * Table viewport height based on the full dashboard frame. + * + * @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 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 { + ...table, + rows, + height, + focusRow, + scrollY, + }; +} + /** * Create the initial model. * @@ -83,13 +216,17 @@ export function createKeyBindings() { * @returns {DashModel} */ function createInitModel(ctx) { + const table = createNavigableTableState({ + columns: TABLE_COLUMNS, + rows: [], + height: tableHeight(ctx.runtime.rows ?? 24), + }); return { status: 'loading', columns: ctx.runtime.columns ?? 80, rows: ctx.runtime.rows ?? 24, entries: [], filtered: [], - cursor: 0, filterText: '', filtering: false, metadata: null, @@ -97,6 +234,8 @@ function createInitModel(ctx) { loadingSlug: null, detailScroll: 0, error: null, + table, + splitPane: createSplitPaneState({ ratio: 0.37, focused: 'a' }), }; } @@ -122,15 +261,20 @@ function applyFilter(entries, text) { */ 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 table = syncTable(model.table, { + entries: filtered, + manifestCache: model.manifestCache, + rows: model.rows, + }); const cmds = /** @type {DashCmd[]} */ (msg.entries.map((/** @type {VaultEntry} */ e) => loadManifestCmd(cas, e.slug, e.treeOid))); return [{ ...model, status: 'ready', entries: msg.entries, filtered, - cursor, metadata: msg.metadata, + loadingSlug: null, + table, }, cmds]; } @@ -144,7 +288,17 @@ function handleLoadedEntries(msg, model, cas) { function handleLoadedManifest(msg, model) { const cache = new Map(model.manifestCache); cache.set(msg.slug, msg.manifest); - return [{ ...model, manifestCache: cache }, []]; + 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, + }, []]; } /** @@ -155,9 +309,20 @@ function handleLoadedManifest(msg, model) { * @returns {[DashModel, DashCmd[]]} */ 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 }, []]; + 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 }, []]; } /** @@ -174,12 +339,26 @@ function handleFilterKey(msg, 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 }, []]; + 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); - return [{ ...model, filterText: text, filtered, cursor: 0 }, []]; + 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, []]; } @@ -192,12 +371,19 @@ function handleFilterKey(msg, model) { * @returns {[DashModel, DashCmd[]]} */ function handleSelect(model, deps) { - const entry = model.filtered[model.cursor]; - if (!entry || model.manifestCache.has(entry.slug)) { + 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, entry.slug, entry.treeOid)); - return [{ ...model, loadingSlug: entry.slug }, [cmd]]; + return [{ + ...model, + loadingSlug: entry.slug, + splitPane: { ...model.splitPane, focused: 'b' }, + }, [cmd]]; } /** @@ -211,13 +397,35 @@ function handleSelect(model, deps) { function handleAction(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 === 'filter-start') { - return [{ ...model, filtering: true, filterText: '', filtered: model.entries, cursor: 0 }, []]; + 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 }, []]; } 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') { + const delta = model.splitPane.focused === 'a' ? action.delta : -action.delta; + const splitPane = splitPaneResizeBy(model.splitPane, delta, { + total: model.columns, + minA: SPLIT_MIN_LIST_WIDTH, + minB: SPLIT_MIN_DETAIL_WIDTH, + dividerSize: SPLIT_DIVIDER_SIZE, + }); + return [{ ...model, splitPane }, []]; + } if (action.type === 'select') { return handleSelect(model, deps); } return [model, []]; } @@ -235,7 +443,7 @@ function handleAppMsg(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, loadingSlug: model.loadingSlug === msg.slug ? null : model.loadingSlug }, []]; } return [{ ...model, status: 'error', error: msg.error }, []]; } @@ -260,7 +468,12 @@ function handleUpdate(msg, model, deps) { return [model, []]; } if (msg.type === 'resize') { - return [{ ...model, columns: msg.columns, rows: msg.rows }, []]; + const table = syncTable(model.table, { + entries: model.filtered, + manifestCache: model.manifestCache, + rows: msg.rows, + }); + return [{ ...model, columns: msg.columns, rows: msg.rows, table }, []]; } return handleAppMsg(/** @type {DashMsg} */ (msg), model, deps.cas); } diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index 82dea85..ba88784 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -1,5 +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', () => ({ @@ -25,21 +26,56 @@ 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 - 9), + }), + ...(options.overrides || {}), + }; +} + function makeModel(overrides = {}) { + const manifestCache = overrides.manifestCache || new Map(); + const filtered = overrides.filtered || overrides.entries || []; + const rows = overrides.rows || 24; return { status: 'ready', columns: 80, rows: 24, entries: [], filtered: [], - cursor: 0, filterText: '', filtering: false, metadata: null, - manifestCache: new Map(), + manifestCache, loadingSlug: null, detailScroll: 0, error: null, + table: makeTable(filtered, { rows, manifestCache }), + splitPane: createSplitPaneState({ ratio: 0.37, focused: 'a' }), ...overrides, }; } @@ -53,26 +89,37 @@ const entries = [ { slug: 'bravo', treeOid: 'bbb222' }, ]; -describe('dashboard init and navigation', () => { +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', () => { @@ -86,12 +133,27 @@ 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(30); + }); + + 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); }); }); @@ -102,6 +164,7 @@ describe('dashboard data loading', () => { 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); }); @@ -118,6 +181,7 @@ describe('dashboard data loading', () => { 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'); }); @@ -138,14 +202,14 @@ describe('dashboard data loading', () => { }); }); -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', () => { @@ -154,7 +218,9 @@ describe('dashboard edge cases', () => { expect(next.error).toBe('boom'); expect(next.status).toBe('error'); }); +}); +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 }); @@ -163,12 +229,16 @@ describe('dashboard edge cases', () => { expect(next.error).toBeNull(); }); - 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 model = makeModel({ + status: 'loading', + filterText: 'al', + table: makeTable([], { overrides: { focusRow: 5 } }), + }); const msg = { type: 'loaded-entries', entries, metadata: null }; const [next] = app.update(msg, model); - expect(next.cursor).toBe(0); + expect(next.table.focusRow).toBe(0); expect(next.filtered).toHaveLength(1); }); @@ -182,9 +252,10 @@ 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); }); }); @@ -208,6 +279,7 @@ describe('dashboard view rendering', () => { const app = createDashboardApp(deps); const model = makeModel({ entries, filtered: entries }); const rendered = renderView(app.view(model), deps.ctx); + expect(rendered).toContain('Slug'); expect(rendered).toContain('alpha'); expect(rendered).toContain('bravo'); }); @@ -237,6 +309,8 @@ describe('dashboard explorer details', () => { const model = makeModel(); const rendered = renderView(app.view(model), deps.ctx); expect(rendered).toContain('inspect'); + expect(rendered).toContain('resize'); + expect(rendered).toContain('pane'); expect(rendered).toContain('quit'); }); @@ -251,6 +325,14 @@ describe('dashboard explorer details', () => { }); const rendered = renderView(app.view(model), deps.ctx); expect(rendered).toContain('asset alpha'); - expect(rendered).toContain('Chunks (1)'); + 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('Inspector *'); }); }); From 768a272ad74e52eb96d01881e23be9e578f959b1 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 18 Mar 2026 05:14:03 -0700 Subject: [PATCH 04/22] feat(ui): add dashboard command palette drawers --- bin/ui/dashboard-cmds.js | 38 +++ bin/ui/dashboard-view.js | 207 ++++++++++++++- bin/ui/dashboard.js | 453 ++++++++++++++++++++++++++++++-- test/unit/cli/dashboard.test.js | 138 +++++++++- 4 files changed, 798 insertions(+), 38 deletions(-) diff --git a/bin/ui/dashboard-cmds.js b/bin/ui/dashboard-cmds.js index 8a88a06..e72c040 100644 --- a/bin/ui/dashboard-cmds.js +++ b/bin/ui/dashboard-cmds.js @@ -2,6 +2,8 @@ * Async command factories for the vault dashboard. */ +import { buildVaultStats, inspectVaultHealth } from './vault-report.js'; + /** @typedef {import('../../index.js').default} ContentAddressableStore */ /** @@ -40,3 +42,39 @@ export function loadManifestCmd(cas, slug, treeOid) { } }; } + +/** + * Load aggregate vault stats for the current vault. + * + * @param {ContentAddressableStore} cas + */ +export function loadStatsCmd(cas) { + return async () => { + try { + const entries = await cas.listVault(); + 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) }); + } catch (/** @type {any} */ err) { + return /** @type {const} */ ({ type: 'load-error', source: 'stats', error: /** @type {Error} */ (err).message }); + } + }; +} + +/** + * Load the doctor report for the current vault. + * + * @param {ContentAddressableStore} cas + */ +export function loadDoctorCmd(cas) { + return async () => { + try { + const report = await inspectVaultHealth(cas); + return /** @type {const} */ ({ type: 'loaded-doctor', report }); + } catch (/** @type {any} */ err) { + return /** @type {const} */ ({ type: 'load-error', source: 'doctor', error: /** @type {Error} */ (err).message }); + } + }; +} diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index c4ba272..990ba91 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -3,7 +3,8 @@ */ import { badge, boxV3, createSurface, parseAnsiToSurface, kbd } from '@flyingrobots/bijou'; -import { navigableTable, splitPaneLayout } from '@flyingrobots/bijou-tui'; +import { commandPalette, navigableTable, splitPaneLayout } from '@flyingrobots/bijou-tui'; +import { renderDoctorReport, renderVaultStats } from './vault-report.js'; import { renderManifestView } from './manifest-view.js'; /** @@ -63,16 +64,13 @@ function blitInline(target, options) { } /** - * Render the header surface. + * Build header badges that summarize current explorer state. * * @param {DashModel} model * @param {BijouContext} ctx - * @returns {Surface} + * @returns {(Surface | string)[]} */ -function renderHeaderSurface(model, ctx) { - const surface = createSurface(Math.max(1, model.columns), 3); - surface.blit(textSurface('git-cas vault explorer', surface.width, 1), 0, 0); - +function headerParts(model, ctx) { const parts = [ badge(`${model.filtered.length}/${model.entries.length || model.filtered.length} visible`, { variant: 'info', ctx }), ]; @@ -83,20 +81,177 @@ function renderHeaderSurface(model, ctx) { parts.push(badge(model.filtering ? 'filtering' : `filter ${model.filterText}`, { variant: 'accent', ctx })); } parts.push(badge(`pane ${model.splitPane.focused === 'a' ? 'entries' : 'inspector'}`, { variant: 'primary', ctx })); + appendSelectionBadges(parts, model, ctx); + return ['refs/cas/vault', ...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) { parts.push(badge(`selected ${selected.slug}`, { variant: 'accent', ctx })); } + if (model.activeDrawer) { + parts.push(badge(`${model.activeDrawer} drawer`, { variant: 'info', ctx })); + } + if (model.palette) { + parts.push(badge('palette', { variant: 'warning', ctx })); + } +} + +/** + * Render the header surface. + * + * @param {DashModel} model + * @param {BijouContext} ctx + * @returns {Surface} + */ +function renderHeaderSurface(model, ctx) { + const surface = createSurface(Math.max(1, model.columns), 3); + surface.blit(textSurface('git-cas vault explorer', surface.width, 1), 0, 0); blitInline(surface, { x: 0, y: 1, - parts: ['refs/cas/vault', ...parts], + parts: headerParts(model, ctx), maxWidth: surface.width, }); surface.blit(textSurface('─'.repeat(surface.width), surface.width, 1), 0, 2); 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, + }); +} + +/** + * Build drawer copy for the stats overlay. + * + * @param {DashModel} model + * @returns {string} + */ +function statsDrawerBody(model) { + if (model.statsStatus === 'loading') { + return 'Loading vault stats...'; + } + if (model.statsStatus === 'error') { + return `Failed to load stats\n\n${model.statsError ?? 'unknown error'}`; + } + return model.statsReport + ? renderVaultStats(model.statsReport) + : 'Stats have not been loaded yet.'; +} + +/** + * Build drawer copy for the doctor overlay. + * + * @param {DashModel} model + * @returns {string} + */ +function doctorDrawerBody(model) { + 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 model.doctorReport + ? 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 Stats', + body: statsDrawerBody(model), + 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: 'Doctor Report', + body: doctorDrawerBody(model), + width: Math.max(32, Math.min(56, opts.width - 2)), + height: Math.max(8, opts.height), + ctx: opts.ctx, + }); +} + +/** + * 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) { + return null; + } + return model.activeDrawer === 'stats' + ? renderStatsDrawer(model, opts) + : renderDoctorDrawer(model, opts); +} + +/** + * 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 Palette', + body, + width, + height: Math.min(opts.height, model.palette.height + 3), + ctx: opts.ctx, + }); +} + /** * Select the current vault entry from table focus. * @@ -279,9 +434,10 @@ function renderFooterSurface(ctx, width) { const lines = [ '─'.repeat(Math.max(1, width)), `${kbd('j/k', { ctx })} rows ${kbd('d/u', { ctx })} page ${kbd('J/K', { ctx })} scroll ${kbd('enter', { ctx })} inspect`, - `${kbd('tab', { ctx })} pane ${kbd('H/L', { ctx })} resize ${kbd('q', { ctx })} quit`, + `${kbd('tab', { ctx })} pane ${kbd('H/L', { ctx })} resize ${kbd('ctrl+p', { ctx })} palette`, + `${kbd('s', { ctx })} stats ${kbd('g', { ctx })} doctor ${kbd('esc', { ctx })} close ${kbd('q', { ctx })} quit`, ]; - return textSurface(lines.join('\n'), width, 3); + return textSurface(lines.join('\n'), width, 4); } /** @@ -307,6 +463,36 @@ function renderBody(model, deps, options) { 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); + } +} + /** * Render the full dashboard explorer layout. * @@ -325,6 +511,7 @@ export function renderDashboard(model, deps) { 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 a5ec591..30f5003 100644 --- a/bin/ui/dashboard.js +++ b/bin/ui/dashboard.js @@ -6,8 +6,9 @@ import { run, quit, createKeyMap, createNavigableTableState, navTableFocusNext, navTableFocusPrev, navTablePageDown, navTablePageUp, createSplitPaneState, splitPaneFocusNext, splitPaneResizeBy, + createCommandPaletteState, cpFilter, cpFocusNext, cpFocusPrev, cpPageDown, cpPageUp, cpSelectedItem, commandPaletteKeyMap, } from '@flyingrobots/bijou-tui'; -import { loadEntriesCmd, loadManifestCmd } from './dashboard-cmds.js'; +import { loadEntriesCmd, loadManifestCmd, loadStatsCmd, loadDoctorCmd } from './dashboard-cmds.js'; import { createCliTuiContext, detectCliTuiMode } from './context.js'; import { renderDashboard } from './dashboard-view.js'; @@ -19,9 +20,11 @@ import { renderDashboard } from './dashboard-view.js'; * @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 {{ slug: string, treeOid: string }} VaultEntry + * @typedef {'idle' | 'loading' | 'ready' | 'error'} LoadState */ /** @@ -33,12 +36,28 @@ import { renderDashboard } from './dashboard-view.js'; * | { type: 'scroll-detail', delta: number } * | { type: 'split-focus' } * | { type: 'split-resize', delta: number } + * | { type: 'open-palette' } + * | { type: 'open-stats' } + * | { type: 'open-doctor' } + * | { type: 'overlay-close' } * } DashAction */ +/** + * @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 } * | { type: 'loaded-manifest', slug: string, manifest: Manifest } + * | { type: 'loaded-stats', stats: any } + * | { type: 'loaded-doctor', report: any } * | { type: 'load-error', source: string, slug?: string, error: string } * } DashMsg */ @@ -59,6 +78,14 @@ import { renderDashboard } from './dashboard-view.js'; * @property {string | null} error * @property {NavigableTableState} table * @property {SplitPaneState} splitPane + * @property {CommandPaletteState | null} palette + * @property {'stats' | 'doctor' | null} activeDrawer + * @property {LoadState} statsStatus + * @property {any | null} statsReport + * @property {string | null} statsError + * @property {LoadState} doctorStatus + * @property {any | null} doctorReport + * @property {string | null} doctorError */ /** @@ -89,6 +116,11 @@ export function createKeyBindings() { .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('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 }); } @@ -103,13 +135,59 @@ const TABLE_COLUMNS = [ ]; const DASH_HEADER_ROWS = 3; -const DASH_FOOTER_ROWS = 3; +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 PALETTE_ITEMS = [ + { + id: 'stats', + label: 'Open Vault Stats', + description: 'Logical size, dedup ratio, encryption 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 Drawer', + description: '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. * @@ -209,6 +287,37 @@ function syncTable(table, updates = {}) { }; } +/** + * 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 setPalette(model, palette) { + return [{ ...model, palette }, []]; +} + /** * Create the initial model. * @@ -236,6 +345,14 @@ function createInitModel(ctx) { error: null, table, splitPane: createSplitPaneState({ ratio: 0.37, focused: 'a' }), + palette: null, + activeDrawer: null, + statsStatus: 'idle', + statsReport: null, + statsError: null, + doctorStatus: 'idle', + doctorReport: null, + doctorError: null, }; } @@ -387,27 +504,260 @@ function handleSelect(model, deps) { } /** - * Handle keymap actions. + * Open the stats drawer and trigger a load when needed. * - * @param {DashAction} action * @param {DashModel} model * @param {DashDeps} deps * @returns {[DashModel, DashCmd[]]} */ -function handleAction(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); } +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))]]; +} + +/** + * 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))]]; +} + +/** + * Close the command palette or active drawer, 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 }, []]; + } + return [model, []]; +} + +/** + * 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 }, []]; + } + if (item.id === 'stats') { + return openStatsDrawer(model, deps); + } + if (item.id === 'doctor') { + return openDoctorDrawer(model, deps); + } + if (item.id === 'focus-entries') { + return [{ + ...model, + palette: null, + splitPane: { ...model.splitPane, focused: 'a' }, + }, []]; + } + if (item.id === 'focus-inspector') { + return [{ + ...model, + palette: null, + splitPane: { ...model.splitPane, focused: 'b' }, + }, []]; + } + if (item.id === 'close-drawer') { + return [{ + ...model, + palette: null, + activeDrawer: null, + }, []]; + } + 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) { + if (action.type === 'open-palette') { + return setPalette(model, createPalette(model.rows)); + } + if (action.type === 'open-stats') { + return openStatsDrawer(model, deps); + } + if (action.type === 'open-doctor') { + return openDoctorDrawer(model, deps); + } + if (action.type === 'overlay-close') { + return closeOverlay(model); + } + return 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') { - 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 }, []]; + return startFilter(model); } if (action.type === 'scroll-detail') { const scroll = Math.max(0, model.detailScroll + action.delta); @@ -417,16 +767,28 @@ function handleAction(action, model, deps) { return [{ ...model, splitPane: splitPaneFocusNext(model.splitPane) }, []]; } if (action.type === 'split-resize') { - const delta = model.splitPane.focused === 'a' ? action.delta : -action.delta; - const splitPane = splitPaneResizeBy(model.splitPane, delta, { - total: model.columns, - minA: SPLIT_MIN_LIST_WIDTH, - minB: SPLIT_MIN_DETAIL_WIDTH, - dividerSize: SPLIT_DIVIDER_SIZE, - }); - return [{ ...model, splitPane }, []]; + return resizeSplitPane(model, action.delta); } + return null; +} + +/** + * Handle keymap actions. + * + * @param {DashAction} action + * @param {DashModel} model + * @param {DashDeps} deps + * @returns {[DashModel, DashCmd[]]} + */ +function handleAction(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); } + const overlayResult = handleOverlayAction(action, model, deps); + if (overlayResult) { return overlayResult; } + const layoutResult = handleLayoutAction(action, model); + if (layoutResult) { return layoutResult; } return [model, []]; } @@ -441,10 +803,40 @@ function handleAction(action, model, deps) { 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 === 'loaded-stats') { + return [{ + ...model, + statsStatus: 'ready', + statsReport: msg.stats, + statsError: null, + }, []]; + } + if (msg.type === 'loaded-doctor') { + return [{ + ...model, + doctorStatus: 'ready', + doctorReport: msg.report, + doctorError: null, + }, []]; + } if (msg.type === 'load-error') { if (msg.source === 'manifest') { return [{ ...model, loadingSlug: model.loadingSlug === msg.slug ? null : model.loadingSlug }, []]; } + if (msg.source === 'stats') { + return [{ + ...model, + statsStatus: 'error', + statsError: msg.error, + }, []]; + } + if (msg.source === 'doctor') { + return [{ + ...model, + doctorStatus: 'error', + doctorError: msg.error, + }, []]; + } return [{ ...model, status: 'error', error: msg.error }, []]; } return [model, []]; @@ -459,6 +851,9 @@ function handleAppMsg(msg, model, cas) { * @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); } @@ -473,7 +868,13 @@ function handleUpdate(msg, model, deps) { manifestCache: model.manifestCache, rows: msg.rows, }); - return [{ ...model, columns: msg.columns, rows: msg.rows, table }, []]; + const palette = model.palette + ? { + ...model.palette, + height: paletteHeight(msg.rows), + } + : null; + return [{ ...model, columns: msg.columns, rows: msg.rows, table, palette }, []]; } return handleAppMsg(/** @type {DashMsg} */ (msg), model, deps.cas); } diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index ba88784..530736c 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -76,6 +76,14 @@ function makeModel(overrides = {}) { error: null, table: makeTable(filtered, { rows, manifestCache }), splitPane: createSplitPaneState({ ratio: 0.37, focused: 'a' }), + palette: null, + activeDrawer: null, + statsStatus: 'idle', + statsReport: null, + statsError: null, + doctorStatus: 'idle', + doctorReport: null, + doctorError: null, ...overrides, }; } @@ -89,6 +97,37 @@ const entries = [ { slug: 'bravo', treeOid: 'bbb222' }, ]; +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' }], + }; +} + describe('dashboard initialization', () => { it('init returns loading model with one cmd', () => { const app = createDashboardApp(makeDeps()); @@ -141,7 +180,7 @@ describe('dashboard pane controls', () => { 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(30); + expect(next.table.height).toBe(29); }); it('tab toggles the focused pane', () => { @@ -157,6 +196,42 @@ describe('dashboard pane controls', () => { }); }); +describe('dashboard overlays', () => { + 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 Palette'); + expect(rendered).toContain('Open Vault 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 [next, cmds] = app.update(keyMsg('enter'), withPalette); + expect(next.palette).toBeNull(); + expect(next.activeDrawer).toBe('stats'); + expect(next.statsStatus).toBe('loading'); + expect(cmds).toHaveLength(1); + }); + + 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 data loading', () => { it('loaded-entries sets entries and fires manifest loads', () => { const app = createDashboardApp(makeDeps()); @@ -174,7 +249,29 @@ describe('dashboard data loading', () => { const [next] = app.update({ type: 'loaded-manifest', slug: 'alpha', manifest }, makeModel()); expect(next.manifestCache.get('alpha')).toBe(manifest); }); +}); + +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 }, 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 }, makeModel({ activeDrawer: 'doctor', doctorStatus: 'loading' })); + expect(next.doctorStatus).toBe('ready'); + expect(next.doctorReport).toEqual(report); + expect(next.doctorError).toBeNull(); + }); +}); +describe('dashboard filter mode', () => { it('filter mode captures characters and filters entries', () => { const app = createDashboardApp(makeDeps()); const model = makeModel({ filtering: true, entries, filtered: entries }); @@ -302,7 +399,7 @@ describe('dashboard view rendering', () => { }); }); -describe('dashboard explorer details', () => { +describe('dashboard footer and inspector rendering', () => { it('renders footer keybinding hints', () => { const deps = makeDeps(); const app = createDashboardApp(deps); @@ -311,6 +408,10 @@ describe('dashboard explorer details', () => { 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('close'); expect(rendered).toContain('quit'); }); @@ -336,3 +437,36 @@ describe('dashboard explorer details', () => { expect(rendered).toContain('Inspector *'); }); }); + +describe('dashboard 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 Stats'); + expect(rendered).toContain('dedup-ratio'); + }); + + 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('Doctor Report'); + expect(rendered).toContain('Loading doctor report'); + }); + + 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 Palette'); + expect(rendered).toContain('Open Vault Stats'); + }); +}); From 9b0580bc64d7b3603ff1408764fdc897095f1e54 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 18 Mar 2026 05:23:32 -0700 Subject: [PATCH 05/22] feat(ui): show dashboard working directory --- bin/git-cas.js | 2 +- bin/ui/dashboard-view.js | 35 ++++++++++++++++++++++++++------- bin/ui/dashboard.js | 6 ++++-- test/unit/cli/dashboard.test.js | 15 ++++++++++---- 4 files changed, 44 insertions(+), 14 deletions(-) diff --git a/bin/git-cas.js b/bin/git-cas.js index 333e838..fe41caa 100755 --- a/bin/git-cas.js +++ b/bin/git-cas.js @@ -675,7 +675,7 @@ vault .action(runAction(async (/** @type {Record} */ opts) => { const cas = createCas(opts.cwd); const { launchDashboard } = await import('./ui/dashboard.js'); - await launchDashboard(cas); + await launchDashboard(cas, { cwd: path.resolve(opts.cwd) }); }, getJson)); // --------------------------------------------------------------------------- diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index 990ba91..78e1a3b 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -27,6 +27,26 @@ function clip(text, width) { return width > 0 ? text.slice(0, width) : ''; } +/** + * Clip long paths from the left so the most specific 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))}`; +} + /** * Compute visible window for cursor scrolling. * @@ -109,19 +129,20 @@ function appendSelectionBadges(parts, model, ctx) { * Render the header surface. * * @param {DashModel} model - * @param {BijouContext} ctx + * @param {DashDeps} deps * @returns {Surface} */ -function renderHeaderSurface(model, ctx) { - const surface = createSurface(Math.max(1, model.columns), 3); +function renderHeaderSurface(model, deps) { + const surface = createSurface(Math.max(1, model.columns), 4); surface.blit(textSurface('git-cas vault explorer', surface.width, 1), 0, 0); + surface.blit(textSurface(tailClip(`cwd ${deps.cwdLabel ?? '-'}`, surface.width), surface.width, 1), 0, 1); blitInline(surface, { x: 0, - y: 1, - parts: headerParts(model, ctx), + y: 2, + parts: headerParts(model, deps.ctx), maxWidth: surface.width, }); - surface.blit(textSurface('─'.repeat(surface.width), surface.width, 1), 0, 2); + surface.blit(textSurface('─'.repeat(surface.width), surface.width, 1), 0, 3); return surface; } @@ -504,7 +525,7 @@ export function renderDashboard(model, deps) { const width = Math.max(1, model.columns); const height = Math.max(1, model.rows); const screen = createSurface(width, height); - const header = renderHeaderSurface(model, deps.ctx); + const header = renderHeaderSurface(model, deps); const footer = renderFooterSurface(deps.ctx, width); const bodyTop = header.height; const bodyHeight = Math.max(1, height - header.height - footer.height); diff --git a/bin/ui/dashboard.js b/bin/ui/dashboard.js index 30f5003..eb9deb8 100644 --- a/bin/ui/dashboard.js +++ b/bin/ui/dashboard.js @@ -93,6 +93,7 @@ import { renderDashboard } from './dashboard-view.js'; * @property {DashKeyMap} keyMap * @property {ContentAddressableStore} cas * @property {BijouContext} ctx + * @property {string | undefined} [cwdLabel] */ /** @@ -134,7 +135,7 @@ const TABLE_COLUMNS = [ { header: 'Profile', width: 12 }, ]; -const DASH_HEADER_ROWS = 3; +const DASH_HEADER_ROWS = 4; const DASH_FOOTER_ROWS = 4; const PANE_BORDER_ROWS = 2; const LIST_META_ROWS = 2; @@ -933,6 +934,7 @@ function normalizeLaunchContext(ctx) { * @param {{ * ctx?: BijouContext, * runApp?: typeof run, + * cwd?: string, * output?: Pick, * }} [options] */ @@ -942,7 +944,7 @@ export async function launchDashboard(cas, options = {}) { return printStaticList(cas, options.output); } const keyMap = createKeyBindings(); - const deps = { keyMap, cas, ctx }; + const deps = { keyMap, cas, ctx, cwdLabel: options.cwd }; const runApp = options.runApp || run; return runApp(createDashboardApp(deps), { ctx }); } diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index 530736c..1c09858 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -18,8 +18,14 @@ 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', + ...overrides, + }; } function renderView(output, ctx) { @@ -51,7 +57,7 @@ function makeTable(filtered = [], options = {}) { ...createNavigableTableState({ columns: [{ header: 'Slug', width: 20 }], rows: buildTableRows(filtered, manifestCache), - height: Math.max(1, rows - 9), + height: Math.max(1, rows - 12), }), ...(options.overrides || {}), }; @@ -180,7 +186,7 @@ describe('dashboard pane controls', () => { 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(29); + expect(next.table.height).toBe(28); }); it('tab toggles the focused pane', () => { @@ -367,6 +373,7 @@ describe('dashboard view rendering', () => { expect(output.width).toBe(model.columns); const rendered = renderView(output, deps.ctx); expect(rendered).toContain('git-cas vault explorer'); + expect(rendered).toContain('cwd /tmp/git-cas-fixture'); expect(rendered).toContain('Entries'); expect(rendered).toContain('Inspector'); }); From f7516b2bb501f41fee3acdcd2c38dc18b9fb8511 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 18 Mar 2026 05:55:40 -0700 Subject: [PATCH 06/22] feat(ui): add ref and oid source inspection --- bin/git-cas.js | 14 +- bin/ui/dashboard-cmds.js | 254 ++++++++++++++++++++++++- bin/ui/dashboard-view.js | 29 ++- bin/ui/dashboard.js | 27 +-- test/unit/cli/dashboard-cmds.test.js | 124 ++++++++++++ test/unit/cli/dashboard.launch.test.js | 17 ++ test/unit/cli/dashboard.test.js | 10 +- 7 files changed, 445 insertions(+), 30 deletions(-) create mode 100644 test/unit/cli/dashboard-cmds.test.js diff --git a/bin/git-cas.js b/bin/git-cas.js index fe41caa..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, { cwd: path.resolve(opts.cwd) }); + 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 e72c040..d002369 100644 --- a/bin/ui/dashboard-cmds.js +++ b/bin/ui/dashboard-cmds.js @@ -5,19 +5,247 @@ 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 */ + +/** + * 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, + }; +} + +/** + * 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 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) { + if (source.type === 'oid') { + return singleEntrySource(`oid:${shortOid(source.treeOid)}`, source.treeOid); + } + + const [service, vault] = await Promise.all([ + cas.getService(), + cas.getVaultService(), + ]); + const resolvedOid = await vault.ref.resolveRef(source.ref); + + if (await canReadManifest(cas, resolvedOid)) { + return singleEntrySource(source.ref, resolvedOid); + } + + const treeOid = await tryResolveTree(vault.ref, resolvedOid); + if (treeOid && await canReadManifest(cas, treeOid)) { + return singleEntrySource(`${source.ref}^{tree}`, treeOid); + } + + const indexed = extractJsonEntries(await tryReadJsonBlob(service.persistence, resolvedOid), source.ref); + if (indexed.length > 0) { + return { entries: indexed, metadata: null }; + } + + const hintedTreeOid = await tryReadManifestHint(service.persistence, resolvedOid); + if (hintedTreeOid) { + return singleEntrySource(source.ref, 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 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); +} /** * 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(), - ]); + const { entries, metadata } = await readSourceEntries(cas, source); return /** @type {const} */ ({ type: 'loaded-entries', entries, metadata }); } catch (/** @type {any} */ err) { return /** @type {const} */ ({ type: 'load-error', source: 'entries', error: /** @type {Error} */ (err).message }); @@ -47,11 +275,11 @@ export function loadManifestCmd(cas, slug, treeOid) { * Load aggregate vault stats for the current vault. * * @param {ContentAddressableStore} cas + * @param {ExplorerEntry[]} entries */ -export function loadStatsCmd(cas) { +export function loadStatsCmd(cas, entries) { return async () => { try { - const entries = await cas.listVault(); const records = await Promise.all(entries.map(async (entry) => ({ ...entry, manifest: await cas.readManifest({ treeOid: entry.treeOid }), @@ -67,10 +295,20 @@ export function loadStatsCmd(cas) { * Load the doctor report for the current vault. * * @param {ContentAddressableStore} cas + * @param {DashSource} [source] + * @param {ExplorerEntry[]} [entries] */ -export function loadDoctorCmd(cas) { +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 }); + } const report = await inspectVaultHealth(cas); return /** @type {const} */ ({ type: 'loaded-doctor', report }); } catch (/** @type {any} */ err) { diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index 78e1a3b..bfabc70 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -10,6 +10,7 @@ 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('@flyingrobots/bijou').Surface} Surface */ @@ -102,7 +103,7 @@ function headerParts(model, ctx) { } parts.push(badge(`pane ${model.splitPane.focused === 'a' ? 'entries' : 'inspector'}`, { variant: 'primary', ctx })); appendSelectionBadges(parts, model, ctx); - return ['refs/cas/vault', ...parts]; + return parts; } /** @@ -125,6 +126,22 @@ function appendSelectionBadges(parts, model, ctx) { } } +/** + * 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}`; +} + /** * Render the header surface. * @@ -134,12 +151,12 @@ function appendSelectionBadges(parts, model, ctx) { */ function renderHeaderSurface(model, deps) { const surface = createSurface(Math.max(1, model.columns), 4); - surface.blit(textSurface('git-cas vault explorer', surface.width, 1), 0, 0); + surface.blit(textSurface('git-cas repository explorer', surface.width, 1), 0, 0); surface.blit(textSurface(tailClip(`cwd ${deps.cwdLabel ?? '-'}`, surface.width), surface.width, 1), 0, 1); blitInline(surface, { x: 0, y: 2, - parts: headerParts(model, deps.ctx), + parts: [sourceLabel(deps.source), ...headerParts(model, deps.ctx)], maxWidth: surface.width, }); surface.blit(textSurface('─'.repeat(surface.width), surface.width, 1), 0, 3); @@ -193,7 +210,9 @@ function doctorDrawerBody(model) { if (model.doctorStatus === 'error') { return `Failed to load doctor report\n\n${model.doctorError ?? 'unknown error'}`; } - return model.doctorReport + return typeof model.doctorReport === 'string' + ? model.doctorReport + : model.doctorReport ? renderDoctorReport(model.doctorReport) : 'Doctor report has not been loaded yet.'; } @@ -207,7 +226,7 @@ function doctorDrawerBody(model) { */ function renderStatsDrawer(model, opts) { return renderOverlayPanel({ - title: 'Vault Stats', + title: 'Source Stats', body: statsDrawerBody(model), width: Math.max(32, Math.min(56, opts.width - 2)), height: Math.max(8, opts.height), diff --git a/bin/ui/dashboard.js b/bin/ui/dashboard.js index eb9deb8..c4791c2 100644 --- a/bin/ui/dashboard.js +++ b/bin/ui/dashboard.js @@ -8,7 +8,7 @@ import { createSplitPaneState, splitPaneFocusNext, splitPaneResizeBy, createCommandPaletteState, cpFilter, cpFocusNext, cpFocusPrev, cpPageDown, cpPageUp, cpSelectedItem, commandPaletteKeyMap, } from '@flyingrobots/bijou-tui'; -import { loadEntriesCmd, loadManifestCmd, loadStatsCmd, loadDoctorCmd } from './dashboard-cmds.js'; +import { loadEntriesCmd, loadManifestCmd, loadStatsCmd, loadDoctorCmd, readSourceEntries } from './dashboard-cmds.js'; import { createCliTuiContext, detectCliTuiMode } from './context.js'; import { renderDashboard } from './dashboard-view.js'; @@ -24,6 +24,7 @@ import { renderDashboard } from './dashboard-view.js'; * @typedef {import('../../index.js').default} ContentAddressableStore * @typedef {import('../../src/domain/value-objects/Manifest.js').default} Manifest * @typedef {{ slug: string, treeOid: string }} VaultEntry + * @typedef {{ type: 'vault' } | { type: 'ref', ref: string } | { type: 'oid', treeOid: string }} DashSource * @typedef {'idle' | 'loading' | 'ready' | 'error'} LoadState */ @@ -94,6 +95,7 @@ import { renderDashboard } from './dashboard-view.js'; * @property {ContentAddressableStore} cas * @property {BijouContext} ctx * @property {string | undefined} [cwdLabel] + * @property {DashSource} source */ /** @@ -146,8 +148,8 @@ const SPLIT_DIVIDER_SIZE = 1; const PALETTE_ITEMS = [ { id: 'stats', - label: 'Open Vault Stats', - description: 'Logical size, dedup ratio, encryption coverage', + label: 'Open Source Stats', + description: 'Logical size, dedup ratio, and format coverage', category: 'View', shortcut: 's', }, @@ -525,7 +527,7 @@ function openStatsDrawer(model, deps) { palette: null, statsStatus: 'loading', statsError: null, - }, [/** @type {DashCmd} */ (loadStatsCmd(deps.cas))]]; + }, [/** @type {DashCmd} */ (loadStatsCmd(deps.cas, model.entries))]]; } /** @@ -549,7 +551,7 @@ function openDoctorDrawer(model, deps) { palette: null, doctorStatus: 'loading', doctorError: null, - }, [/** @type {DashCmd} */ (loadDoctorCmd(deps.cas))]]; + }, [/** @type {DashCmd} */ (loadDoctorCmd(deps.cas, deps.source, model.entries))]]; } /** @@ -888,7 +890,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), [/** @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), }; @@ -898,10 +900,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`); } @@ -934,17 +937,19 @@ function normalizeLaunchContext(ctx) { * @param {{ * ctx?: BijouContext, * runApp?: typeof run, - * cwd?: string, + * 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, cwdLabel: options.cwd }; + const deps = { keyMap, cas, ctx, cwdLabel: options.cwd, source }; const runApp = options.runApp || run; return runApp(createDashboardApp(deps), { ctx }); } diff --git a/test/unit/cli/dashboard-cmds.test.js b/test/unit/cli/dashboard-cmds.test.js new file mode 100644 index 0000000..0c67ecc --- /dev/null +++ b/test/unit/cli/dashboard-cmds.test.js @@ -0,0 +1,124 @@ +import { describe, it, expect, vi } from 'vitest'; +import { 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, + }; +} + +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, + }); + }); +}); 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 1c09858..d7b43d7 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -24,6 +24,7 @@ function makeDeps(overrides = {}) { cas: mockCas(), ctx: makeCtx(), cwdLabel: '/tmp/git-cas-fixture', + source: { type: 'vault' }, ...overrides, }; } @@ -210,7 +211,7 @@ describe('dashboard overlays', () => { expect(next.palette).not.toBeNull(); const rendered = renderView(app.view(next), deps.ctx); expect(rendered).toContain('Command Palette'); - expect(rendered).toContain('Open Vault Stats'); + expect(rendered).toContain('Open Source Stats'); }); it('palette selection opens the stats drawer and queues a load', () => { @@ -372,8 +373,9 @@ describe('dashboard view rendering', () => { expect(typeof output).toBe('object'); expect(output.width).toBe(model.columns); const rendered = renderView(output, deps.ctx); - expect(rendered).toContain('git-cas vault explorer'); + 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('Inspector'); }); @@ -455,7 +457,7 @@ describe('dashboard overlay rendering', () => { statsReport: makeStatsReport(), }); const rendered = renderView(app.view(model), deps.ctx); - expect(rendered).toContain('Vault Stats'); + expect(rendered).toContain('Source Stats'); expect(rendered).toContain('dedup-ratio'); }); @@ -474,6 +476,6 @@ describe('dashboard overlay rendering', () => { const rendered = renderView(app.view(withPalette), deps.ctx); expect(rendered).toContain('palette'); expect(rendered).toContain('Command Palette'); - expect(rendered).toContain('Open Vault Stats'); + expect(rendered).toContain('Open Source Stats'); }); }); From c3fd058f0ec7d7bf1d0d76bd5b2813d4a8ad0f2c Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 18 Mar 2026 06:42:14 -0700 Subject: [PATCH 07/22] feat(ui): add repository treemap atlas --- bin/ui/dashboard-cmds.js | 396 +++++++++++++++++++++++++++ bin/ui/dashboard-view.js | 42 ++- bin/ui/dashboard.js | 160 ++++++++++- bin/ui/repo-treemap.js | 298 ++++++++++++++++++++ test/unit/cli/dashboard-cmds.test.js | 130 ++++++++- test/unit/cli/dashboard.test.js | 109 +++++++- 6 files changed, 1121 insertions(+), 14 deletions(-) create mode 100644 bin/ui/repo-treemap.js diff --git a/bin/ui/dashboard-cmds.js b/bin/ui/dashboard-cmds.js index d002369..e5afbc7 100644 --- a/bin/ui/dashboard-cmds.js +++ b/bin/ui/dashboard-cmds.js @@ -2,11 +2,34 @@ * Async command factories for the vault dashboard. */ +import { lstat, 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 {'worktree' | 'git' | 'ref' | 'vault' | 'cas' | 'meta'} RepoTreemapKind */ +/** @typedef {{ label: string, kind: RepoTreemapKind, value: number, detail: string }} RepoTreemapTile */ +/** @typedef {{ + * scope: TreemapScope, + * cwd: string, + * source: DashSource, + * totalValue: number, + * tiles: RepoTreemapTile[], + * notes: string[], + * summary: { + * bare: boolean, + * gitDir: string, + * worktreeItems: number, + * refNamespaces: number, + * refCount: number, + * vaultEntries: number, + * sourceEntries: number, + * } + * }} RepoTreemapReport + */ /** * Compact OID label for human-facing rows. @@ -32,6 +55,334 @@ function singleEntrySource(slug, treeOid) { }; } +/** + * 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; +} + +/** + * 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 [gitDirRaw, bareRaw] = await Promise.all([ + plumbing.execute({ args: ['rev-parse', '--git-dir'] }), + 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: path.resolve(cwd, gitDirRaw.trim()), + bare, + }; +} + +/** + * Measure a filesystem path recursively without following symlinks. + * + * @param {string} targetPath + * @returns {Promise} + */ +async function measurePathBytes(targetPath) { + let stat; + try { + stat = await lstat(targetPath); + } catch { + return 0; + } + if (!stat.isDirectory() || stat.isSymbolicLink()) { + return stat.size; + } + const entries = await readdir(targetPath, { withFileTypes: true }); + const childSizes = await Promise.all(entries.map((entry) => measurePathBytes(path.join(targetPath, entry.name)))); + return childSizes.reduce((sum, size) => sum + size, 0); +} + +/** + * Build semantic tiles from the direct children of a directory. + * + * @param {string} directory + * @param {RepoTreemapKind} kind + * @param {(name: string) => string} labelFor + * @returns {Promise} + */ +async function scanDirectoryTiles(directory, kind, labelFor) { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return []; + } + + const tiles = await Promise.all(entries.map(async (entry) => { + const entryPath = path.join(directory, entry.name); + const value = await measurePathBytes(entryPath); + return { + label: labelFor(entry.name), + kind, + value: Math.max(1, value), + detail: `${formatBytes(value)} on disk`, + }; + })); + + return tiles + .filter((tile) => tile.value > 0) + .sort((left, right) => right.value - left.value || left.label.localeCompare(right.label)); +} + +/** + * Group refs by their top-level namespace. + * + * @param {{ execute: ({ args }: { args: string[] }) => Promise }} plumbing + * @returns {Promise<{ tiles: RepoTreemapTile[], totalRefs: number }>} + */ +async function readRefNamespaceTiles(plumbing) { + let output = ''; + try { + output = await plumbing.execute({ args: ['show-ref'] }); + } catch { + return { tiles: [], totalRefs: 0 }; + } + + const namespaces = new Map(); + for (const line of output.split('\n').map((row) => row.trim()).filter(Boolean)) { + const [, ref = ''] = line.split(' '); + const parts = ref.split('/'); + const label = parts[0] === 'refs' && parts[1] ? `refs/${parts[1]}` : ref; + namespaces.set(label, (namespaces.get(label) ?? 0) + 1); + } + + const tiles = Array.from(namespaces.entries()) + .map(([label, count]) => ({ + label, + kind: /** @type {const} */ ('ref'), + value: Math.max(1, count * 4096), + detail: `${count} refs`, + })) + .sort((left, right) => right.value - left.value || left.label.localeCompare(right.label)); + + return { + tiles, + totalRefs: Array.from(namespaces.values()).reduce((sum, count) => sum + count, 0), + }; +} + +/** + * 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, + }; + })); +} + +/** + * Convert logical CAS records into treemap tiles. + * + * @param {Array} records + * @param {RepoTreemapKind} kind + * @returns {RepoTreemapTile[]} + */ +function buildLogicalTiles(records, kind) { + return records + .map((record) => { + const data = manifestData(record.manifest); + const format = data.compression?.algorithm ?? 'raw'; + const crypto = data.encryption ? 'enc' : 'plain'; + return { + label: record.slug, + kind, + value: Math.max(1, record.size), + detail: `${formatBytes(record.size)} logical · ${data.chunks?.length ?? 0} chunks · ${crypto}/${format}`, + }; + }) + .sort((left, right) => right.value - left.value || left.label.localeCompare(right.label)); +} + +/** + * 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) { + if (tiles.length <= limit) { + return tiles; + } + const kept = tiles.slice(0, limit - 1); + const remainder = tiles.slice(limit - 1); + const otherValue = remainder.reduce((sum, tile) => sum + tile.value, 0); + kept.push({ + label: 'other', + kind: 'meta', + value: Math.max(1, otherValue), + detail: `${remainder.length} smaller regions`, + }); + return kept; +} + +/** + * Build a one-line aggregate tile for a source collection. + * + * @param {string} label + * @param {RepoTreemapKind} kind + * @param {Array} records + * @returns {RepoTreemapTile | null} + */ +function aggregateLogicalTile(label, kind, records) { + if (records.length === 0) { + return null; + } + const total = records.reduce((sum, record) => sum + record.size, 0); + return { + label, + kind, + value: Math.max(1, total), + detail: `${records.length} entries · ${formatBytes(total)} logical`, + }; +} + +/** + * Build the source-focused treemap report. + * + * @param {{ + * repo: { cwd: string, gitDir: string, bare: boolean }, + * source: DashSource, + * sourceResult: { entries: ExplorerEntry[] }, + * sourceRecords: Array, + * }} options + * @returns {RepoTreemapReport} + */ +function buildSourceScopeReport({ repo, source, sourceResult, sourceRecords }) { + const sourceTiles = compactTiles(buildLogicalTiles(sourceRecords, source.type === 'vault' ? 'vault' : 'cas')); + const totalValue = sourceTiles.reduce((sum, tile) => sum + tile.value, 0); + return { + scope: 'source', + cwd: repo.cwd, + source, + totalValue, + tiles: sourceTiles.length > 0 ? sourceTiles : [{ + label: 'empty source', + kind: 'meta', + value: 1, + detail: 'No CAS entries resolved for this source', + }], + notes: [ + 'Source view weights tiles by logical manifest size.', + ], + summary: { + bare: repo.bare, + gitDir: repo.gitDir, + worktreeItems: 0, + refNamespaces: 0, + refCount: 0, + vaultEntries: source.type === 'vault' ? sourceResult.entries.length : 0, + sourceEntries: sourceResult.entries.length, + }, + }; +} + +/** + * 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, + * }} options + * @returns {Promise} + */ +async function buildRepositoryScopeReport({ cas, source, repo, plumbing, sourceResult, sourceRecords }) { + const worktreeTiles = repo.bare + ? [] + : await scanDirectoryTiles(repo.cwd, 'worktree', (name) => name).then((tiles) => tiles.filter((tile) => tile.label !== path.basename(repo.gitDir))); + const gitTiles = await scanDirectoryTiles(repo.gitDir, 'git', (name) => repo.bare ? name : `.git/${name}`); + const { tiles: refTiles, totalRefs } = await readRefNamespaceTiles(plumbing); + const vaultResult = source.type === 'vault' ? sourceResult : await readSourceEntries(cas, { type: 'vault' }); + const vaultRecords = source.type === 'vault' ? sourceRecords : await loadEntryRecords(cas, vaultResult.entries); + const vaultTile = aggregateLogicalTile('vault', 'vault', vaultRecords); + const activeSourceTile = source.type !== 'vault' + ? aggregateLogicalTile('active source', 'cas', sourceRecords) + : null; + const tiles = compactTiles([ + ...worktreeTiles, + ...gitTiles, + ...refTiles, + ...(vaultTile ? [vaultTile] : []), + ...(activeSourceTile ? [activeSourceTile] : []), + ]); + const totalValue = tiles.reduce((sum, tile) => sum + tile.value, 0); + + return { + scope: 'repository', + cwd: repo.cwd, + source, + totalValue, + tiles: tiles.length > 0 ? tiles : [{ + label: 'empty repo', + kind: 'meta', + value: 1, + detail: 'No worktree, ref, or CAS regions were detected', + }], + notes: [ + 'Repository view mixes worktree/.git bytes with logical CAS region sizes.', + repo.bare ? 'Bare repository: worktree regions are omitted.' : `Git dir ${repo.gitDir}`, + ], + summary: { + bare: repo.bare, + gitDir: repo.gitDir, + worktreeItems: worktreeTiles.length, + refNamespaces: refTiles.length, + refCount: totalRefs, + vaultEntries: vaultResult.entries.length, + sourceEntries: sourceResult.entries.length, + }, + }; +} + /** * Return true when the provided tree OID resolves to a CAS manifest. * @@ -236,6 +587,33 @@ export async function readSourceEntries(cas, source = { type: 'vault' }) { return resolveNonVaultSource(cas, source); } +/** + * Build the semantic repo/source treemap report for the dashboard. + * + * @param {ContentAddressableStore} cas + * @param {DashSource} [source] + * @param {TreemapScope} [scope] + * @returns {Promise} + */ +export async function buildRepoTreemapReport(cas, source = { type: 'vault' }, scope = 'repository') { + 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 }); + } + return buildRepositoryScopeReport({ + cas, + source, + repo, + plumbing: service.persistence.plumbing, + sourceResult, + sourceRecords, + }); +} + /** * Load vault entries and metadata in parallel. * @@ -316,3 +694,21 @@ export function loadDoctorCmd(cas, source = { type: 'vault' }, entries = []) { } }; } + +/** + * Load the repository/source treemap report for the dashboard drawer. + * + * @param {ContentAddressableStore} cas + * @param {DashSource} [source] + * @param {TreemapScope} [scope] + */ +export function loadTreemapCmd(cas, source = { type: 'vault' }, scope = 'repository') { + return async () => { + try { + const report = await buildRepoTreemapReport(cas, source, scope); + return /** @type {const} */ ({ type: 'loaded-treemap', report }); + } catch (/** @type {any} */ err) { + return /** @type {const} */ ({ type: 'load-error', source: 'treemap', scopeId: scope, error: /** @type {Error} */ (err).message }); + } + }; +} diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index bfabc70..57d4886 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -4,6 +4,7 @@ import { badge, boxV3, createSurface, parseAnsiToSurface, kbd } from '@flyingrobots/bijou'; import { commandPalette, navigableTable, splitPaneLayout } from '@flyingrobots/bijou-tui'; +import { renderRepoTreemap } from './repo-treemap.js'; import { renderDoctorReport, renderVaultStats } from './vault-report.js'; import { renderManifestView } from './manifest-view.js'; @@ -118,6 +119,9 @@ function appendSelectionBadges(parts, model, ctx) { if (selected) { parts.push(badge(`selected ${selected.slug}`, { variant: 'accent', ctx })); } + if (model.activeDrawer === 'treemap') { + parts.push(badge(`scope ${model.treemapScope}`, { variant: 'primary', ctx })); + } if (model.activeDrawer) { parts.push(badge(`${model.activeDrawer} drawer`, { variant: 'info', ctx })); } @@ -187,7 +191,7 @@ function renderOverlayPanel(options) { */ function statsDrawerBody(model) { if (model.statsStatus === 'loading') { - return 'Loading vault stats...'; + return 'Loading source stats...'; } if (model.statsStatus === 'error') { return `Failed to load stats\n\n${model.statsError ?? 'unknown error'}`; @@ -251,6 +255,37 @@ function renderDoctorDrawer(model, opts) { }); } +/** + * Render the repository treemap drawer. + * + * @param {DashModel} model + * @param {{ width: number, height: number, ctx: BijouContext }} opts + * @returns {Surface} + */ +function renderTreemapDrawer(model, opts) { + const width = Math.max(42, Math.min(78, opts.width - 2)); + const height = Math.max(10, Math.min(22, opts.height)); + let body = 'Treemap has not been loaded yet.'; + if (model.treemapStatus === 'loading') { + body = `Loading ${model.treemapScope} treemap...`; + } else if (model.treemapStatus === 'error') { + body = `Failed to load treemap\n\n${model.treemapError ?? 'unknown error'}`; + } else if (model.treemapReport) { + body = renderRepoTreemap(model.treemapReport, { + ctx: opts.ctx, + width: Math.max(16, width - 2), + height: Math.max(6, height - 2), + }); + } + return renderOverlayPanel({ + title: 'Repo Treemap', + body, + width, + height, + ctx: opts.ctx, + }); +} + /** * Render the operator drawer surface when active. * @@ -262,6 +297,9 @@ function renderDrawerSurface(model, opts) { if (!model.activeDrawer) { return null; } + if (model.activeDrawer === 'treemap') { + return renderTreemapDrawer(model, opts); + } return model.activeDrawer === 'stats' ? renderStatsDrawer(model, opts) : renderDoctorDrawer(model, opts); @@ -475,7 +513,7 @@ function renderFooterSurface(ctx, width) { '─'.repeat(Math.max(1, width)), `${kbd('j/k', { ctx })} rows ${kbd('d/u', { ctx })} page ${kbd('J/K', { ctx })} scroll ${kbd('enter', { ctx })} inspect`, `${kbd('tab', { ctx })} pane ${kbd('H/L', { ctx })} resize ${kbd('ctrl+p', { ctx })} palette`, - `${kbd('s', { ctx })} stats ${kbd('g', { ctx })} doctor ${kbd('esc', { ctx })} close ${kbd('q', { ctx })} quit`, + `${kbd('s', { ctx })} stats ${kbd('g', { ctx })} doctor ${kbd('t', { ctx })} treemap ${kbd('T', { ctx })} scope ${kbd('esc', { ctx })} close ${kbd('q', { ctx })} quit`, ]; return textSurface(lines.join('\n'), width, 4); } diff --git a/bin/ui/dashboard.js b/bin/ui/dashboard.js index c4791c2..659172e 100644 --- a/bin/ui/dashboard.js +++ b/bin/ui/dashboard.js @@ -8,7 +8,7 @@ import { createSplitPaneState, splitPaneFocusNext, splitPaneResizeBy, createCommandPaletteState, cpFilter, cpFocusNext, cpFocusPrev, cpPageDown, cpPageUp, cpSelectedItem, commandPaletteKeyMap, } from '@flyingrobots/bijou-tui'; -import { loadEntriesCmd, loadManifestCmd, loadStatsCmd, loadDoctorCmd, readSourceEntries } from './dashboard-cmds.js'; +import { loadEntriesCmd, loadManifestCmd, loadStatsCmd, loadDoctorCmd, loadTreemapCmd, readSourceEntries } from './dashboard-cmds.js'; import { createCliTuiContext, detectCliTuiMode } from './context.js'; import { renderDashboard } from './dashboard-view.js'; @@ -23,6 +23,7 @@ import { renderDashboard } from './dashboard-view.js'; * @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 {{ slug: string, treeOid: string }} VaultEntry * @typedef {{ type: 'vault' } | { type: 'ref', ref: string } | { type: 'oid', treeOid: string }} DashSource * @typedef {'idle' | 'loading' | 'ready' | 'error'} LoadState @@ -40,6 +41,8 @@ import { renderDashboard } from './dashboard-view.js'; * | { type: 'open-palette' } * | { type: 'open-stats' } * | { type: 'open-doctor' } + * | { type: 'open-treemap' } + * | { type: 'toggle-treemap-scope' } * | { type: 'overlay-close' } * } DashAction */ @@ -59,7 +62,8 @@ import { renderDashboard } from './dashboard-view.js'; * | { type: 'loaded-manifest', slug: string, manifest: Manifest } * | { type: 'loaded-stats', stats: any } * | { type: 'loaded-doctor', report: any } - * | { type: 'load-error', source: string, slug?: string, error: string } + * | { type: 'loaded-treemap', report: any } + * | { type: 'load-error', source: string, slug?: string, scopeId?: TreemapScope, error: string } * } DashMsg */ @@ -80,13 +84,17 @@ import { renderDashboard } from './dashboard-view.js'; * @property {NavigableTableState} table * @property {SplitPaneState} splitPane * @property {CommandPaletteState | null} palette - * @property {'stats' | 'doctor' | null} activeDrawer + * @property {'stats' | 'doctor' | 'treemap' | null} activeDrawer * @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 {LoadState} treemapStatus + * @property {any | null} treemapReport + * @property {string | null} treemapError */ /** @@ -123,6 +131,8 @@ export function createKeyBindings() { .bind(':', 'Palette', { type: 'open-palette' }) .bind('s', 'Stats', { type: 'open-stats' }) .bind('g', 'Doctor', { type: 'open-doctor' }) + .bind('t', 'Treemap', { type: 'open-treemap' }) + .bind('shift+t', 'Treemap scope', { type: 'toggle-treemap-scope' }) .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 }); @@ -146,6 +156,20 @@ const SPLIT_MIN_DETAIL_WIDTH = 32; const SPLIT_DIVIDER_SIZE = 1; const PALETTE_ITEMS = [ + { + id: 'treemap', + label: 'Open Repo Treemap', + description: '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: 'stats', label: 'Open Source Stats', @@ -356,6 +380,10 @@ function createInitModel(ctx) { doctorStatus: 'idle', doctorReport: null, doctorError: null, + treemapScope: 'repository', + treemapStatus: 'idle', + treemapReport: null, + treemapError: null, }; } @@ -554,6 +582,66 @@ function openDoctorDrawer(model, deps) { }, [/** @type {DashCmd} */ (loadDoctorCmd(deps.cas, deps.source, model.entries))]]; } +/** + * Open the repo treemap drawer and trigger a load when needed. + * + * @param {DashModel} model + * @param {DashDeps} deps + * @returns {[DashModel, DashCmd[]]} + */ +function openTreemapDrawer(model, deps) { + if (model.treemapStatus === 'ready' && model.treemapReport?.scope === model.treemapScope) { + 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, + }, [/** @type {DashCmd} */ (loadTreemapCmd(deps.cas, deps.source, model.treemapScope))]]; +} + +/** + * 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'; + if (model.treemapReport?.scope === treemapScope) { + return [{ + ...model, + treemapScope, + activeDrawer: 'treemap', + palette: null, + treemapStatus: 'ready', + treemapError: null, + }, []]; + } + return [{ + ...model, + treemapScope, + activeDrawer: 'treemap', + palette: null, + treemapStatus: 'loading', + treemapError: null, + }, [/** @type {DashCmd} */ (loadTreemapCmd(deps.cas, deps.source, treemapScope))]]; +} + /** * Close the command palette or active drawer, whichever is visible. * @@ -582,6 +670,12 @@ function handlePaletteSelect(model, deps) { if (!item) { return [{ ...model, palette: null }, []]; } + if (item.id === 'treemap') { + return openTreemapDrawer(model, deps); + } + if (item.id === 'treemap-scope') { + return toggleTreemapScope(model, deps); + } if (item.id === 'stats') { return openStatsDrawer(model, deps); } @@ -745,6 +839,12 @@ function handleOverlayAction(action, model, deps) { if (action.type === 'open-doctor') { return openDoctorDrawer(model, deps); } + if (action.type === 'open-treemap') { + return openTreemapDrawer(model, deps); + } + if (action.type === 'toggle-treemap-scope') { + return toggleTreemapScope(model, deps); + } if (action.type === 'overlay-close') { return closeOverlay(model); } @@ -796,16 +896,13 @@ function handleAction(action, model, deps) { } /** - * Handle app-level messages (data loading results). + * Handle successful report loads. * * @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); } +function handleLoadedReport(msg, model) { if (msg.type === 'loaded-stats') { return [{ ...model, @@ -822,6 +919,28 @@ function handleAppMsg(msg, model, cas) { doctorError: null, }, []]; } + if (msg.type === 'loaded-treemap') { + if (msg.report.scope !== model.treemapScope) { + return [model, []]; + } + return [{ + ...model, + treemapStatus: 'ready', + treemapReport: msg.report, + treemapError: null, + }, []]; + } + return [model, []]; +} + +/** + * Handle load errors from async dashboard commands. + * + * @param {DashMsg & { type: 'load-error' }} msg + * @param {DashModel} model + * @returns {[DashModel, DashCmd[]]} + */ +function handleLoadError(msg, model) { if (msg.type === 'load-error') { if (msg.source === 'manifest') { return [{ ...model, loadingSlug: model.loadingSlug === msg.slug ? null : model.loadingSlug }, []]; @@ -840,11 +959,36 @@ function handleAppMsg(msg, model, cas) { doctorError: msg.error, }, []]; } + if (msg.source === 'treemap') { + if (msg.scopeId && msg.scopeId !== model.treemapScope) { + return [model, []]; + } + return [{ + ...model, + treemapStatus: 'error', + treemapError: msg.error, + }, []]; + } return [{ ...model, status: 'error', error: msg.error }, []]; } return [model, []]; } +/** + * 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 === 'load-error') { return handleLoadError(msg, model); } + return handleLoadedReport(msg, model); +} + /** * Route all update messages to the appropriate handler. * diff --git a/bin/ui/repo-treemap.js b/bin/ui/repo-treemap.js new file mode 100644 index 0000000..bdb66b9 --- /dev/null +++ b/bin/ui/repo-treemap.js @@ -0,0 +1,298 @@ +/** + * Render a semantic repository treemap for the dashboard drawer. + */ + +/** + * @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 + */ + +const TILE_COLOR = { + worktree: [59, 207, 212], + git: [252, 147, 5], + ref: [242, 0, 148], + vault: [166, 227, 1], + cas: [137, 180, 250], + meta: [148, 163, 184], +}; + +const TILE_FILL = { + worktree: '█', + git: '▓', + ref: '▒', + vault: '■', + cas: '▦', + meta: '░', +}; + +/** + * 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)}…`; +} + +/** + * 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))}`; +} + +/** + * Group tiles into a binary split that approximates a treemap. + * + * @param {RepoTreemapTile[]} tiles + * @returns {[RepoTreemapTile[], RepoTreemapTile[]]} + */ +function splitTiles(tiles) { + const total = tiles.reduce((sum, tile) => sum + tile.value, 0); + const target = total / 2; + const left = []; + let leftSum = 0; + for (let index = 0; index < tiles.length; index++) { + const tile = tiles[index]; + if (index > 0 && leftSum >= target) { + return [left, tiles.slice(index)]; + } + left.push(tile); + leftSum += tile.value; + } + return [left, []]; +} + +/** + * 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 }))); +} + +/** + * 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 < 4 || rect.height < 2) { + return; + } + const label = clip(rect.tile.label, rect.width - 1); + 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 }; + } + } +} + +/** + * Paint a rectangle into the cell grid. + * + * @param {ReturnType} grid + * @param {{ tile: RepoTreemapTile, x: number, y: number, width: number, height: number }} rect + */ +function paintRect(grid, rect) { + 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 }; + } + } + } + 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; + 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 renderLegend(ctx, width) { + const parts = [ + ['worktree', 'worktree'], + ['git', 'git'], + ['ref', 'refs'], + ['vault', 'vault'], + ['cas', 'source'], + ].map(([kind, label]) => { + const fill = TILE_FILL[/** @type {keyof typeof TILE_FILL} */ (kind)]; + const color = TILE_COLOR[/** @type {keyof typeof TILE_COLOR} */ (kind)]; + return `${ctx.style.rgb(color[0], color[1], color[2], fill)} ${label}`; + }); + return clip(`legend ${parts.join(' ')}`, width); +} + +/** + * Render the most important tile details below the treemap. + * + * @param {RepoTreemapTile[]} tiles + * @param {number} width + * @param {number} lines + * @returns {string[]} + */ +function renderDetails(tiles, width, lines) { + return tiles + .slice(0, Math.max(0, lines)) + .map((tile) => clip(`${tile.label} ${tile.detail}`, width)); +} + +/** + * Render a repository treemap as ANSI-aware text. + * + * @param {RepoTreemapReport} report + * @param {{ ctx: BijouContext, width: number, height: number }} options + * @returns {string} + */ +export function renderRepoTreemap(report, options) { + const width = Math.max(24, options.width); + const height = Math.max(10, options.height); + const summaryLines = [ + clip(`scope ${report.scope} source ${sourceLabel(report.source)}`, width), + clip(`root ${tailClip(report.cwd, Math.max(1, width - 5))}`, width), + clip(`total ${formatBytes(report.totalValue)} worktree ${report.summary.worktreeItems} refs ${report.summary.refCount} source ${report.summary.sourceEntries}`, width), + ]; + + const legendLine = renderLegend(options.ctx, width); + const noteLines = report.notes.map((note) => clip(note, width)); + const detailRows = Math.min(4, Math.max(1, height - 8)); + const gridHeight = Math.max(4, height - summaryLines.length - detailRows - noteLines.length - 2); + const grid = createGrid(width, gridHeight); + const layout = layoutTreemap(report.tiles, { x: 0, y: 0, width, height: gridHeight }); + for (const rect of layout) { + paintRect(grid, rect); + } + + return [ + ...summaryLines, + ...renderGrid(grid, options.ctx), + legendLine, + ...renderDetails(report.tiles, width, detailRows), + ...noteLines, + ].slice(0, height).join('\n'); +} diff --git a/test/unit/cli/dashboard-cmds.test.js b/test/unit/cli/dashboard-cmds.test.js index 0c67ecc..47ae799 100644 --- a/test/unit/cli/dashboard-cmds.test.js +++ b/test/unit/cli/dashboard-cmds.test.js @@ -1,5 +1,8 @@ import { describe, it, expect, vi } from 'vitest'; -import { readSourceEntries } from '../../../bin/ui/dashboard-cmds.js'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { buildRepoTreemapReport, readSourceEntries } from '../../../bin/ui/dashboard-cmds.js'; function makePersistence(overrides = {}) { return { @@ -17,6 +20,79 @@ function makeRefPort(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 makeRepoExec(repoDir, showRefOutput = '') { + return async ({ args }) => { + if (args[0] === 'rev-parse' && args[1] === '--git-dir') { + return '.git'; + } + if (args[0] === 'rev-parse' && args[1] === '--is-bare-repository') { + return 'false'; + } + if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') { + return repoDir; + } + if (args[0] === 'show-ref') { + return showRefOutput; + } + throw new Error(`unexpected git command: ${args.join(' ')}`); + }; +} + +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 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, '.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 } }), + }; +} + +function makeSourceTreemapCas(plumbing) { + return { + readManifest: vi.fn().mockImplementation(async ({ treeOid }) => readTreemapManifest(treeOid)), + getService: vi.fn().mockResolvedValue({ persistence: { plumbing } }), + }; +} + describe('readSourceEntries vault and oid modes', () => { it('loads vault entries through the vault service facade', async () => { const entries = [{ slug: 'alpha', treeOid: 'deadbeef' }]; @@ -122,3 +198,55 @@ describe('readSourceEntries commit message hints', () => { }); }); }); + +describe('buildRepoTreemapReport repository scope', () => { + it('builds a repository-scope atlas with worktree, git, ref, vault, and source regions', async () => { + await withTempRepo(async (repoDir) => { + await seedRepoLayout(repoDir); + const plumbing = makePlumbing(repoDir, makeRepoExec(repoDir, [ + '1111111111111111111111111111111111111111 refs/heads/main', + '2222222222222222222222222222222222222222 refs/warp/demo/seek-cache', + ].join('\n'))); + const cas = makeRepositoryTreemapCas(plumbing); + const report = await buildRepoTreemapReport(cas, { type: 'oid', treeOid: 'source-tree' }, 'repository'); + expect(report.scope).toBe('repository'); + expect(report.cwd).toBe(repoDir); + expect(report.summary.worktreeItems).toBeGreaterThan(0); + expect(report.summary.refCount).toBe(2); + expect(report.summary.vaultEntries).toBe(1); + expect(report.summary.sourceEntries).toBe(1); + const labels = []; + for (const tile of report.tiles) { + labels.push(tile.label); + } + expect(labels).toEqual(expect.arrayContaining([ + 'README.md', + 'src', + '.git/objects', + 'refs/heads', + 'refs/warp', + 'vault', + 'active source', + ])); + }); + }); +}); + +describe('buildRepoTreemapReport source scope', () => { + it('builds a source-scope treemap from logical source entries', async () => { + await withTempRepo(async (repoDir) => { + const plumbing = makePlumbing(repoDir, makeRepoExec(repoDir)); + const cas = makeSourceTreemapCas(plumbing); + const report = await buildRepoTreemapReport(cas, { type: 'oid', treeOid: 'feedfacecafebeef' }, 'source'); + expect(report.scope).toBe('source'); + expect(report.summary.sourceEntries).toBe(1); + expect(report.tiles).toEqual([ + expect.objectContaining({ + label: 'oid:feedfacecafe', + kind: 'cas', + }), + ]); + expect(report.notes[0]).toContain('logical manifest size'); + }); + }); +}); diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index d7b43d7..8b20663 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -91,6 +91,10 @@ function makeModel(overrides = {}) { doctorStatus: 'idle', doctorReport: null, doctorError: null, + treemapScope: 'repository', + treemapStatus: 'idle', + treemapReport: null, + treemapError: null, ...overrides, }; } @@ -135,6 +139,31 @@ function makeDoctorReport() { }; } +function makeTreemapReport(overrides = {}) { + return { + scope: 'repository', + cwd: '/tmp/git-cas-fixture', + source: { type: 'vault' }, + totalValue: 8192, + tiles: [ + { label: 'src', kind: 'worktree', value: 4096, detail: '4.0K on disk' }, + { label: '.git/objects', kind: 'git', value: 2048, detail: '2.0K on disk' }, + { label: 'vault', kind: 'vault', value: 2048, detail: '2 entries · 2.0K logical' }, + ], + notes: ['Repository view mixes worktree/.git bytes with logical CAS region sizes.'], + summary: { + bare: false, + gitDir: '/tmp/git-cas-fixture/.git', + worktreeItems: 1, + refNamespaces: 1, + refCount: 3, + vaultEntries: 2, + sourceEntries: 2, + }, + ...overrides, + }; +} + describe('dashboard initialization', () => { it('init returns loading model with one cmd', () => { const app = createDashboardApp(makeDeps()); @@ -203,7 +232,7 @@ describe('dashboard pane controls', () => { }); }); -describe('dashboard overlays', () => { +describe('dashboard palette and overlay commands', () => { it('ctrl+p opens the command palette', () => { const deps = makeDeps(); const app = createDashboardApp(deps); @@ -211,19 +240,24 @@ describe('dashboard overlays', () => { expect(next.palette).not.toBeNull(); const rendered = renderView(app.view(next), deps.ctx); expect(rendered).toContain('Command Palette'); + 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 [next, cmds] = app.update(keyMsg('enter'), withPalette); + const [onTreemap] = app.update(keyMsg('down'), withPalette); + const [onStats] = app.update(keyMsg('down'), onTreemap); + 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()); @@ -237,6 +271,29 @@ describe('dashboard overlays', () => { const [next] = app.update(keyMsg('escape'), makeModel({ activeDrawer: 'stats', statsStatus: 'ready' })); expect(next.activeDrawer).toBeNull(); }); + + it('t opens the treemap drawer 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); + }); + + 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); + }); }); describe('dashboard data loading', () => { @@ -276,6 +333,31 @@ describe('dashboard report loading', () => { expect(next.doctorReport).toEqual(report); expect(next.doctorError).toBeNull(); }); + + it('loaded-treemap stores the report for the active scope', () => { + const app = createDashboardApp(makeDeps()); + const report = { + scope: 'repository', + cwd: '/tmp/git-cas-fixture', + source: { type: 'vault' }, + totalValue: 2048, + tiles: [{ label: 'src', kind: 'worktree', value: 2048, detail: '2.0K on disk' }], + notes: [], + summary: { + bare: false, + gitDir: '/tmp/git-cas-fixture/.git', + worktreeItems: 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 filter mode', () => { @@ -420,6 +502,8 @@ describe('dashboard footer and inspector rendering', () => { expect(rendered).toContain('palette'); expect(rendered).toContain('stats'); expect(rendered).toContain('doctor'); + expect(rendered).toContain('treemap'); + expect(rendered).toContain('scope'); expect(rendered).toContain('close'); expect(rendered).toContain('quit'); }); @@ -447,7 +531,7 @@ describe('dashboard footer and inspector rendering', () => { }); }); -describe('dashboard overlay rendering', () => { +describe('dashboard report overlay rendering', () => { it('renders the stats drawer overlay', () => { const deps = makeDeps(); const app = createDashboardApp(deps); @@ -468,6 +552,24 @@ describe('dashboard overlay rendering', () => { expect(rendered).toContain('Doctor Report'); expect(rendered).toContain('Loading doctor report'); }); +}); + +describe('dashboard treemap and palette rendering', () => { + it('renders the treemap drawer overlay', () => { + const deps = makeDeps(); + const app = createDashboardApp(deps); + const model = makeModel({ + activeDrawer: 'treemap', + treemapScope: 'repository', + treemapStatus: 'ready', + treemapReport: makeTreemapReport(), + }); + const rendered = renderView(app.view(model), deps.ctx); + expect(rendered).toContain('Repo Treemap'); + expect(rendered).toContain('scope repository'); + expect(rendered).toContain('legend'); + expect(rendered).toContain('Repository view mixes worktree/.git bytes with logical CAS region sizes.'); + }); it('renders the palette badge when the command palette is open', () => { const deps = makeDeps(); @@ -476,6 +578,7 @@ describe('dashboard overlay rendering', () => { const rendered = renderView(app.view(withPalette), deps.ctx); expect(rendered).toContain('palette'); expect(rendered).toContain('Command Palette'); + expect(rendered).toContain('Open Repo Treemap'); expect(rendered).toContain('Open Source Stats'); }); }); From 7c8fd8fa9809ff4b09aeb8f734e5ca01f878af0a Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 18 Mar 2026 06:58:47 -0700 Subject: [PATCH 08/22] fix(ui): add dashboard toasts and safe treemap repo probing --- bin/ui/dashboard-cmds.js | 36 ++++++-- bin/ui/dashboard-view.js | 122 +++++++++++++++++++++++++ bin/ui/dashboard.js | 131 ++++++++++++++++++++------- test/unit/cli/dashboard-cmds.test.js | 2 +- test/unit/cli/dashboard.test.js | 57 +++++++++++- 5 files changed, 308 insertions(+), 40 deletions(-) diff --git a/bin/ui/dashboard-cmds.js b/bin/ui/dashboard-cmds.js index e5afbc7..bbcd714 100644 --- a/bin/ui/dashboard-cmds.js +++ b/bin/ui/dashboard-cmds.js @@ -2,7 +2,7 @@ * Async command factories for the vault dashboard. */ -import { lstat, readdir } from 'node:fs/promises'; +import { lstat, readFile, readdir } from 'node:fs/promises'; import path from 'node:path'; import { buildVaultStats, inspectVaultHealth } from './vault-report.js'; @@ -78,6 +78,33 @@ 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. * @@ -86,10 +113,7 @@ function manifestData(manifest) { */ async function resolveRepoInfo(plumbing) { const cwd = plumbing.cwd ?? process.cwd(); - const [gitDirRaw, bareRaw] = await Promise.all([ - plumbing.execute({ args: ['rev-parse', '--git-dir'] }), - plumbing.execute({ args: ['rev-parse', '--is-bare-repository'] }), - ]); + const bareRaw = await plumbing.execute({ args: ['rev-parse', '--is-bare-repository'] }); const bare = bareRaw.trim() === 'true'; let repoRoot = cwd; if (!bare) { @@ -101,7 +125,7 @@ async function resolveRepoInfo(plumbing) { } return { cwd: repoRoot, - gitDir: path.resolve(cwd, gitDirRaw.trim()), + gitDir: bare ? repoRoot : await resolveWorktreeGitDir(repoRoot), bare, }; } diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index 57d4886..d6e9f07 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -19,6 +19,12 @@ import { renderManifestView } from './manifest-view.js'; const SPLIT_MIN_LIST_WIDTH = 28; const SPLIT_MIN_DETAIL_WIDTH = 32; const SPLIT_DIVIDER_SIZE = 1; +const TOAST_THEME = { + error: { label: 'Error', bg: [185, 28, 28], fg: [255, 255, 255] }, + warning: { label: 'Warning', bg: [202, 138, 4], fg: [17, 24, 39] }, + info: { label: 'Info', bg: [37, 99, 235], fg: [255, 255, 255] }, + success: { label: 'Success', bg: [22, 163, 74], fg: [255, 255, 255] }, +}; /** * Safely clip text to a pane width. @@ -119,6 +125,9 @@ function appendSelectionBadges(parts, model, ctx) { if (selected) { parts.push(badge(`selected ${selected.slug}`, { variant: 'accent', ctx })); } + if (model.toasts.length > 0) { + parts.push(badge(`alerts ${model.toasts.length}`, { variant: 'warning', ctx })); + } if (model.activeDrawer === 'treemap') { parts.push(badge(`scope ${model.treemapScope}`, { variant: 'primary', ctx })); } @@ -183,6 +192,94 @@ function renderOverlayPanel(options) { }); } +/** + * 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 (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; +} + +/** + * Wrap toast copy with simple fixed-width chunks. + * + * @param {string} text + * @param {number} width + * @param {number} maxLines + * @returns {string[]} + */ +function wrapToastText(text, width, maxLines) { + const chunkPattern = new RegExp(`.{1,${Math.max(1, width)}}`, 'g'); + const lines = text + .split('\n') + .flatMap((part) => part.length === 0 ? [''] : (part.match(chunkPattern) ?? [''])); + 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), + ), + ); +} + +/** + * Render one toast box surface. + * + * @param {{ id: number, level: 'error' | 'warning' | 'info' | 'success', title: string, message: string }} toast + * @param {{ width: number, ctx: BijouContext }} opts + * @returns {Surface} + */ +function renderToastSurface(toast, opts) { + const theme = TOAST_THEME[toast.level] ?? TOAST_THEME.info; + const width = Math.max(28, Math.min(46, opts.width)); + const innerWidth = Math.max(1, width - 2); + const bodyLines = wrapToastText(toast.message, innerWidth, 3).map((line) => styleToastLine({ + text: line, + theme, + ctx: opts.ctx, + width: innerWidth, + })); + return boxV3(textSurface(bodyLines.join('\n'), innerWidth, bodyLines.length), { + ctx: opts.ctx, + title: `${theme.label}: ${toast.title}`, + width, + }); +} + /** * Build drawer copy for the stats overlay. * @@ -305,6 +402,29 @@ function renderDrawerSurface(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) { + let cursorY = options.top + options.height; + for (const toast of model.toasts) { + const surface = renderToastSurface(toast, { + width: Math.min(54, Math.max(34, Math.floor(options.screen.width * 0.55))), + ctx: deps.ctx, + }); + cursorY -= surface.height; + if (cursorY < options.top) { + break; + } + options.screen.blit(surface, Math.max(0, options.screen.width - surface.width), cursorY); + cursorY -= 1; + } +} + /** * Render the command palette overlay. * @@ -569,6 +689,8 @@ function renderOverlays(model, deps, options) { const y = options.top + Math.max(0, Math.floor((options.height - palette.height) / 3)); options.screen.blit(palette, x, y); } + + renderToastStack(model, deps, options); } /** diff --git a/bin/ui/dashboard.js b/bin/ui/dashboard.js index 659172e..da6f09d 100644 --- a/bin/ui/dashboard.js +++ b/bin/ui/dashboard.js @@ -3,7 +3,7 @@ */ import { - run, quit, createKeyMap, + run, quit, tick, createKeyMap, createNavigableTableState, navTableFocusNext, navTableFocusPrev, navTablePageDown, navTablePageUp, createSplitPaneState, splitPaneFocusNext, splitPaneResizeBy, createCommandPaletteState, cpFilter, cpFocusNext, cpFocusPrev, cpPageDown, cpPageUp, cpSelectedItem, commandPaletteKeyMap, @@ -25,6 +25,8 @@ import { renderDashboard } from './dashboard-view.js'; * @typedef {import('../../src/domain/value-objects/Manifest.js').default} Manifest * @typedef {import('./dashboard-cmds.js').TreemapScope} TreemapScope * @typedef {{ slug: string, treeOid: string }} VaultEntry + * @typedef {'error' | 'warning' | 'info' | 'success'} ToastLevel + * @typedef {{ id: number, level: ToastLevel, title: string, message: string }} ToastRecord * @typedef {{ type: 'vault' } | { type: 'ref', ref: string } | { type: 'oid', treeOid: string }} DashSource * @typedef {'idle' | 'loading' | 'ready' | 'error'} LoadState */ @@ -63,6 +65,7 @@ import { renderDashboard } from './dashboard-view.js'; * | { type: 'loaded-stats', stats: any } * | { type: 'loaded-doctor', report: any } * | { type: 'loaded-treemap', report: any } + * | { type: 'dismiss-toast', id: number } * | { type: 'load-error', source: string, slug?: string, scopeId?: TreemapScope, error: string } * } DashMsg */ @@ -95,6 +98,8 @@ import { renderDashboard } from './dashboard-view.js'; * @property {LoadState} treemapStatus * @property {any | null} treemapReport * @property {string | null} treemapError + * @property {ToastRecord[]} toasts + * @property {number} nextToastId */ /** @@ -154,6 +159,8 @@ 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 PALETTE_ITEMS = [ { @@ -345,6 +352,85 @@ 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 }; + return [{ + ...model, + nextToastId: id + 1, + toasts: [toast, ...model.toasts].slice(0, TOAST_LIMIT), + }, [/** @type {DashCmd} */ (tick(TOAST_TTL_MS, { type: 'dismiss-toast', id }))]]; +} + +/** + * Dismiss a toast by id. + * + * @param {DashModel} model + * @param {number} id + * @returns {[DashModel, DashCmd[]]} + */ +function dismissToast(model, id) { + return [{ + ...model, + toasts: model.toasts.filter((toast) => toast.id !== id), + }, []]; +} + +/** + * 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 === 'manifest') { + return { ...model, loadingSlug: model.loadingSlug === msg.slug ? null : model.loadingSlug }; + } + if (msg.source === 'stats') { + return { ...model, statsStatus: 'error', statsError: msg.error }; + } + if (msg.source === 'doctor') { + return { ...model, doctorStatus: 'error', doctorError: msg.error }; + } + if (msg.source === 'treemap') { + if (msg.scopeId && msg.scopeId !== model.treemapScope) { + return model; + } + return { ...model, treemapStatus: 'error', treemapError: msg.error }; + } + 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 === 'treemap') { + return 'Failed to load repo treemap'; + } + return 'Failed to load entries'; +} + /** * Create the initial model. * @@ -384,6 +470,8 @@ function createInitModel(ctx) { treemapStatus: 'idle', treemapReport: null, treemapError: null, + toasts: [], + nextToastId: 1, }; } @@ -655,6 +743,9 @@ function closeOverlay(model) { if (model.activeDrawer) { return [{ ...model, activeDrawer: null }, []]; } + if (model.toasts.length > 0) { + return dismissToast(model, model.toasts[0].id); + } return [model, []]; } @@ -941,37 +1032,14 @@ function handleLoadedReport(msg, model) { * @returns {[DashModel, DashCmd[]]} */ function handleLoadError(msg, model) { - if (msg.type === 'load-error') { - if (msg.source === 'manifest') { - return [{ ...model, loadingSlug: model.loadingSlug === msg.slug ? null : model.loadingSlug }, []]; - } - if (msg.source === 'stats') { - return [{ - ...model, - statsStatus: 'error', - statsError: msg.error, - }, []]; - } - if (msg.source === 'doctor') { - return [{ - ...model, - doctorStatus: 'error', - doctorError: msg.error, - }, []]; - } - if (msg.source === 'treemap') { - if (msg.scopeId && msg.scopeId !== model.treemapScope) { - return [model, []]; - } - return [{ - ...model, - treemapStatus: 'error', - treemapError: msg.error, - }, []]; - } - return [{ ...model, status: 'error', error: msg.error }, []]; + if (msg.scopeId && msg.scopeId !== model.treemapScope) { + return [model, []]; } - return [model, []]; + return addToast(applyLoadErrorState(msg, model), { + level: 'error', + title: loadErrorTitle(msg), + message: msg.error, + }); } /** @@ -985,6 +1053,7 @@ function handleLoadError(msg, model) { 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 === 'dismiss-toast') { return dismissToast(model, msg.id); } if (msg.type === 'load-error') { return handleLoadError(msg, model); } return handleLoadedReport(msg, model); } diff --git a/test/unit/cli/dashboard-cmds.test.js b/test/unit/cli/dashboard-cmds.test.js index 47ae799..d29c1c2 100644 --- a/test/unit/cli/dashboard-cmds.test.js +++ b/test/unit/cli/dashboard-cmds.test.js @@ -39,7 +39,7 @@ async function withTempRepo(run) { function makeRepoExec(repoDir, showRefOutput = '') { return async ({ args }) => { if (args[0] === 'rev-parse' && args[1] === '--git-dir') { - return '.git'; + throw new Error('Prohibited git flag detected: --git-dir'); } if (args[0] === 'rev-parse' && args[1] === '--is-bare-repository') { return 'false'; diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index 8b20663..117e5dd 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -95,6 +95,8 @@ function makeModel(overrides = {}) { treemapStatus: 'idle', treemapReport: null, treemapError: null, + toasts: [], + nextToastId: 1, ...overrides, }; } @@ -271,6 +273,21 @@ describe('dashboard drawer shortcuts', () => { const [next] = app.update(keyMsg('escape'), makeModel({ activeDrawer: 'stats', statsStatus: 'ready' })); expect(next.activeDrawer).toBeNull(); }); +}); + +describe('dashboard treemap shortcuts and toast dismissal', () => { + it('escape dismisses the latest toast when no overlay is open', () => { + const app = createDashboardApp(makeDeps()); + const [next] = app.update(keyMsg('escape'), makeModel({ + toasts: [ + { id: 2, level: 'warning', title: 'Heads up', message: 'yellow alert' }, + { id: 1, level: 'error', title: 'Failed to load repo treemap', message: 'boom' }, + ], + })); + expect(next.toasts).toEqual([ + { id: 1, level: 'error', title: 'Failed to load repo treemap', message: 'boom' }, + ]); + }); it('t opens the treemap drawer and queues a load', () => { const app = createDashboardApp(makeDeps()); @@ -333,7 +350,9 @@ describe('dashboard report loading', () => { expect(next.doctorReport).toEqual(report); expect(next.doctorError).toBeNull(); }); +}); +describe('dashboard treemap report and toast messages', () => { it('loaded-treemap stores the report for the active scope', () => { const app = createDashboardApp(makeDeps()); const report = { @@ -358,6 +377,20 @@ describe('dashboard report loading', () => { expect(next.treemapReport).toEqual(report); expect(next.treemapError).toBeNull(); }); + + it('dismiss-toast removes the matching toast', () => { + const app = createDashboardApp(makeDeps()); + const model = makeModel({ + toasts: [ + { id: 1, level: 'error', title: 'Failed to load entries', message: 'boom' }, + { id: 2, level: 'warning', title: 'Heads up', message: 'careful' }, + ], + }); + const [next] = app.update({ type: 'dismiss-toast', id: 1 }, model); + expect(next.toasts).toEqual([ + { id: 2, level: 'warning', title: 'Heads up', message: 'careful' }, + ]); + }); }); describe('dashboard filter mode', () => { @@ -400,9 +433,12 @@ describe('dashboard filter edge cases', () => { 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', 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(cmds).toHaveLength(1); }); }); @@ -410,9 +446,12 @@ 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', 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(cmds).toHaveLength(1); }); it('loaded-entries clamps table focus to filtered bounds', () => { @@ -581,4 +620,18 @@ describe('dashboard treemap and palette rendering', () => { 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: [ + { id: 2, level: 'warning', title: 'Heads up', message: 'yellow alert' }, + { 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'); + }); }); From d4db883738a8ca3573848cd73f82d8ea9916d473 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 18 Mar 2026 07:26:22 -0700 Subject: [PATCH 09/22] feat(ui): toggle treemap tracked and ignored views --- bin/ui/dashboard-cmds.js | 168 ++++++++++++++++++++-- bin/ui/dashboard-view.js | 5 +- bin/ui/dashboard.js | 206 ++++++++++++++++++++------- bin/ui/repo-treemap.js | 10 +- test/unit/cli/dashboard-cmds.test.js | 177 +++++++++++++++-------- test/unit/cli/dashboard.test.js | 45 +++++- 6 files changed, 477 insertions(+), 134 deletions(-) diff --git a/bin/ui/dashboard-cmds.js b/bin/ui/dashboard-cmds.js index bbcd714..27b8ac5 100644 --- a/bin/ui/dashboard-cmds.js +++ b/bin/ui/dashboard-cmds.js @@ -10,10 +10,12 @@ import { buildVaultStats, inspectVaultHealth } from './vault-report.js'; /** @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 {'worktree' | 'git' | 'ref' | 'vault' | 'cas' | 'meta'} RepoTreemapKind */ /** @typedef {{ label: string, kind: RepoTreemapKind, value: number, detail: string }} RepoTreemapTile */ /** @typedef {{ * scope: TreemapScope, + * worktreeMode: TreemapWorktreeMode, * cwd: string, * source: DashSource, * totalValue: number, @@ -23,6 +25,7 @@ import { buildVaultStats, inspectVaultHealth } from './vault-report.js'; * bare: boolean, * gitDir: string, * worktreeItems: number, + * worktreePaths: number, * refNamespaces: number, * refCount: number, * vaultEntries: number, @@ -151,6 +154,114 @@ async function measurePathBytes(targetPath) { return childSizes.reduce((sum, size) => sum + size, 0); } +/** + * Measure a single filesystem path selected by Git. + * + * Ignored-mode listings may collapse whole ignored directories, so that path + * needs a recursive byte count. Tracked listings should stay file-level. + * + * @param {string} targetPath + * @param {{ recurseDirectory?: boolean }} [options] + * @returns {Promise} + */ +async function measureListedPathBytes(targetPath, options = {}) { + let stat; + try { + stat = await lstat(targetPath); + } catch { + return 0; + } + if (stat.isDirectory() && !stat.isSymbolicLink() && options.recurseDirectory) { + return measurePathBytes(targetPath); + } + return stat.size; +} + +/** + * 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} + */ +function topLevelLabel(repoPath) { + const normalized = repoPath.replace(/\\/g, '/').replace(/\/+$/, ''); + if (!normalized) { + return ''; + } + return normalized.split('/')[0] ?? normalized; +} + +/** + * Aggregate top-level worktree tiles from Git-reported paths. + * + * @param {{ + * plumbing: { execute: ({ args }: { args: string[] }) => Promise }, + * repo: { cwd: string, bare: boolean }, + * worktreeMode: TreemapWorktreeMode, + * }} options + * @returns {Promise<{ tiles: RepoTreemapTile[], pathCount: number }>} + */ +async function readWorktreeTiles({ plumbing, repo, worktreeMode }) { + if (repo.bare) { + return { tiles: [], 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 { tiles: [], pathCount: 0 }; + } + + const repoPaths = parseNullPaths(output); + const buckets = new Map(); + + await Promise.all(repoPaths.map(async (repoPath) => { + const normalizedPath = repoPath.replace(/\/+$/, ''); + const label = topLevelLabel(normalizedPath); + if (!label || label === '.git') { + return; + } + + const fullPath = path.join(repo.cwd, normalizedPath); + const value = await measureListedPathBytes(fullPath, { + recurseDirectory: worktreeMode === 'ignored' && repoPath.endsWith('/'), + }); + const bucket = buckets.get(label) ?? { value: 0, count: 0 }; + bucket.value += value; + bucket.count += 1; + buckets.set(label, bucket); + })); + + const tiles = Array.from(buckets.entries()) + .map(([label, bucket]) => ({ + label, + kind: /** @type {const} */ ('worktree'), + value: Math.max(1, bucket.value), + detail: `${bucket.count} ${worktreeMode} path${bucket.count === 1 ? '' : 's'} · ${formatBytes(bucket.value)} on disk`, + })) + .sort((left, right) => right.value - left.value || left.label.localeCompare(right.label)); + + return { + tiles, + pathCount: repoPaths.length, + }; +} + /** * Build semantic tiles from the direct children of a directory. * @@ -322,6 +433,7 @@ function buildSourceScopeReport({ repo, source, sourceResult, sourceRecords }) { const totalValue = sourceTiles.reduce((sum, tile) => sum + tile.value, 0); return { scope: 'source', + worktreeMode: 'tracked', cwd: repo.cwd, source, totalValue, @@ -338,6 +450,7 @@ function buildSourceScopeReport({ repo, source, sourceResult, sourceRecords }) { bare: repo.bare, gitDir: repo.gitDir, worktreeItems: 0, + worktreePaths: 0, refNamespaces: 0, refCount: 0, vaultEntries: source.type === 'vault' ? sourceResult.entries.length : 0, @@ -356,13 +469,12 @@ function buildSourceScopeReport({ repo, source, sourceResult, sourceRecords }) { * plumbing: { execute: ({ args }: { args: string[] }) => Promise }, * sourceResult: { entries: ExplorerEntry[] }, * sourceRecords: Array, + * worktreeMode: TreemapWorktreeMode, * }} options * @returns {Promise} */ -async function buildRepositoryScopeReport({ cas, source, repo, plumbing, sourceResult, sourceRecords }) { - const worktreeTiles = repo.bare - ? [] - : await scanDirectoryTiles(repo.cwd, 'worktree', (name) => name).then((tiles) => tiles.filter((tile) => tile.label !== path.basename(repo.gitDir))); +async function buildRepositoryScopeReport({ cas, source, repo, plumbing, sourceResult, sourceRecords, worktreeMode }) { + const { tiles: worktreeTiles, pathCount: worktreePaths } = await readWorktreeTiles({ plumbing, repo, worktreeMode }); const gitTiles = await scanDirectoryTiles(repo.gitDir, 'git', (name) => repo.bare ? name : `.git/${name}`); const { tiles: refTiles, totalRefs } = await readRefNamespaceTiles(plumbing); const vaultResult = source.type === 'vault' ? sourceResult : await readSourceEntries(cas, { type: 'vault' }); @@ -382,6 +494,7 @@ async function buildRepositoryScopeReport({ cas, source, repo, plumbing, sourceR return { scope: 'repository', + worktreeMode, cwd: repo.cwd, source, totalValue, @@ -392,13 +505,17 @@ async function buildRepositoryScopeReport({ cas, source, repo, plumbing, sourceR detail: 'No worktree, ref, or CAS regions were detected', }], notes: [ - 'Repository view mixes worktree/.git bytes with logical CAS region sizes.', - repo.bare ? 'Bare repository: worktree regions are omitted.' : `Git dir ${repo.gitDir}`, + 'Repository view mixes Git-reported worktree paths, .git on-disk bytes, and logical CAS region sizes.', + 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}`, ], summary: { bare: repo.bare, gitDir: repo.gitDir, worktreeItems: worktreeTiles.length, + worktreePaths, refNamespaces: refTiles.length, refCount: totalRefs, vaultEntries: vaultResult.entries.length, @@ -615,11 +732,19 @@ export async function readSourceEntries(cas, source = { type: 'vault' }) { * Build the semantic repo/source treemap report for the dashboard. * * @param {ContentAddressableStore} cas - * @param {DashSource} [source] - * @param {TreemapScope} [scope] + * @param {{ + * source?: DashSource, + * scope?: TreemapScope, + * worktreeMode?: TreemapWorktreeMode, + * }} [options] * @returns {Promise} */ -export async function buildRepoTreemapReport(cas, source = { type: 'vault' }, scope = 'repository') { +export async function buildRepoTreemapReport(cas, options = {}) { + const { + source = { type: 'vault' }, + scope = 'repository', + worktreeMode = 'tracked', + } = options; const service = await cas.getService(); const repo = await resolveRepoInfo(service.persistence.plumbing); const sourceResult = await readSourceEntries(cas, source); @@ -635,6 +760,7 @@ export async function buildRepoTreemapReport(cas, source = { type: 'vault' }, sc plumbing: service.persistence.plumbing, sourceResult, sourceRecords, + worktreeMode, }); } @@ -723,16 +849,30 @@ export function loadDoctorCmd(cas, source = { type: 'vault' }, entries = []) { * Load the repository/source treemap report for the dashboard drawer. * * @param {ContentAddressableStore} cas - * @param {DashSource} [source] - * @param {TreemapScope} [scope] + * @param {{ + * source?: DashSource, + * scope?: TreemapScope, + * worktreeMode?: TreemapWorktreeMode, + * }} [options] */ -export function loadTreemapCmd(cas, source = { type: 'vault' }, scope = 'repository') { +export function loadTreemapCmd(cas, options = {}) { + const { + source = { type: 'vault' }, + scope = 'repository', + worktreeMode = 'tracked', + } = options; return async () => { try { - const report = await buildRepoTreemapReport(cas, source, scope); + const report = await buildRepoTreemapReport(cas, { source, scope, worktreeMode }); return /** @type {const} */ ({ type: 'loaded-treemap', report }); } catch (/** @type {any} */ err) { - return /** @type {const} */ ({ type: 'load-error', source: 'treemap', scopeId: scope, error: /** @type {Error} */ (err).message }); + return /** @type {const} */ ({ + type: 'load-error', + source: 'treemap', + scopeId: scope, + worktreeMode, + error: /** @type {Error} */ (err).message, + }); } }; } diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index d6e9f07..5874b48 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -130,6 +130,9 @@ function appendSelectionBadges(parts, model, ctx) { } if (model.activeDrawer === 'treemap') { parts.push(badge(`scope ${model.treemapScope}`, { variant: 'primary', ctx })); + if (model.treemapScope === 'repository') { + parts.push(badge(`files ${model.treemapWorktreeMode}`, { variant: 'accent', ctx })); + } } if (model.activeDrawer) { parts.push(badge(`${model.activeDrawer} drawer`, { variant: 'info', ctx })); @@ -633,7 +636,7 @@ function renderFooterSurface(ctx, width) { '─'.repeat(Math.max(1, width)), `${kbd('j/k', { ctx })} rows ${kbd('d/u', { ctx })} page ${kbd('J/K', { ctx })} scroll ${kbd('enter', { ctx })} inspect`, `${kbd('tab', { ctx })} pane ${kbd('H/L', { ctx })} resize ${kbd('ctrl+p', { ctx })} palette`, - `${kbd('s', { ctx })} stats ${kbd('g', { ctx })} doctor ${kbd('t', { ctx })} treemap ${kbd('T', { ctx })} scope ${kbd('esc', { ctx })} close ${kbd('q', { ctx })} quit`, + `${kbd('s', { ctx })} stats ${kbd('g', { ctx })} doctor ${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); } diff --git a/bin/ui/dashboard.js b/bin/ui/dashboard.js index da6f09d..0220240 100644 --- a/bin/ui/dashboard.js +++ b/bin/ui/dashboard.js @@ -24,6 +24,7 @@ import { renderDashboard } from './dashboard-view.js'; * @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 {{ slug: string, treeOid: string }} VaultEntry * @typedef {'error' | 'warning' | 'info' | 'success'} ToastLevel * @typedef {{ id: number, level: ToastLevel, title: string, message: string }} ToastRecord @@ -45,6 +46,7 @@ import { renderDashboard } from './dashboard-view.js'; * | { type: 'open-doctor' } * | { type: 'open-treemap' } * | { type: 'toggle-treemap-scope' } + * | { type: 'toggle-treemap-worktree' } * | { type: 'overlay-close' } * } DashAction */ @@ -66,7 +68,7 @@ import { renderDashboard } from './dashboard-view.js'; * | { type: 'loaded-doctor', report: any } * | { type: 'loaded-treemap', report: any } * | { type: 'dismiss-toast', id: number } - * | { type: 'load-error', source: string, slug?: string, scopeId?: TreemapScope, error: string } + * | { type: 'load-error', source: string, slug?: string, scopeId?: TreemapScope, worktreeMode?: TreemapWorktreeMode, error: string } * } DashMsg */ @@ -95,6 +97,7 @@ import { renderDashboard } from './dashboard-view.js'; * @property {any | null} doctorReport * @property {string | null} doctorError * @property {TreemapScope} treemapScope + * @property {TreemapWorktreeMode} treemapWorktreeMode * @property {LoadState} treemapStatus * @property {any | null} treemapReport * @property {string | null} treemapError @@ -138,6 +141,7 @@ export function createKeyBindings() { .bind('g', 'Doctor', { type: 'open-doctor' }) .bind('t', 'Treemap', { type: 'open-treemap' }) .bind('shift+t', 'Treemap scope', { type: 'toggle-treemap-scope' }) + .bind('i', 'Treemap files', { type: 'toggle-treemap-worktree' }) .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 }); @@ -177,6 +181,13 @@ const PALETTE_ITEMS = [ 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: 'stats', label: 'Open Source Stats', @@ -383,6 +394,22 @@ function dismissToast(model, id) { }, []]; } +/** + * Return true when a treemap load message is stale for the current model. + * + * @param {{ scopeId?: TreemapScope, worktreeMode?: TreemapWorktreeMode }} msg + * @param {DashModel} model + * @returns {boolean} + */ +function isStaleTreemapLoad(msg, model) { + if (msg.scopeId && msg.scopeId !== model.treemapScope) { + return true; + } + return msg.scopeId === 'repository' + && Boolean(msg.worktreeMode) + && msg.worktreeMode !== model.treemapWorktreeMode; +} + /** * Apply state changes caused by an async load error. * @@ -391,22 +418,18 @@ function dismissToast(model, id) { * @returns {DashModel} */ function applyLoadErrorState(msg, model) { - if (msg.source === 'manifest') { - return { ...model, loadingSlug: model.loadingSlug === msg.slug ? null : model.loadingSlug }; - } - if (msg.source === 'stats') { - return { ...model, statsStatus: 'error', statsError: msg.error }; - } - if (msg.source === 'doctor') { - return { ...model, doctorStatus: 'error', doctorError: msg.error }; - } - if (msg.source === 'treemap') { - if (msg.scopeId && msg.scopeId !== model.treemapScope) { - return model; - } - return { ...model, treemapStatus: 'error', treemapError: msg.error }; + switch (msg.source) { + case 'manifest': + return { ...model, loadingSlug: model.loadingSlug === msg.slug ? null : model.loadingSlug }; + case 'stats': + return { ...model, statsStatus: 'error', statsError: msg.error }; + case 'doctor': + return { ...model, doctorStatus: 'error', doctorError: msg.error }; + case 'treemap': + return isStaleTreemapLoad(msg, model) ? model : { ...model, treemapStatus: 'error', treemapError: msg.error }; + default: + return { ...model, status: 'error', error: msg.error }; } - return { ...model, status: 'error', error: msg.error }; } /** @@ -467,6 +490,7 @@ function createInitModel(ctx) { doctorReport: null, doctorError: null, treemapScope: 'repository', + treemapWorktreeMode: 'tracked', treemapStatus: 'idle', treemapReport: null, treemapError: null, @@ -678,7 +702,7 @@ function openDoctorDrawer(model, deps) { * @returns {[DashModel, DashCmd[]]} */ function openTreemapDrawer(model, deps) { - if (model.treemapStatus === 'ready' && model.treemapReport?.scope === model.treemapScope) { + if (treemapReportMatches(model, model.treemapReport)) { return [{ ...model, activeDrawer: 'treemap', @@ -698,7 +722,11 @@ function openTreemapDrawer(model, deps) { palette: null, treemapStatus: 'loading', treemapError: null, - }, [/** @type {DashCmd} */ (loadTreemapCmd(deps.cas, deps.source, model.treemapScope))]]; + }, [/** @type {DashCmd} */ (loadTreemapCmd(deps.cas, { + source: deps.source, + scope: model.treemapScope, + worktreeMode: model.treemapWorktreeMode, + }))]]; } /** @@ -710,7 +738,7 @@ function openTreemapDrawer(model, deps) { */ function toggleTreemapScope(model, deps) { const treemapScope = model.treemapScope === 'repository' ? 'source' : 'repository'; - if (model.treemapReport?.scope === treemapScope) { + if (treemapReportMatches({ ...model, treemapScope }, model.treemapReport)) { return [{ ...model, treemapScope, @@ -727,7 +755,48 @@ function toggleTreemapScope(model, deps) { palette: null, treemapStatus: 'loading', treemapError: null, - }, [/** @type {DashCmd} */ (loadTreemapCmd(deps.cas, deps.source, treemapScope))]]; + }, [/** @type {DashCmd} */ (loadTreemapCmd(deps.cas, { + source: deps.source, + scope: treemapScope, + worktreeMode: model.treemapWorktreeMode, + }))]]; +} + +/** + * Toggle repository treemap file visibility between tracked and ignored paths. + * + * This control is repository-specific, so switching visibility also returns the + * drawer 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, + activeDrawer: 'treemap', + palette: null, + }; + if (treemapReportMatches(nextModel, model.treemapReport)) { + return [{ + ...nextModel, + treemapStatus: 'ready', + treemapError: null, + }, []]; + } + return [{ + ...nextModel, + treemapStatus: 'loading', + treemapError: null, + }, [/** @type {DashCmd} */ (loadTreemapCmd(deps.cas, { + source: deps.source, + scope: 'repository', + worktreeMode: treemapWorktreeMode, + }))]]; } /** @@ -749,6 +818,35 @@ function closeOverlay(model) { 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 drawer from the command palette. + * + * @param {DashModel} model + * @returns {[DashModel, DashCmd[]]} + */ +function closeDrawerFromPalette(model) { + return [{ + ...model, + palette: null, + activeDrawer: null, + }, []]; +} + /** * Apply the focused command palette item. * @@ -761,38 +859,18 @@ function handlePaletteSelect(model, deps) { if (!item) { return [{ ...model, palette: null }, []]; } - if (item.id === 'treemap') { - return openTreemapDrawer(model, deps); - } - if (item.id === 'treemap-scope') { - return toggleTreemapScope(model, deps); - } - if (item.id === 'stats') { - return openStatsDrawer(model, deps); - } - if (item.id === 'doctor') { - return openDoctorDrawer(model, deps); - } - if (item.id === 'focus-entries') { - return [{ - ...model, - palette: null, - splitPane: { ...model.splitPane, focused: 'a' }, - }, []]; - } - if (item.id === 'focus-inspector') { - return [{ - ...model, - palette: null, - splitPane: { ...model.splitPane, focused: 'b' }, - }, []]; - } - if (item.id === 'close-drawer') { - return [{ - ...model, - palette: null, - activeDrawer: null, - }, []]; + const handlers = { + treemap: () => openTreemapDrawer(model, deps), + 'treemap-scope': () => toggleTreemapScope(model, deps), + 'treemap-worktree': () => toggleTreemapWorktreeMode(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 }, []]; } @@ -936,6 +1014,9 @@ function handleOverlayAction(action, model, deps) { if (action.type === 'toggle-treemap-scope') { return toggleTreemapScope(model, deps); } + if (action.type === 'toggle-treemap-worktree') { + return toggleTreemapWorktreeMode(model, deps); + } if (action.type === 'overlay-close') { return closeOverlay(model); } @@ -1011,7 +1092,7 @@ function handleLoadedReport(msg, model) { }, []]; } if (msg.type === 'loaded-treemap') { - if (msg.report.scope !== model.treemapScope) { + if (!treemapReportMatches(model, msg.report)) { return [model, []]; } return [{ @@ -1032,7 +1113,7 @@ function handleLoadedReport(msg, model) { * @returns {[DashModel, DashCmd[]]} */ function handleLoadError(msg, model) { - if (msg.scopeId && msg.scopeId !== model.treemapScope) { + if (msg.source === 'treemap' && isStaleTreemapLoad(msg, model)) { return [model, []]; } return addToast(applyLoadErrorState(msg, model), { @@ -1095,6 +1176,23 @@ function handleUpdate(msg, model, deps) { return handleAppMsg(/** @type {DashMsg} */ (msg), model, deps.cas); } +/** + * Return true when a treemap report matches the current drawer 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) { + return false; + } + if (report.scope !== 'repository') { + return true; + } + return report.worktreeMode === model.treemapWorktreeMode; +} + /** * Create the TEA app object for the dashboard. * diff --git a/bin/ui/repo-treemap.js b/bin/ui/repo-treemap.js index bdb66b9..b21b0a8 100644 --- a/bin/ui/repo-treemap.js +++ b/bin/ui/repo-treemap.js @@ -272,10 +272,16 @@ function renderDetails(tiles, width, lines) { export function renderRepoTreemap(report, options) { const width = Math.max(24, options.width); const height = Math.max(10, options.height); + const scopeLine = report.scope === 'repository' + ? `scope ${report.scope} files ${report.worktreeMode} source ${sourceLabel(report.source)}` + : `scope ${report.scope} source ${sourceLabel(report.source)}`; + const totalsLine = report.scope === 'repository' + ? `total ${formatBytes(report.totalValue)} ${report.worktreeMode} ${report.summary.worktreePaths} paths refs ${report.summary.refCount} source ${report.summary.sourceEntries}` + : `total ${formatBytes(report.totalValue)} refs ${report.summary.refCount} source ${report.summary.sourceEntries}`; const summaryLines = [ - clip(`scope ${report.scope} source ${sourceLabel(report.source)}`, width), + clip(scopeLine, width), clip(`root ${tailClip(report.cwd, Math.max(1, width - 5))}`, width), - clip(`total ${formatBytes(report.totalValue)} worktree ${report.summary.worktreeItems} refs ${report.summary.refCount} source ${report.summary.sourceEntries}`, width), + clip(totalsLine, width), ]; const legendLine = renderLegend(options.ctx, width); diff --git a/test/unit/cli/dashboard-cmds.test.js b/test/unit/cli/dashboard-cmds.test.js index d29c1c2..7f6e585 100644 --- a/test/unit/cli/dashboard-cmds.test.js +++ b/test/unit/cli/dashboard-cmds.test.js @@ -36,21 +36,47 @@ async function withTempRepo(run) { } } -function makeRepoExec(repoDir, showRefOutput = '') { +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 }) => { - if (args[0] === 'rev-parse' && args[1] === '--git-dir') { - throw new Error('Prohibited git flag detected: --git-dir'); - } - if (args[0] === 'rev-parse' && args[1] === '--is-bare-repository') { - return 'false'; - } - if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') { - return repoDir; - } - if (args[0] === 'show-ref') { - return showRefOutput; - } - throw new Error(`unexpected git command: ${args.join(' ')}`); + return repoExecResult(repoDir, options, args); }; } @@ -58,8 +84,12 @@ 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'); } @@ -93,6 +123,34 @@ function makeSourceTreemapCas(plumbing) { }; } +async function buildRepositoryReport({ source = { type: 'oid', treeOid: 'source-tree' }, worktreeMode = 'tracked' } = {}) { + 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); + const report = await buildRepoTreemapReport(cas, { source, scope: 'repository', worktreeMode }); + return { report, 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', + }); + }); +} + describe('readSourceEntries vault and oid modes', () => { it('loads vault entries through the vault service facade', async () => { const entries = [{ slug: 'alpha', treeOid: 'deadbeef' }]; @@ -200,53 +258,60 @@ describe('readSourceEntries commit message hints', () => { }); describe('buildRepoTreemapReport repository scope', () => { - it('builds a repository-scope atlas with worktree, git, ref, vault, and source regions', async () => { - await withTempRepo(async (repoDir) => { - await seedRepoLayout(repoDir); - const plumbing = makePlumbing(repoDir, makeRepoExec(repoDir, [ - '1111111111111111111111111111111111111111 refs/heads/main', - '2222222222222222222222222222222222222222 refs/warp/demo/seek-cache', - ].join('\n'))); - const cas = makeRepositoryTreemapCas(plumbing); - const report = await buildRepoTreemapReport(cas, { type: 'oid', treeOid: 'source-tree' }, 'repository'); - expect(report.scope).toBe('repository'); - expect(report.cwd).toBe(repoDir); - expect(report.summary.worktreeItems).toBeGreaterThan(0); - expect(report.summary.refCount).toBe(2); - expect(report.summary.vaultEntries).toBe(1); - expect(report.summary.sourceEntries).toBe(1); - const labels = []; - for (const tile of report.tiles) { - labels.push(tile.label); - } - expect(labels).toEqual(expect.arrayContaining([ - 'README.md', - 'src', - '.git/objects', - 'refs/heads', - 'refs/warp', - 'vault', - 'active source', - ])); + 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', + 'active source', + ])); + expect(labels).not.toContain('node_modules'); + expect(report.notes).toEqual(expect.arrayContaining([ + 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 source scope', () => { it('builds a source-scope treemap from logical source entries', async () => { - await withTempRepo(async (repoDir) => { - const plumbing = makePlumbing(repoDir, makeRepoExec(repoDir)); - const cas = makeSourceTreemapCas(plumbing); - const report = await buildRepoTreemapReport(cas, { type: 'oid', treeOid: 'feedfacecafebeef' }, 'source'); - expect(report.scope).toBe('source'); - expect(report.summary.sourceEntries).toBe(1); - expect(report.tiles).toEqual([ - expect.objectContaining({ - label: 'oid:feedfacecafe', - kind: 'cas', - }), - ]); - expect(report.notes[0]).toContain('logical manifest size'); - }); + 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[0]).toContain('logical manifest size'); }); }); diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index 117e5dd..a647d47 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -92,6 +92,7 @@ function makeModel(overrides = {}) { doctorReport: null, doctorError: null, treemapScope: 'repository', + treemapWorktreeMode: 'tracked', treemapStatus: 'idle', treemapReport: null, treemapError: null, @@ -144,19 +145,24 @@ function makeDoctorReport() { function makeTreemapReport(overrides = {}) { return { scope: 'repository', + worktreeMode: 'tracked', cwd: '/tmp/git-cas-fixture', source: { type: 'vault' }, totalValue: 8192, tiles: [ - { label: 'src', kind: 'worktree', value: 4096, detail: '4.0K on disk' }, + { label: 'src', kind: 'worktree', value: 4096, detail: '2 tracked paths · 4.0K on disk' }, { label: '.git/objects', kind: 'git', value: 2048, detail: '2.0K on disk' }, { label: 'vault', kind: 'vault', value: 2048, detail: '2 entries · 2.0K logical' }, ], - notes: ['Repository view mixes worktree/.git bytes with logical CAS region sizes.'], + notes: [ + 'Repository view mixes Git-reported worktree paths, .git on-disk bytes, and logical CAS region sizes.', + 'Worktree mode tracked via git ls-files.', + ], summary: { bare: false, gitDir: '/tmp/git-cas-fixture/.git', worktreeItems: 1, + worktreePaths: 2, refNamespaces: 1, refCount: 3, vaultEntries: 2, @@ -250,7 +256,8 @@ describe('dashboard palette and overlay commands', () => { const app = createDashboardApp(makeDeps()); const [withPalette] = app.update(keyMsg('p', { ctrl: true }), makeModel()); const [onTreemap] = app.update(keyMsg('down'), withPalette); - const [onStats] = app.update(keyMsg('down'), onTreemap); + const [onTreemapScope] = app.update(keyMsg('down'), onTreemap); + const [onStats] = app.update(keyMsg('down'), onTreemapScope); const [next, cmds] = app.update(keyMsg('enter'), onStats); expect(next.palette).toBeNull(); expect(next.activeDrawer).toBe('stats'); @@ -275,7 +282,7 @@ describe('dashboard drawer shortcuts', () => { }); }); -describe('dashboard treemap shortcuts and toast dismissal', () => { +describe('dashboard toast dismissal', () => { it('escape dismisses the latest toast when no overlay is open', () => { const app = createDashboardApp(makeDeps()); const [next] = app.update(keyMsg('escape'), makeModel({ @@ -288,7 +295,9 @@ describe('dashboard treemap shortcuts and toast dismissal', () => { { id: 1, level: 'error', title: 'Failed to load repo treemap', message: 'boom' }, ]); }); +}); +describe('dashboard treemap shortcuts', () => { it('t opens the treemap drawer and queues a load', () => { const app = createDashboardApp(makeDeps()); const [next, cmds] = app.update(keyMsg('t'), makeModel()); @@ -311,6 +320,23 @@ describe('dashboard treemap shortcuts and toast dismissal', () => { 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); + }); }); describe('dashboard data loading', () => { @@ -357,6 +383,7 @@ describe('dashboard treemap report and toast messages', () => { const app = createDashboardApp(makeDeps()); const report = { scope: 'repository', + worktreeMode: 'tracked', cwd: '/tmp/git-cas-fixture', source: { type: 'vault' }, totalValue: 2048, @@ -366,6 +393,7 @@ describe('dashboard treemap report and toast messages', () => { bare: false, gitDir: '/tmp/git-cas-fixture/.git', worktreeItems: 1, + worktreePaths: 1, refNamespaces: 1, refCount: 2, vaultEntries: 1, @@ -533,7 +561,7 @@ describe('dashboard footer and inspector rendering', () => { it('renders footer keybinding hints', () => { const deps = makeDeps(); const app = createDashboardApp(deps); - const model = makeModel(); + const model = makeModel({ columns: 120 }); const rendered = renderView(app.view(model), deps.ctx); expect(rendered).toContain('inspect'); expect(rendered).toContain('resize'); @@ -543,7 +571,8 @@ describe('dashboard footer and inspector rendering', () => { expect(rendered).toContain('doctor'); expect(rendered).toContain('treemap'); expect(rendered).toContain('scope'); - expect(rendered).toContain('close'); + expect(rendered).toContain('files'); + expect(rendered).toContain('clos'); expect(rendered).toContain('quit'); }); @@ -606,8 +635,10 @@ describe('dashboard treemap and palette rendering', () => { const rendered = renderView(app.view(model), deps.ctx); expect(rendered).toContain('Repo Treemap'); expect(rendered).toContain('scope repository'); + expect(rendered).toContain('files tracked'); expect(rendered).toContain('legend'); - expect(rendered).toContain('Repository view mixes worktree/.git bytes with logical CAS region sizes.'); + expect(rendered).toContain('tracked 2 paths'); + expect(rendered).toContain('Repository view mixes Git-reported worktree paths'); }); it('renders the palette badge when the command palette is open', () => { From 0bde52e74232432e9a948b1cd21a21dcd490bdbf Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 18 Mar 2026 07:46:48 -0700 Subject: [PATCH 10/22] feat(ui): make treemap a full-screen workspace --- bin/ui/dashboard-view.js | 164 +++++++++++++++------ bin/ui/dashboard.js | 67 +++++++-- bin/ui/repo-treemap.js | 248 ++++++++++++++++++++++++++------ test/unit/cli/dashboard.test.js | 68 ++++++--- 4 files changed, 433 insertions(+), 114 deletions(-) diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index 5874b48..3c026df 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -4,7 +4,7 @@ import { badge, boxV3, createSurface, parseAnsiToSurface, kbd } from '@flyingrobots/bijou'; import { commandPalette, navigableTable, splitPaneLayout } from '@flyingrobots/bijou-tui'; -import { renderRepoTreemap } from './repo-treemap.js'; +import { renderRepoTreemapMap, renderRepoTreemapSidebar } from './repo-treemap.js'; import { renderDoctorReport, renderVaultStats } from './vault-report.js'; import { renderManifestView } from './manifest-view.js'; @@ -108,7 +108,11 @@ function headerParts(model, ctx) { if (model.filtering || model.filterText) { parts.push(badge(model.filtering ? 'filtering' : `filter ${model.filterText}`, { variant: 'accent', ctx })); } - parts.push(badge(`pane ${model.splitPane.focused === 'a' ? 'entries' : 'inspector'}`, { variant: 'primary', ctx })); + if (model.activeDrawer === 'treemap') { + parts.push(badge('treemap view', { variant: 'primary', ctx })); + } else { + parts.push(badge(`pane ${model.splitPane.focused === 'a' ? 'entries' : 'inspector'}`, { variant: 'primary', ctx })); + } appendSelectionBadges(parts, model, ctx); return parts; } @@ -122,7 +126,7 @@ function headerParts(model, ctx) { */ function appendSelectionBadges(parts, model, ctx) { const selected = model.filtered[model.table.focusRow]; - if (selected) { + if (selected && model.activeDrawer !== 'treemap') { parts.push(badge(`selected ${selected.slug}`, { variant: 'accent', ctx })); } if (model.toasts.length > 0) { @@ -134,7 +138,7 @@ function appendSelectionBadges(parts, model, ctx) { parts.push(badge(`files ${model.treemapWorktreeMode}`, { variant: 'accent', ctx })); } } - if (model.activeDrawer) { + if (model.activeDrawer && model.activeDrawer !== 'treemap') { parts.push(badge(`${model.activeDrawer} drawer`, { variant: 'info', ctx })); } if (model.palette) { @@ -356,33 +360,16 @@ function renderDoctorDrawer(model, opts) { } /** - * Render the repository treemap drawer. + * Render a boxed panel surface. * - * @param {DashModel} model - * @param {{ width: number, height: number, ctx: BijouContext }} opts + * @param {{ title: string, body: string, width: number, height: number, ctx: BijouContext }} options * @returns {Surface} */ -function renderTreemapDrawer(model, opts) { - const width = Math.max(42, Math.min(78, opts.width - 2)); - const height = Math.max(10, Math.min(22, opts.height)); - let body = 'Treemap has not been loaded yet.'; - if (model.treemapStatus === 'loading') { - body = `Loading ${model.treemapScope} treemap...`; - } else if (model.treemapStatus === 'error') { - body = `Failed to load treemap\n\n${model.treemapError ?? 'unknown error'}`; - } else if (model.treemapReport) { - body = renderRepoTreemap(model.treemapReport, { - ctx: opts.ctx, - width: Math.max(16, width - 2), - height: Math.max(6, height - 2), - }); - } - return renderOverlayPanel({ - title: 'Repo Treemap', - body, - width, - height, - ctx: opts.ctx, +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, }); } @@ -394,12 +381,9 @@ function renderTreemapDrawer(model, opts) { * @returns {Surface | null} */ function renderDrawerSurface(model, opts) { - if (!model.activeDrawer) { + if (!model.activeDrawer || model.activeDrawer === 'treemap') { return null; } - if (model.activeDrawer === 'treemap') { - return renderTreemapDrawer(model, opts); - } return model.activeDrawer === 'stats' ? renderStatsDrawer(model, opts) : renderDoctorDrawer(model, opts); @@ -624,20 +608,116 @@ function renderDetailPane(model, opts) { }); } +/** + * Compose sidebar copy for the full-screen treemap view. + * + * @param {{ model: DashModel, deps: DashDeps, width: number, height: number }} options + * @returns {string} + */ +function renderTreemapSidebarText(options) { + if (options.model.treemapStatus === 'loading') { + return `Overview\nLoading ${options.model.treemapScope} treemap...`; + } + if (options.model.treemapStatus === 'error') { + return `Overview\nFailed to load treemap\n\n${options.model.treemapError ?? 'unknown error'}`; + } + if (!options.model.treemapReport) { + return 'Overview\nTreemap has not been loaded yet.'; + } + const sections = renderRepoTreemapSidebar(options.model.treemapReport, { + ctx: options.deps.ctx, + width: Math.max(16, options.width), + height: options.height, + }); + return [ + 'Overview', + sections.overview, + '', + 'Legend', + sections.legend, + '', + 'Largest Regions', + sections.regions || 'No regions to display.', + '', + 'Notes', + sections.notes || 'No notes.', + ].join('\n'); +} + +/** + * 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 mapBody = model.treemapStatus === 'loading' + ? `Loading ${model.treemapScope} treemap...` + : model.treemapStatus === 'error' + ? `Failed to load treemap\n\n${model.treemapError ?? 'unknown error'}` + : model.treemapReport + ? renderRepoTreemapMap(model.treemapReport, { + ctx: deps.ctx, + width: Math.max(8, mapWidth - 2), + height: Math.max(4, mapHeight - 2), + }) + : 'Treemap has not been loaded yet.'; + + const mapTitle = model.treemapScope === 'repository' ? 'Repository Map' : 'Source Map'; + const mapPanel = renderPanel({ + title: mapTitle, + body: mapBody, + width: mapWidth, + height: mapHeight, + ctx: deps.ctx, + }); + const sidebarPanel = renderPanel({ + title: 'Treemap Details', + 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(ctx, width) { - const lines = [ - '─'.repeat(Math.max(1, width)), - `${kbd('j/k', { ctx })} rows ${kbd('d/u', { ctx })} page ${kbd('J/K', { ctx })} scroll ${kbd('enter', { ctx })} inspect`, - `${kbd('tab', { ctx })} pane ${kbd('H/L', { ctx })} resize ${kbd('ctrl+p', { ctx })} palette`, - `${kbd('s', { ctx })} stats ${kbd('g', { ctx })} doctor ${kbd('t', { ctx })} treemap ${kbd('T', { ctx })} scope ${kbd('i', { ctx })} files ${kbd('esc', { ctx })} close ${kbd('q', { ctx })} quit`, - ]; +function renderFooterSurface(model, ctx, width) { + const lines = model.activeDrawer === 'treemap' + ? [ + '─'.repeat(Math.max(1, width)), + `${kbd('T', { ctx })} scope ${kbd('i', { ctx })} files ${kbd('ctrl+p', { ctx })} palette`, + `${kbd('s', { ctx })} stats ${kbd('g', { ctx })} doctor ${kbd('esc', { ctx })} back ${kbd('q', { ctx })} quit`, + '', + ] + : [ + '─'.repeat(Math.max(1, width)), + `${kbd('j/k', { ctx })} rows ${kbd('d/u', { ctx })} page ${kbd('J/K', { ctx })} scroll ${kbd('enter', { ctx })} inspect`, + `${kbd('tab', { ctx })} pane ${kbd('H/L', { ctx })} resize ${kbd('ctrl+p', { ctx })} palette`, + `${kbd('s', { ctx })} stats ${kbd('g', { ctx })} doctor ${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); } @@ -649,6 +729,10 @@ function renderFooterSurface(ctx, width) { * @param {{ top: number, height: number, screen: Surface }} options */ function renderBody(model, deps, options) { + if (model.activeDrawer === 'treemap') { + renderTreemapView(model, deps, options); + return; + } const layout = splitPaneLayout(model.splitPane, { direction: 'row', width: model.columns, @@ -708,7 +792,7 @@ export function renderDashboard(model, deps) { const height = Math.max(1, model.rows); const screen = createSurface(width, height); const header = renderHeaderSurface(model, deps); - const footer = renderFooterSurface(deps.ctx, width); + const footer = renderFooterSurface(model, deps.ctx, width); const bodyTop = header.height; const bodyHeight = Math.max(1, height - header.height - footer.height); diff --git a/bin/ui/dashboard.js b/bin/ui/dashboard.js index 0220240..c844e5b 100644 --- a/bin/ui/dashboard.js +++ b/bin/ui/dashboard.js @@ -170,7 +170,7 @@ const PALETTE_ITEMS = [ { id: 'treemap', label: 'Open Repo Treemap', - description: 'Semantic atlas of the repo, refs, vault, and active source', + description: 'Full-screen semantic atlas of the repo, refs, vault, and active source', category: 'View', shortcut: 't', }, @@ -217,8 +217,8 @@ const PALETTE_ITEMS = [ }, { id: 'close-drawer', - label: 'Close Active Drawer', - description: 'Dismiss the stats or doctor overlay', + label: 'Close Active View', + description: 'Leave treemap view or dismiss the stats or doctor overlay', category: 'View', shortcut: 'esc', }, @@ -695,7 +695,7 @@ function openDoctorDrawer(model, deps) { } /** - * Open the repo treemap drawer and trigger a load when needed. + * Open the repo treemap view and trigger a load when needed. * * @param {DashModel} model * @param {DashDeps} deps @@ -766,7 +766,7 @@ function toggleTreemapScope(model, deps) { * Toggle repository treemap file visibility between tracked and ignored paths. * * This control is repository-specific, so switching visibility also returns the - * drawer to repository scope when needed. + * view to repository scope when needed. * * @param {DashModel} model * @param {DashDeps} deps @@ -800,7 +800,7 @@ function toggleTreemapWorktreeMode(model, deps) { } /** - * Close the command palette or active drawer, whichever is visible. + * Close the command palette or active view, whichever is visible. * * @param {DashModel} model * @returns {[DashModel, DashCmd[]]} @@ -834,7 +834,7 @@ function focusPane(model, pane) { } /** - * Close the active drawer from the command palette. + * Close the active view from the command palette. * * @param {DashModel} model * @returns {[DashModel, DashCmd[]]} @@ -1047,6 +1047,46 @@ function handleLayoutAction(action, model) { 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. * @@ -1056,10 +1096,13 @@ function handleLayoutAction(action, model) { * @returns {[DashModel, DashCmd[]]} */ function handleAction(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); } + 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); @@ -1177,7 +1220,7 @@ function handleUpdate(msg, model, deps) { } /** - * Return true when a treemap report matches the current drawer state. + * 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 diff --git a/bin/ui/repo-treemap.js b/bin/ui/repo-treemap.js index b21b0a8..cd431bc 100644 --- a/bin/ui/repo-treemap.js +++ b/bin/ui/repo-treemap.js @@ -1,5 +1,5 @@ /** - * Render a semantic repository treemap for the dashboard drawer. + * Render a semantic repository treemap for the dashboard. */ /** @@ -27,6 +27,15 @@ const TILE_FILL = { meta: '░', }; +const TILE_LABEL = { + worktree: 'worktree', + git: 'git', + ref: 'refs', + vault: 'vault', + cas: 'source', + meta: 'other', +}; + /** * Format bytes as a compact human-readable string. * @@ -96,6 +105,54 @@ function tailClip(text, width) { return `...${text.slice(text.length - (width - 3))}`; } +/** + * Wrap plain text into fixed-width chunks. + * + * @param {string} text + * @param {number} width + * @returns {string[]} + */ +function wrapText(text, width) { + if (width <= 0) { + return ['']; + } + const chunkPattern = new RegExp(`.{1,${Math.max(1, width)}}`, 'g'); + return text + .split('\n') + .flatMap((line) => line.length === 0 ? [''] : (line.match(chunkPattern) ?? [''])); +} + +/** + * 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)}%`; +} + /** * Group tiles into a binary split that approximates a treemap. * @@ -169,6 +226,51 @@ 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'] }} cell + */ +function putCell(grid, cell) { + if (grid[cell.row]?.[cell.col]) { + grid[cell.row][cell.col] = { ch: cell.ch, kind: cell.kind }; + } +} + +/** + * 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 + */ +function outlineRect(grid, rect) { + if (rect.width < 2 || rect.height < 2) { + return; + } + 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: '─', kind: rect.tile.kind }); + putCell(grid, { row: bottom, col, ch: '─', kind: rect.tile.kind }); + } + for (let row = top + 1; row < bottom; row++) { + putCell(grid, { row, col: left, ch: '│', kind: rect.tile.kind }); + putCell(grid, { row, col: right, ch: '│', kind: rect.tile.kind }); + } + putCell(grid, { row: top, col: left, ch: '┌', kind: rect.tile.kind }); + putCell(grid, { row: top, col: right, ch: '┐', kind: rect.tile.kind }); + putCell(grid, { row: bottom, col: left, ch: '└', kind: rect.tile.kind }); + putCell(grid, { row: bottom, col: right, ch: '┘', kind: rect.tile.kind }); +} + /** * Overlay a centered tile label on a painted rectangle. * @@ -176,10 +278,10 @@ function createGrid(width, height) { * @param {{ tile: RepoTreemapTile, x: number, y: number, width: number, height: number }} rect */ function paintLabel(grid, rect) { - if (rect.width < 4 || rect.height < 2) { + if (rect.width < 6 || rect.height < 2) { return; } - const label = clip(rect.tile.label, rect.width - 1); + 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++) { @@ -206,6 +308,7 @@ function paintRect(grid, rect) { } } } + outlineRect(grid, rect); paintLabel(grid, rect); } @@ -233,72 +336,125 @@ function renderGrid(grid, ctx) { * @param {number} width * @returns {string} */ -function renderLegend(ctx, width) { - const parts = [ - ['worktree', 'worktree'], - ['git', 'git'], - ['ref', 'refs'], - ['vault', 'vault'], - ['cas', 'source'], - ].map(([kind, label]) => { - const fill = TILE_FILL[/** @type {keyof typeof TILE_FILL} */ (kind)]; - const color = TILE_COLOR[/** @type {keyof typeof TILE_COLOR} */ (kind)]; - return `${ctx.style.rgb(color[0], color[1], color[2], fill)} ${label}`; - }); - return clip(`legend ${parts.join(' ')}`, width); +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 below the treemap. + * Render the most important tile details. * * @param {RepoTreemapTile[]} tiles - * @param {number} width - * @param {number} lines + * @param {{ totalValue: number, width: number, lines: number }} options * @returns {string[]} */ -function renderDetails(tiles, width, lines) { +function renderDetails(tiles, options) { return tiles - .slice(0, Math.max(0, lines)) - .map((tile) => clip(`${tile.label} ${tile.detail}`, width)); + .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 a repository treemap as ANSI-aware text. + * Render repo/source overview lines for the sidebar. * * @param {RepoTreemapReport} report - * @param {{ ctx: BijouContext, width: number, height: number }} options - * @returns {string} + * @param {number} width + * @returns {string[]} */ -export function renderRepoTreemap(report, options) { - const width = Math.max(24, options.width); - const height = Math.max(10, options.height); - const scopeLine = report.scope === 'repository' - ? `scope ${report.scope} files ${report.worktreeMode} source ${sourceLabel(report.source)}` - : `scope ${report.scope} source ${sourceLabel(report.source)}`; - const totalsLine = report.scope === 'repository' - ? `total ${formatBytes(report.totalValue)} ${report.worktreeMode} ${report.summary.worktreePaths} paths refs ${report.summary.refCount} source ${report.summary.sourceEntries}` - : `total ${formatBytes(report.totalValue)} refs ${report.summary.refCount} source ${report.summary.sourceEntries}`; - const summaryLines = [ - clip(scopeLine, width), +function renderOverview(report, width) { + const worktreeLabel = report.scope === 'repository' + ? `${report.worktreeMode} paths ${report.summary.worktreePaths}` + : 'logical source weighting'; + return [ + clip(`scope ${report.scope}`, width), + clip(`source ${sourceLabel(report.source)}`, width), clip(`root ${tailClip(report.cwd, Math.max(1, width - 5))}`, width), - clip(totalsLine, width), + clip(`total ${formatBytes(report.totalValue)}`, width), + clip(worktreeLabel, 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), ]; +} - const legendLine = renderLegend(options.ctx, width); - const noteLines = report.notes.map((note) => clip(note, width)); - const detailRows = Math.min(4, Math.max(1, height - 8)); - const gridHeight = Math.max(4, height - summaryLines.length - detailRows - noteLines.length - 2); - const grid = createGrid(width, gridHeight); - const layout = layoutTreemap(report.tiles, { x: 0, y: 0, width, height: gridHeight }); +/** + * 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 }} 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(report.tiles, { x: 0, y: 0, width, height }); for (const rect of layout) { paintRect(grid, rect); } + return renderGrid(grid, options.ctx).join('\n'); +} +/** + * Build text sections for the treemap sidebar. + * + * @param {RepoTreemapReport} report + * @param {{ ctx: BijouContext, width: number, height: number }} options + * @returns {{ overview: string, legend: 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'), + 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 }} 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, - ...renderGrid(grid, options.ctx), - legendLine, - ...renderDetails(report.tiles, width, detailRows), + ...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/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index a647d47..72c5da5 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -172,6 +172,33 @@ function makeTreemapReport(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: [ + { label: 'src', kind: 'worktree', value: 4096, detail: '2 tracked paths · 4.0K on disk' }, + { label: '.git/objects', kind: 'git', value: 2048, detail: '2.0K on disk' }, + { label: 'other', kind: 'meta', value: 1024, detail: '2 smaller regions' }, + ], + }), + columns: 120, + rows: 36, + }; +} + describe('dashboard initialization', () => { it('init returns loading model with one cmd', () => { const app = createDashboardApp(makeDeps()); @@ -298,7 +325,7 @@ describe('dashboard toast dismissal', () => { }); describe('dashboard treemap shortcuts', () => { - it('t opens the treemap drawer and queues a load', () => { + 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'); @@ -557,7 +584,7 @@ describe('dashboard view rendering', () => { }); }); -describe('dashboard footer and inspector rendering', () => { +describe('dashboard footer rendering', () => { it('renders footer keybinding hints', () => { const deps = makeDeps(); const app = createDashboardApp(deps); @@ -576,6 +603,18 @@ describe('dashboard footer and inspector rendering', () => { 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'); + }); +}); + +describe('dashboard inspector rendering', () => { it('renders selected asset summary in the inspector pane', () => { const deps = makeDeps(); const app = createDashboardApp(deps); @@ -623,22 +662,19 @@ describe('dashboard report overlay rendering', () => { }); describe('dashboard treemap and palette rendering', () => { - it('renders the treemap drawer overlay', () => { - const deps = makeDeps(); - const app = createDashboardApp(deps); - const model = makeModel({ - activeDrawer: 'treemap', - treemapScope: 'repository', - treemapStatus: 'ready', - treemapReport: makeTreemapReport(), - }); - const rendered = renderView(app.view(model), deps.ctx); - expect(rendered).toContain('Repo Treemap'); + it('renders the treemap as a full-screen view with a details sidebar', () => { + const { rendered } = renderDashboardWithModel(makeFullScreenTreemapModel()); + expect(rendered).toContain('treemap view'); + expect(rendered).toContain('Repository Map'); + expect(rendered).toContain('Treemap Details'); + expect(rendered).toContain('Overview'); + expect(rendered).toContain('Legend'); + expect(rendered).toContain('Largest Regions'); expect(rendered).toContain('scope repository'); expect(rendered).toContain('files tracked'); - expect(rendered).toContain('legend'); - expect(rendered).toContain('tracked 2 paths'); - expect(rendered).toContain('Repository view mixes Git-reported worktree paths'); + expect(rendered).toContain('other'); + expect(rendered).toContain('tracked paths 2'); + expect(rendered).toContain('Repository view mixes Git-reported'); }); it('renders the palette badge when the command palette is open', () => { From f2fba591c6d58efc1ee92d8042d8f2ecffeab543 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 18 Mar 2026 14:41:16 -0700 Subject: [PATCH 11/22] fix(ui): correct treemap region layout --- bin/ui/dashboard-cmds.js | 9 ++-- bin/ui/repo-treemap.js | 40 +++++++++++++----- test/unit/cli/repo-treemap.test.js | 66 ++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 test/unit/cli/repo-treemap.test.js diff --git a/bin/ui/dashboard-cmds.js b/bin/ui/dashboard-cmds.js index 27b8ac5..f848876 100644 --- a/bin/ui/dashboard-cmds.js +++ b/bin/ui/dashboard-cmds.js @@ -381,11 +381,12 @@ function buildLogicalTiles(records, kind) { * @returns {RepoTreemapTile[]} */ function compactTiles(tiles, limit = 14) { - if (tiles.length <= limit) { - return tiles; + const sorted = [...tiles].sort((left, right) => right.value - left.value || left.label.localeCompare(right.label)); + if (sorted.length <= limit) { + return sorted; } - const kept = tiles.slice(0, limit - 1); - const remainder = tiles.slice(limit - 1); + 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({ label: 'other', diff --git a/bin/ui/repo-treemap.js b/bin/ui/repo-treemap.js index cd431bc..d664777 100644 --- a/bin/ui/repo-treemap.js +++ b/bin/ui/repo-treemap.js @@ -153,6 +153,17 @@ function formatPercent(value, total) { 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. * @@ -160,19 +171,26 @@ function formatPercent(value, total) { * @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; - const left = []; - let leftSum = 0; - for (let index = 0; index < tiles.length; index++) { - const tile = tiles[index]; - if (index > 0 && leftSum >= target) { - return [left, tiles.slice(index)]; + 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; } - left.push(tile); - leftSum += tile.value; } - return [left, []]; + + return [tiles.slice(0, bestIndex), tiles.slice(bestIndex)]; } /** @@ -353,7 +371,7 @@ function renderLegendLines(ctx, width) { * @returns {string[]} */ function renderDetails(tiles, options) { - return tiles + 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}`, @@ -407,7 +425,7 @@ 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(report.tiles, { x: 0, y: 0, width, height }); + const layout = layoutTreemap(sortTilesByValue(report.tiles), { x: 0, y: 0, width, height }); for (const rect of layout) { paintRect(grid, rect); } diff --git a/test/unit/cli/repo-treemap.test.js b/test/unit/cli/repo-treemap.test.js new file mode 100644 index 0000000..374d770 --- /dev/null +++ b/test/unit/cli/repo-treemap.test.js @@ -0,0 +1,66 @@ +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' }, + totalValue: 21_400_000, + tiles: [ + { label: 'docs', kind: 'worktree', value: 3_638_000, detail: '33 tracked paths · 3.5M on disk' }, + { label: 'public', kind: 'worktree', value: 107_000, detail: '5 tracked paths · 104.5K on disk' }, + { label: 'package-lock.json', kind: 'worktree', value: 107_000, detail: '1 tracked path · 104.5K on disk' }, + { label: 'test', kind: 'worktree', value: 64_000, detail: '17 tracked paths · 62.5K on disk' }, + { label: 'pnpm-lock.yaml', kind: 'worktree', value: 64_000, detail: '1 tracked path · 62.5K on disk' }, + { label: 'src', kind: 'worktree', value: 43_000, detail: '6 tracked paths · 42.0K on disk' }, + { label: 'scripts', kind: 'worktree', value: 21_000, detail: '13 tracked paths · 20.5K on disk' }, + { label: '.git/objects', kind: 'git', value: 8_000_000, detail: '7.6M on disk' }, + { label: 'refs/git-cms', kind: 'ref', value: 2_000_000, detail: '17 refs' }, + { label: 'vault', kind: 'vault', value: 1_500_000, detail: '2 entries · 1.4M logical' }, + ], + notes: [ + 'Repository view mixes Git-reported worktree paths, .git on-disk bytes, 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, + }; +} + +describe('repo treemap 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('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'); + }); +}); From 99ec46b6468980191a3b451ef79758f0e1e9aae2 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 18 Mar 2026 21:26:02 -0700 Subject: [PATCH 12/22] fix(ui): improve treemap label contrast --- bin/ui/repo-treemap.js | 91 ++++++++++++++++++++++++++++-- test/unit/cli/repo-treemap.test.js | 52 ++++++++++++++++- 2 files changed, 136 insertions(+), 7 deletions(-) diff --git a/bin/ui/repo-treemap.js b/bin/ui/repo-treemap.js index d664777..3b9f014 100644 --- a/bin/ui/repo-treemap.js +++ b/bin/ui/repo-treemap.js @@ -105,6 +105,65 @@ function tailClip(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. * @@ -116,10 +175,9 @@ function wrapText(text, width) { if (width <= 0) { return ['']; } - const chunkPattern = new RegExp(`.{1,${Math.max(1, width)}}`, 'g'); return text .split('\n') - .flatMap((line) => line.length === 0 ? [''] : (line.match(chunkPattern) ?? [''])); + .flatMap((line) => wrapLine(line, width)); } /** @@ -238,7 +296,7 @@ function layoutTreemap(tiles, rect, vertical = rect.width >= rect.height) { * * @param {number} width * @param {number} height - * @returns {Array>} + * @returns {Array>} */ function createGrid(width, height) { return Array.from({ length: height }, () => Array.from({ length: width }, () => ({ ch: ' ', kind: null }))); @@ -248,14 +306,26 @@ function createGrid(width, height) { * Write one cell when it falls inside the current grid. * * @param {ReturnType} grid - * @param {{ row: number, col: number, ch: string, kind: RepoTreemapTile['kind'] }} cell + * @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 }; + grid[cell.row][cell.col] = { ch: cell.ch, kind: cell.kind, label: cell.label ?? false }; } } +/** + * Pick a high-contrast foreground color for a tile background. + * + * @param {[number, number, number]} color + * @returns {[number, number, number]} + */ +function contrastColor(color) { + const [red, green, blue] = color; + const brightness = ((red * 299) + (green * 587) + (blue * 114)) / 1000; + return brightness >= 160 ? [0, 0, 0] : [255, 255, 255]; +} + /** * Paint a visible outline around a tile rectangle. * @@ -305,7 +375,7 @@ function paintLabel(grid, rect) { 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 }; + grid[labelRow][startCol + index] = { ch: label[index], kind: rect.tile.kind, label: true }; } } } @@ -343,6 +413,15 @@ function renderGrid(grid, ctx) { return cell.ch; } const color = TILE_COLOR[cell.kind] ?? TILE_COLOR.meta; + if (cell.label) { + const foreground = contrastColor(color); + return ctx.style.bgRgb( + color[0], + color[1], + color[2], + ctx.style.rgb(foreground[0], foreground[1], foreground[2], cell.ch), + ); + } return ctx.style.rgb(color[0], color[1], color[2], cell.ch); }).join('')); } diff --git a/test/unit/cli/repo-treemap.test.js b/test/unit/cli/repo-treemap.test.js index 374d770..16bf37c 100644 --- a/test/unit/cli/repo-treemap.test.js +++ b/test/unit/cli/repo-treemap.test.js @@ -38,7 +38,22 @@ function makeReport(overrides = {}) { }; } -describe('repo treemap rendering', () => { +function makeStyledCtx() { + return /** @type {any} */ ({ + style: { + rgb: (...args) => { + const [red, green, blue, text] = args; + return `[fg:${red},${green},${blue}]${text}[/fg]`; + }, + bgRgb: (...args) => { + const [red, green, blue, text] = args; + return `[bg:${red},${green},${blue}]${text}[/bg]`; + }, + }, + }); +} + +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(), @@ -51,6 +66,24 @@ describe('repo treemap rendering', () => { expect(output).toContain('refs/git-cms'); }); + it('renders label text with tile-colored backgrounds and contrasting foregrounds', () => { + const output = renderRepoTreemapMap(makeReport({ + totalValue: 10, + tiles: [{ label: 'docs', kind: 'worktree', value: 10, detail: '10 tracked paths' }], + }), { + ctx: makeStyledCtx(), + width: 24, + height: 8, + }); + + expect(output).toContain('[bg:59,207,212]'); + expect(output).toContain('[fg:0,0,0]d[/fg]'); + expect(output).toContain('[fg:0,0,0]o[/fg]'); + }); +}); + +describe('repo treemap sidebar rendering', () => { + it('sorts sidebar largest regions by value instead of source construction order', () => { const sidebar = renderRepoTreemapSidebar(makeReport(), { ctx: makeCtx(), @@ -63,4 +96,21 @@ describe('repo treemap rendering', () => { expect(regionLines[1]).toContain('docs'); expect(regionLines[2]).toContain('refs/git-cms'); }); + + 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', + ]); + }); }); From ff9fa2f9485f6cf7f182ce779fc024c38ba56f6a Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 18 Mar 2026 21:53:24 -0700 Subject: [PATCH 13/22] fix(ui): remove treemap label stripe backgrounds --- bin/ui/repo-treemap.js | 7 +------ test/unit/cli/repo-treemap.test.js | 9 +++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/bin/ui/repo-treemap.js b/bin/ui/repo-treemap.js index 3b9f014..9d09cb9 100644 --- a/bin/ui/repo-treemap.js +++ b/bin/ui/repo-treemap.js @@ -415,12 +415,7 @@ function renderGrid(grid, ctx) { const color = TILE_COLOR[cell.kind] ?? TILE_COLOR.meta; if (cell.label) { const foreground = contrastColor(color); - return ctx.style.bgRgb( - color[0], - color[1], - color[2], - ctx.style.rgb(foreground[0], foreground[1], foreground[2], cell.ch), - ); + return ctx.style.rgb(foreground[0], foreground[1], foreground[2], cell.ch); } return ctx.style.rgb(color[0], color[1], color[2], cell.ch); }).join('')); diff --git a/test/unit/cli/repo-treemap.test.js b/test/unit/cli/repo-treemap.test.js index 16bf37c..c2e3b95 100644 --- a/test/unit/cli/repo-treemap.test.js +++ b/test/unit/cli/repo-treemap.test.js @@ -45,10 +45,7 @@ function makeStyledCtx() { const [red, green, blue, text] = args; return `[fg:${red},${green},${blue}]${text}[/fg]`; }, - bgRgb: (...args) => { - const [red, green, blue, text] = args; - return `[bg:${red},${green},${blue}]${text}[/bg]`; - }, + bgRgb: (...args) => `[bg]${args[3]}[/bg]`, }, }); } @@ -66,7 +63,7 @@ describe('repo treemap map rendering', () => { expect(output).toContain('refs/git-cms'); }); - it('renders label text with tile-colored backgrounds and contrasting foregrounds', () => { + it('renders label text with contrast-picked foregrounds without painting stripe backgrounds', () => { const output = renderRepoTreemapMap(makeReport({ totalValue: 10, tiles: [{ label: 'docs', kind: 'worktree', value: 10, detail: '10 tracked paths' }], @@ -76,9 +73,9 @@ describe('repo treemap map rendering', () => { height: 8, }); - expect(output).toContain('[bg:59,207,212]'); expect(output).toContain('[fg:0,0,0]d[/fg]'); expect(output).toContain('[fg:0,0,0]o[/fg]'); + expect(output).not.toContain('[bg]'); }); }); From 40b1759dc12e2274f2875adc86ea4d33d5a71b98 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 18 Mar 2026 23:09:06 -0700 Subject: [PATCH 14/22] fix(ui): restore treemap label readability --- bin/ui/repo-treemap.js | 17 ++++------------- test/unit/cli/repo-treemap.test.js | 7 ++++--- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/bin/ui/repo-treemap.js b/bin/ui/repo-treemap.js index 9d09cb9..c8e56f9 100644 --- a/bin/ui/repo-treemap.js +++ b/bin/ui/repo-treemap.js @@ -314,17 +314,7 @@ function putCell(grid, cell) { } } -/** - * Pick a high-contrast foreground color for a tile background. - * - * @param {[number, number, number]} color - * @returns {[number, number, number]} - */ -function contrastColor(color) { - const [red, green, blue] = color; - const brightness = ((red * 299) + (green * 587) + (blue * 114)) / 1000; - return brightness >= 160 ? [0, 0, 0] : [255, 255, 255]; -} +const LABEL_FOREGROUND = [255, 255, 255]; /** * Paint a visible outline around a tile rectangle. @@ -414,8 +404,9 @@ function renderGrid(grid, ctx) { } const color = TILE_COLOR[cell.kind] ?? TILE_COLOR.meta; if (cell.label) { - const foreground = contrastColor(color); - return ctx.style.rgb(foreground[0], foreground[1], foreground[2], cell.ch); + 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('')); diff --git a/test/unit/cli/repo-treemap.test.js b/test/unit/cli/repo-treemap.test.js index c2e3b95..d0af25b 100644 --- a/test/unit/cli/repo-treemap.test.js +++ b/test/unit/cli/repo-treemap.test.js @@ -46,6 +46,7 @@ function makeStyledCtx() { return `[fg:${red},${green},${blue}]${text}[/fg]`; }, bgRgb: (...args) => `[bg]${args[3]}[/bg]`, + bold: (text) => `[bold]${text}[/bold]`, }, }); } @@ -63,7 +64,7 @@ describe('repo treemap map rendering', () => { expect(output).toContain('refs/git-cms'); }); - it('renders label text with contrast-picked foregrounds without painting stripe backgrounds', () => { + it('renders label text as bold white without painting stripe backgrounds', () => { const output = renderRepoTreemapMap(makeReport({ totalValue: 10, tiles: [{ label: 'docs', kind: 'worktree', value: 10, detail: '10 tracked paths' }], @@ -73,8 +74,8 @@ describe('repo treemap map rendering', () => { height: 8, }); - expect(output).toContain('[fg:0,0,0]d[/fg]'); - expect(output).toContain('[fg:0,0,0]o[/fg]'); + 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]'); }); }); From e38d3caa0e1a1ff35d58d232277cca20baede305 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 19 Mar 2026 00:29:26 -0700 Subject: [PATCH 15/22] feat(ui): add refs browser and source inspection --- bin/ui/dashboard-cmds.js | 365 ++++++++++++++++++++--- bin/ui/dashboard-view.js | 258 ++++++++++++++-- bin/ui/dashboard.js | 428 ++++++++++++++++++++++++--- bin/ui/repo-treemap.js | 17 +- bin/ui/vault-report.js | 61 ++-- test/unit/cli/dashboard-cmds.test.js | 76 ++++- test/unit/cli/dashboard.test.js | 143 ++++++++- test/unit/cli/vault-report.test.js | 18 +- 8 files changed, 1220 insertions(+), 146 deletions(-) diff --git a/bin/ui/dashboard-cmds.js b/bin/ui/dashboard-cmds.js index f848876..8cb1751 100644 --- a/bin/ui/dashboard-cmds.js +++ b/bin/ui/dashboard-cmds.js @@ -11,8 +11,24 @@ import { buildVaultStats, inspectVaultHealth } from './vault-report.js'; /** @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 {{ label: string, kind: RepoTreemapKind, value: number, detail: string }} 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, @@ -34,6 +50,20 @@ import { buildVaultStats, inspectVaultHealth } from './vault-report.js'; * }} RepoTreemapReport */ +/** + * 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; +} + /** * Compact OID label for human-facing rows. * @@ -58,6 +88,32 @@ function singleEntrySource(slug, treeOid) { }; } +/** + * 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. * @@ -294,6 +350,51 @@ async function scanDirectoryTiles(directory, kind, labelFor) { .sort((left, right) => right.value - left.value || left.label.localeCompare(right.label)); } +/** + * Read semantic git-dir tiles, expanding `.git/objects` one more level so the + * repository view can show pack, info, and loose-object fanout buckets instead + * of collapsing everything into one opaque rectangle. + * + * @param {{ gitDir: string, bare: boolean }} repo + * @returns {Promise} + */ +async function readGitDirTiles(repo) { + let entries; + try { + entries = await readdir(repo.gitDir, { withFileTypes: true }); + } catch { + return []; + } + + /** @type {RepoTreemapTile[]} */ + const tiles = []; + + for (const entry of entries) { + if (entry.name === 'refs') { + continue; + } + + const entryPath = path.join(repo.gitDir, entry.name); + if (entry.name === 'objects') { + const objectTiles = await scanDirectoryTiles(entryPath, 'git', (name) => repo.bare ? `objects/${name}` : `.git/objects/${name}`); + if (objectTiles.length > 0) { + tiles.push(...objectTiles); + continue; + } + } + + const value = await measurePathBytes(entryPath); + tiles.push({ + label: repo.bare ? entry.name : `.git/${entry.name}`, + kind: 'git', + value: Math.max(1, value), + detail: `${formatBytes(value)} on disk`, + }); + } + + return tiles.sort((left, right) => right.value - left.value || left.label.localeCompare(right.label)); +} + /** * Group refs by their top-level namespace. * @@ -311,8 +412,7 @@ async function readRefNamespaceTiles(plumbing) { const namespaces = new Map(); for (const line of output.split('\n').map((row) => row.trim()).filter(Boolean)) { const [, ref = ''] = line.split(' '); - const parts = ref.split('/'); - const label = parts[0] === 'refs' && parts[1] ? `refs/${parts[1]}` : ref; + const label = refNamespace(ref); namespaces.set(label, (namespaces.get(label) ?? 0) + 1); } @@ -418,6 +518,24 @@ function aggregateLogicalTile(label, kind, records) { }; } +/** + * Build repository-scope notes. + * + * @param {{ gitDir: string, bare: boolean }} repo + * @param {TreemapWorktreeMode} worktreeMode + * @returns {string[]} + */ +function buildRepositoryNotes(repo, worktreeMode) { + return [ + 'Repository view mixes Git-reported worktree paths, .git on-disk bytes, 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}`, + ]; +} + /** * Build the source-focused treemap report. * @@ -445,6 +563,9 @@ function buildSourceScopeReport({ repo, source, sourceResult, sourceRecords }) { detail: 'No CAS entries resolved for this source', }], notes: [ + sourceResult.entries.length === 0 + ? `No CAS entries resolved for ${source.type === 'vault' ? 'the vault' : source.type === 'ref' ? source.ref : source.treeOid}. Press r to browse refs or T to return to repository scope.` + : `Loaded ${sourceResult.entries.length} source entr${sourceResult.entries.length === 1 ? 'y' : 'ies'} for ${source.type === 'vault' ? 'the vault' : source.type === 'ref' ? source.ref : source.treeOid}.`, 'Source view weights tiles by logical manifest size.', ], summary: { @@ -476,7 +597,7 @@ function buildSourceScopeReport({ repo, source, sourceResult, sourceRecords }) { */ async function buildRepositoryScopeReport({ cas, source, repo, plumbing, sourceResult, sourceRecords, worktreeMode }) { const { tiles: worktreeTiles, pathCount: worktreePaths } = await readWorktreeTiles({ plumbing, repo, worktreeMode }); - const gitTiles = await scanDirectoryTiles(repo.gitDir, 'git', (name) => repo.bare ? name : `.git/${name}`); + const gitTiles = await readGitDirTiles(repo); const { tiles: refTiles, totalRefs } = await readRefNamespaceTiles(plumbing); const vaultResult = source.type === 'vault' ? sourceResult : await readSourceEntries(cas, { type: 'vault' }); const vaultRecords = source.type === 'vault' ? sourceRecords : await loadEntryRecords(cas, vaultResult.entries); @@ -491,27 +612,19 @@ async function buildRepositoryScopeReport({ cas, source, repo, plumbing, sourceR ...(vaultTile ? [vaultTile] : []), ...(activeSourceTile ? [activeSourceTile] : []), ]); - const totalValue = tiles.reduce((sum, tile) => sum + tile.value, 0); - return { scope: 'repository', worktreeMode, cwd: repo.cwd, source, - totalValue, + totalValue: tiles.reduce((sum, tile) => sum + tile.value, 0), tiles: tiles.length > 0 ? tiles : [{ label: 'empty repo', kind: 'meta', value: 1, detail: 'No worktree, ref, or CAS regions were detected', }], - notes: [ - 'Repository view mixes Git-reported worktree paths, .git on-disk bytes, and logical CAS region sizes.', - 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}`, - ], + notes: buildRepositoryNotes(repo, worktreeMode), summary: { bare: repo.bare, gitDir: repo.gitDir, @@ -525,6 +638,61 @@ async function buildRepositoryScopeReport({ cas, source, repo, plumbing, sourceR }; } +/** + * 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. * @@ -672,45 +840,105 @@ async function tryReadManifestHint(persistence, oid) { } /** - * Resolve dashboard entries for a non-vault source. + * Resolve a direct OID source. * - * @param {ContentAddressableStore} cas - * @param {{ type: 'ref', ref: string } | { type: 'oid', treeOid: string }} source - * @returns {Promise<{ entries: ExplorerEntry[], metadata: any }>} + * @param {Extract} source + * @returns {{ entries: ExplorerEntry[], metadata: any, resolution: RefResolutionKind, targetTreeOid: string }} */ -async function resolveNonVaultSource(cas, source) { - if (source.type === 'oid') { - return singleEntrySource(`oid:${shortOid(source.treeOid)}`, source.treeOid); - } +function resolveOidSourceDetailed(source) { + return { + ...singleEntrySource(`oid:${shortOid(source.treeOid)}`, source.treeOid), + resolution: 'oid', + targetTreeOid: source.treeOid, + }; +} - const [service, vault] = await Promise.all([ - cas.getService(), - cas.getVaultService(), - ]); +/** + * 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); + 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); + 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 }; + return { + entries: indexed, + metadata: null, + resolution: 'index', + resolvedOid, + targetTreeOid: null, + }; } const hintedTreeOid = await tryReadManifestHint(service.persistence, resolvedOid); if (hintedTreeOid) { - return singleEntrySource(source.ref, 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. * @@ -729,6 +957,41 @@ export async function readSourceEntries(cas, source = { type: 'vault' }) { 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. * @@ -775,9 +1038,9 @@ export function loadEntriesCmd(cas, source = { type: 'vault' }) { return async () => { try { const { entries, metadata } = await readSourceEntries(cas, source); - return /** @type {const} */ ({ type: 'loaded-entries', entries, metadata }); + 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 }); } }; } @@ -786,16 +1049,31 @@ export function loadEntriesCmd(cas, source = { type: 'vault' }) { * 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 loadManifestCmd(cas, slug, treeOid) { +export function loadRefsCmd(cas) { return async () => { try { - const manifest = await cas.readManifest({ treeOid }); - return /** @type {const} */ ({ type: 'loaded-manifest', slug, manifest }); + const refs = await readRefInventory(cas); + return /** @type {const} */ ({ type: 'loaded-refs', refs }); } 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: 'refs', error: /** @type {Error} */ (err).message }); } }; } @@ -805,17 +1083,18 @@ export function loadManifestCmd(cas, slug, treeOid) { * * @param {ContentAddressableStore} cas * @param {ExplorerEntry[]} entries + * @param {DashSource} source */ -export function loadStatsCmd(cas, entries) { +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) }); + return /** @type {const} */ ({ type: 'loaded-stats', stats: buildVaultStats(records), source }); } catch (/** @type {any} */ err) { - return /** @type {const} */ ({ type: 'load-error', source: 'stats', error: /** @type {Error} */ (err).message }); + return /** @type {const} */ ({ type: 'load-error', source: 'stats', forSource: source, error: /** @type {Error} */ (err).message }); } }; } @@ -836,12 +1115,12 @@ export function loadDoctorCmd(cas, source = { type: 'vault' }, entries = []) { + `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 }); + return /** @type {const} */ ({ type: 'loaded-doctor', report, source }); } const report = await inspectVaultHealth(cas); - return /** @type {const} */ ({ type: 'loaded-doctor', report }); + return /** @type {const} */ ({ type: 'loaded-doctor', report, source }); } catch (/** @type {any} */ err) { - return /** @type {const} */ ({ type: 'load-error', source: 'doctor', error: /** @type {Error} */ (err).message }); + return /** @type {const} */ ({ type: 'load-error', source: 'doctor', forSource: source, error: /** @type {Error} */ (err).message }); } }; } diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index 3c026df..a062cc6 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -110,6 +110,8 @@ function headerParts(model, ctx) { } if (model.activeDrawer === 'treemap') { parts.push(badge('treemap view', { variant: 'primary', ctx })); + } else if (model.activeDrawer === 'refs') { + parts.push(badge('refs view', { variant: 'primary', ctx })); } else { parts.push(badge(`pane ${model.splitPane.focused === 'a' ? 'entries' : 'inspector'}`, { variant: 'primary', ctx })); } @@ -176,7 +178,7 @@ function renderHeaderSurface(model, deps) { blitInline(surface, { x: 0, y: 2, - parts: [sourceLabel(deps.source), ...headerParts(model, deps.ctx)], + parts: [sourceLabel(model.source), ...headerParts(model, deps.ctx)], maxWidth: surface.width, }); surface.blit(textSurface('─'.repeat(surface.width), surface.width, 1), 0, 3); @@ -381,7 +383,7 @@ function renderPanel(options) { * @returns {Surface | null} */ function renderDrawerSurface(model, opts) { - if (!model.activeDrawer || model.activeDrawer === 'treemap') { + if (!model.activeDrawer || model.activeDrawer === 'treemap' || model.activeDrawer === 'refs') { return null; } return model.activeDrawer === 'stats' @@ -608,6 +610,227 @@ function renderDetailPane(model, opts) { }); } +/** + * Choose a responsive table schema for the refs browser. + * + * @param {number} width + * @returns {{ columns: { header: string, width: number, align?: 'left' | 'right' | 'center' }[], indexes: number[] }} + */ +function refTableSchema(width) { + if (width >= 80) { + return { + columns: [ + { header: 'Namespace', width: 14 }, + { header: 'Ref', width: Math.max(16, width - 47) }, + { header: 'Kind', width: 10 }, + { header: 'Entries', width: 7, align: 'right' }, + { header: 'OID', width: 12 }, + ], + indexes: [0, 1, 2, 3, 4], + }; + } + if (width >= 58) { + return { + columns: [ + { header: 'Ref', width: Math.max(18, width - 20) }, + { header: 'Kind', width: 10 }, + { header: 'Entries', width: 7, align: 'right' }, + ], + indexes: [1, 2, 3], + }; + } + return { + columns: [ + { header: 'Ref', width: Math.max(16, width - 12) }, + { header: 'CAS', width: 10 }, + ], + indexes: [1, 2], + }; +} + +/** + * Clamp the refs table to the current pane size. + * + * @param {DashModel} model + * @param {{ width: number, height: number }} size + * @returns {import('@flyingrobots/bijou-tui').NavigableTableState} + */ +function refsTableViewState(model, size) { + const schema = refTableSchema(size.width); + const rows = model.refsTable.rows.map((row) => schema.indexes.map((index) => row[index] ?? '')); + const focusRow = Math.max(0, Math.min(model.refsTable.focusRow, rows.length - 1)); + let scrollY = model.refsTable.scrollY; + if (focusRow < scrollY) { + scrollY = focusRow; + } else if (focusRow >= scrollY + size.height) { + scrollY = focusRow - size.height + 1; + } + return { + ...model.refsTable, + columns: schema.columns, + rows, + height: size.height, + focusRow, + scrollY: Math.min(scrollY, Math.max(0, rows.length - size.height)), + }; +} + +/** + * 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]; +} + +/** + * Render the refs-browser table body. + * + * @param {DashModel} model + * @param {DashDeps} deps + * @param {{ width: number, height: number }} size + * @returns {string} + */ +function renderRefsListBody(model, deps, size) { + if (model.refsStatus === 'loading') { + return 'Loading refs...'; + } + if (model.refsStatus === 'error') { + return `Failed to load refs\n\n${model.refsError ?? 'unknown error'}`; + } + if (model.refsItems.length === 0) { + return 'No refs found.'; + } + return navigableTable(refsTableViewState(model, size), { + ctx: deps.ctx, + focusIndicator: '▸', + }); +} + +/** + * Render the refs-browser detail sidebar. + * + * @param {DashModel} model + * @returns {string} + */ +function renderRefsDetailBody(model) { + const current = selectedRef(model); + const namespaceCounts = new Map(); + for (const ref of model.refsItems) { + namespaceCounts.set(ref.namespace, (namespaceCounts.get(ref.namespace) ?? 0) + 1); + } + + const sidebarLines = [ + `refs ${model.refsItems.length} under ${namespaceCounts.size} namespaces`, + `current ${sourceLabel(model.source)}`, + '', + ]; + + if (current) { + sidebarLines.push( + `ref ${current.ref}`, + `namespace ${current.namespace}`, + `oid ${current.oid}`, + `status ${current.browsable ? 'browsable' : 'opaque'}`, + `kind ${current.resolution}`, + `entries ${current.entryCount}`, + '', + current.detail, + ); + if (current.previewSlugs.length > 0) { + sidebarLines.push('', 'preview', ...current.previewSlugs.map((slug) => `- ${slug}`)); + } + sidebarLines.push('', current.browsable + ? 'Press enter to switch source to this ref.' + : 'This ref does not currently resolve to CAS entries.'); + } else if (model.refsStatus === 'ready') { + sidebarLines.push('Select a ref to inspect it.'); + } + + if (namespaceCounts.size > 0) { + sidebarLines.push( + '', + 'namespaces', + ...Array.from(namespaceCounts.entries()) + .slice(0, 8) + .map(([namespace, count]) => `- ${namespace} (${count})`), + ); + } + + 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: 'Refs', + 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 Details', + body: renderRefsDetailBody(model), + 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), + }); + } + return 'Treemap has not been loaded yet.'; +} + /** * Compose sidebar copy for the full-screen treemap view. * @@ -658,22 +881,10 @@ function renderTreemapView(model, deps, options) { const mapHeight = options.height; const sidebarHeight = options.height; - const mapBody = model.treemapStatus === 'loading' - ? `Loading ${model.treemapScope} treemap...` - : model.treemapStatus === 'error' - ? `Failed to load treemap\n\n${model.treemapError ?? 'unknown error'}` - : model.treemapReport - ? renderRepoTreemapMap(model.treemapReport, { - ctx: deps.ctx, - width: Math.max(8, mapWidth - 2), - height: Math.max(4, mapHeight - 2), - }) - : 'Treemap has not been loaded yet.'; - const mapTitle = model.treemapScope === 'repository' ? 'Repository Map' : 'Source Map'; const mapPanel = renderPanel({ title: mapTitle, - body: mapBody, + body: renderTreemapMapBody(model, deps, { mapWidth, mapHeight }), width: mapWidth, height: mapHeight, ctx: deps.ctx, @@ -708,15 +919,22 @@ function renderFooterSurface(model, ctx, width) { const lines = model.activeDrawer === 'treemap' ? [ '─'.repeat(Math.max(1, width)), - `${kbd('T', { ctx })} scope ${kbd('i', { ctx })} files ${kbd('ctrl+p', { ctx })} palette`, + `${kbd('T', { ctx })} scope ${kbd('i', { ctx })} files ${kbd('r', { ctx })} refs ${kbd('ctrl+p', { ctx })} palette`, `${kbd('s', { ctx })} stats ${kbd('g', { ctx })} doctor ${kbd('esc', { ctx })} back ${kbd('q', { ctx })} quit`, '', ] - : [ + : model.activeDrawer === 'refs' + ? [ + '─'.repeat(Math.max(1, width)), + `${kbd('j/k', { ctx })} refs ${kbd('d/u', { ctx })} page ${kbd('enter', { ctx })} switch source`, + `${kbd('t', { ctx })} treemap ${kbd('s', { ctx })} stats ${kbd('g', { ctx })} doctor ${kbd('ctrl+p', { ctx })} palette`, + `${kbd('esc', { ctx })} back ${kbd('q', { ctx })} quit`, + ] + : [ '─'.repeat(Math.max(1, width)), `${kbd('j/k', { ctx })} rows ${kbd('d/u', { ctx })} page ${kbd('J/K', { ctx })} scroll ${kbd('enter', { ctx })} inspect`, `${kbd('tab', { ctx })} pane ${kbd('H/L', { ctx })} resize ${kbd('ctrl+p', { ctx })} palette`, - `${kbd('s', { ctx })} stats ${kbd('g', { ctx })} doctor ${kbd('t', { ctx })} treemap ${kbd('T', { ctx })} scope ${kbd('i', { ctx })} files ${kbd('esc', { ctx })} close ${kbd('q', { ctx })} quit`, + `${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); } @@ -733,6 +951,10 @@ function renderBody(model, deps, options) { renderTreemapView(model, deps, options); return; } + if (model.activeDrawer === 'refs') { + renderRefsView(model, deps, options); + return; + } const layout = splitPaneLayout(model.splitPane, { direction: 'row', width: model.columns, diff --git a/bin/ui/dashboard.js b/bin/ui/dashboard.js index c844e5b..ea8f348 100644 --- a/bin/ui/dashboard.js +++ b/bin/ui/dashboard.js @@ -8,7 +8,7 @@ import { createSplitPaneState, splitPaneFocusNext, splitPaneResizeBy, createCommandPaletteState, cpFilter, cpFocusNext, cpFocusPrev, cpPageDown, cpPageUp, cpSelectedItem, commandPaletteKeyMap, } from '@flyingrobots/bijou-tui'; -import { loadEntriesCmd, loadManifestCmd, loadStatsCmd, loadDoctorCmd, loadTreemapCmd, readSourceEntries } from './dashboard-cmds.js'; +import { loadEntriesCmd, loadManifestCmd, loadRefsCmd, loadStatsCmd, loadDoctorCmd, loadTreemapCmd, readSourceEntries } from './dashboard-cmds.js'; import { createCliTuiContext, detectCliTuiMode } from './context.js'; import { renderDashboard } from './dashboard-view.js'; @@ -25,6 +25,8 @@ import { renderDashboard } from './dashboard-view.js'; * @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').RefInventory} RefInventory + * @typedef {import('./dashboard-cmds.js').RefInventoryItem} RefInventoryItem * @typedef {{ slug: string, treeOid: string }} VaultEntry * @typedef {'error' | 'warning' | 'info' | 'success'} ToastLevel * @typedef {{ id: number, level: ToastLevel, title: string, message: string }} ToastRecord @@ -45,6 +47,7 @@ import { renderDashboard } from './dashboard-view.js'; * | { type: 'open-stats' } * | { type: 'open-doctor' } * | { type: 'open-treemap' } + * | { type: 'open-refs' } * | { type: 'toggle-treemap-scope' } * | { type: 'toggle-treemap-worktree' } * | { type: 'overlay-close' } @@ -62,13 +65,14 @@ import { renderDashboard } from './dashboard-view.js'; */ /** - * @typedef {{ type: 'loaded-entries', entries: VaultEntry[], metadata: any } - * | { type: 'loaded-manifest', slug: string, manifest: Manifest } - * | { type: 'loaded-stats', stats: any } - * | { type: 'loaded-doctor', report: any } + * @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: 'dismiss-toast', id: number } - * | { type: 'load-error', source: string, slug?: string, scopeId?: TreemapScope, worktreeMode?: TreemapWorktreeMode, error: string } + * | { type: 'load-error', source: string, slug?: string, forSource?: DashSource, scopeId?: TreemapScope, worktreeMode?: TreemapWorktreeMode, error: string } * } DashMsg */ @@ -77,6 +81,7 @@ 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 {string} filterText @@ -87,9 +92,13 @@ import { renderDashboard } from './dashboard-view.js'; * @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' | null} activeDrawer + * @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 @@ -140,6 +149,7 @@ export function createKeyBindings() { .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('escape', 'Close overlay', { type: 'overlay-close' }) @@ -167,6 +177,13 @@ const TOAST_LIMIT = 4; const TOAST_TTL_MS = 6000; 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', @@ -332,6 +349,73 @@ function syncTable(table, updates = {}) { }; } +/** + * Return true when two dashboard sources describe the same target. + * + * @param {DashSource} left + * @param {DashSource} right + * @returns {boolean} + */ +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; +} + +/** + * 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. * @@ -410,6 +494,43 @@ function isStaleTreemapLoad(msg, model) { && msg.worktreeMode !== model.treemapWorktreeMode; } +/** + * 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 + * @returns {boolean} + */ +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 }; + } + 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; +} + /** * Apply state changes caused by an async load error. * @@ -418,18 +539,16 @@ function isStaleTreemapLoad(msg, model) { * @returns {DashModel} */ function applyLoadErrorState(msg, model) { - switch (msg.source) { - case 'manifest': - return { ...model, loadingSlug: model.loadingSlug === msg.slug ? null : model.loadingSlug }; - case 'stats': - return { ...model, statsStatus: 'error', statsError: msg.error }; - case 'doctor': - return { ...model, doctorStatus: 'error', doctorError: msg.error }; - case 'treemap': - return isStaleTreemapLoad(msg, model) ? model : { ...model, treemapStatus: 'error', treemapError: msg.error }; - default: - return { ...model, status: 'error', error: msg.error }; + 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 }; } /** @@ -448,6 +567,9 @@ function loadErrorTitle(msg) { 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'; } @@ -455,21 +577,53 @@ function loadErrorTitle(msg) { } /** - * Create the initial model. + * Create the initial explorer table state. * - * @param {BijouContext} ctx - * @returns {DashModel} + * @param {number} rows + * @returns {NavigableTableState} */ -function createInitModel(ctx) { - const table = createNavigableTableState({ +function createInitTable(rows) { + return createNavigableTableState({ columns: TABLE_COLUMNS, rows: [], - height: tableHeight(ctx.runtime.rows ?? 24), + 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: ctx.runtime.rows ?? 24, + rows, + source, entries: [], filtered: [], filterText: '', @@ -479,10 +633,14 @@ function createInitModel(ctx) { loadingSlug: null, detailScroll: 0, error: null, - table, + 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, @@ -499,6 +657,33 @@ function createInitModel(ctx) { }; } +/** + * Handle actions that are specific to full-screen refs mode. + * + * @param {DashAction} action + * @param {DashModel} model + * @param {DashDeps} deps + * @returns {[DashModel, DashCmd[]] | null} + */ +function handleRefsViewAction(action, model, deps) { + if (model.activeDrawer !== 'refs') { + return null; + } + if (action.type === 'move') { + return handleRefsMove(action, model); + } + if (action.type === 'page') { + return handleRefsPage(action, model); + } + if (action.type === 'select') { + return handleRefSelect(model, deps); + } + if (isBlockedByTreemapView(action)) { + return [model, []]; + } + return null; +} + /** * Apply filter text to entries. * @@ -520,13 +705,20 @@ function applyFilter(entries, text) { * @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, e.slug, e.treeOid))); + 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', @@ -546,6 +738,9 @@ function handleLoadedEntries(msg, model, cas) { * @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, { @@ -638,7 +833,11 @@ function handleSelect(model, deps) { if (model.manifestCache.has(entry.slug)) { return [{ ...model, splitPane: { ...model.splitPane, focused: 'b' } }, []]; } - const cmd = /** @type {DashCmd} */ (loadManifestCmd(deps.cas, entry.slug, entry.treeOid)); + const cmd = /** @type {DashCmd} */ (loadManifestCmd(deps.cas, { + slug: entry.slug, + treeOid: entry.treeOid, + source: model.source, + })); return [{ ...model, loadingSlug: entry.slug, @@ -646,6 +845,30 @@ function handleSelect(model, deps) { }, [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. * @@ -667,7 +890,7 @@ function openStatsDrawer(model, deps) { palette: null, statsStatus: 'loading', statsError: null, - }, [/** @type {DashCmd} */ (loadStatsCmd(deps.cas, model.entries))]]; + }, [/** @type {DashCmd} */ (loadStatsCmd(deps.cas, model.entries, model.source))]]; } /** @@ -691,7 +914,7 @@ function openDoctorDrawer(model, deps) { palette: null, doctorStatus: 'loading', doctorError: null, - }, [/** @type {DashCmd} */ (loadDoctorCmd(deps.cas, deps.source, model.entries))]]; + }, [/** @type {DashCmd} */ (loadDoctorCmd(deps.cas, model.source, model.entries))]]; } /** @@ -723,7 +946,7 @@ function openTreemapDrawer(model, deps) { treemapStatus: 'loading', treemapError: null, }, [/** @type {DashCmd} */ (loadTreemapCmd(deps.cas, { - source: deps.source, + source: model.source, scope: model.treemapScope, worktreeMode: model.treemapWorktreeMode, }))]]; @@ -756,7 +979,7 @@ function toggleTreemapScope(model, deps) { treemapStatus: 'loading', treemapError: null, }, [/** @type {DashCmd} */ (loadTreemapCmd(deps.cas, { - source: deps.source, + source: model.source, scope: treemapScope, worktreeMode: model.treemapWorktreeMode, }))]]; @@ -793,7 +1016,7 @@ function toggleTreemapWorktreeMode(model, deps) { treemapStatus: 'loading', treemapError: null, }, [/** @type {DashCmd} */ (loadTreemapCmd(deps.cas, { - source: deps.source, + source: model.source, scope: 'repository', worktreeMode: treemapWorktreeMode, }))]]; @@ -847,6 +1070,104 @@ function closeDrawerFromPalette(model) { }, []]; } +/** + * 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, + }, []]; + } + const clearedTable = syncTable(model.table, { + entries: [], + manifestCache: new Map(), + rows: model.rows, + focusRow: 0, + scrollY: 0, + }); + return [{ + ...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, + }, [/** @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. * @@ -860,6 +1181,7 @@ function handlePaletteSelect(model, deps) { return [{ ...model, palette: null }, []]; } const handlers = { + refs: () => openRefsDrawer(model, deps), treemap: () => openTreemapDrawer(model, deps), 'treemap-scope': () => toggleTreemapScope(model, deps), 'treemap-worktree': () => toggleTreemapWorktreeMode(model, deps), @@ -1008,6 +1330,9 @@ function handleOverlayAction(action, model, deps) { if (action.type === 'open-doctor') { return openDoctorDrawer(model, deps); } + if (action.type === 'open-refs') { + return openRefsDrawer(model, deps); + } if (action.type === 'open-treemap') { return openTreemapDrawer(model, deps); } @@ -1096,6 +1421,10 @@ function handlePrimaryAction(action, model, deps) { * @returns {[DashModel, DashCmd[]]} */ function handleAction(action, model, deps) { + const refsResult = handleRefsViewAction(action, model, deps); + if (refsResult) { + return refsResult; + } if (model.activeDrawer === 'treemap' && isBlockedByTreemapView(action)) { return [model, []]; } @@ -1118,7 +1447,24 @@ function handleAction(action, model, deps) { * @returns {[DashModel, DashCmd[]]} */ function handleLoadedReport(msg, model) { + if (msg.type === 'loaded-refs') { + 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, + }), + }, []]; + } if (msg.type === 'loaded-stats') { + if (!sourceEquals(msg.source, model.source)) { + return [model, []]; + } return [{ ...model, statsStatus: 'ready', @@ -1127,6 +1473,9 @@ function handleLoadedReport(msg, model) { }, []]; } if (msg.type === 'loaded-doctor') { + if (!sourceEquals(msg.source, model.source)) { + return [model, []]; + } return [{ ...model, doctorStatus: 'ready', @@ -1156,6 +1505,9 @@ function handleLoadedReport(msg, model) { * @returns {[DashModel, DashCmd[]]} */ function handleLoadError(msg, model) { + if (isStaleSourceLoad(msg, model)) { + return [model, []]; + } if (msg.source === 'treemap' && isStaleTreemapLoad(msg, model)) { return [model, []]; } @@ -1208,13 +1560,17 @@ function handleUpdate(msg, model, deps) { 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, palette }, []]; + return [{ ...model, columns: msg.columns, rows: msg.rows, table, refsTable, palette }, []]; } return handleAppMsg(/** @type {DashMsg} */ (msg), model, deps.cas); } @@ -1227,7 +1583,7 @@ function handleUpdate(msg, model, deps) { * @returns {boolean} */ function treemapReportMatches(model, report) { - if (!report || report.scope !== model.treemapScope) { + if (!report || report.scope !== model.treemapScope || !sourceEquals(report.source, model.source)) { return false; } if (report.scope !== 'repository') { @@ -1244,7 +1600,7 @@ function treemapReportMatches(model, report) { */ export function createDashboardApp(deps) { return { - init: () => /** @type {[DashModel, DashCmd[]]} */ ([createInitModel(deps.ctx), [/** @type {DashCmd} */ (loadEntriesCmd(deps.cas, deps.source))]]), + 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), }; diff --git a/bin/ui/repo-treemap.js b/bin/ui/repo-treemap.js index c8e56f9..77123c1 100644 --- a/bin/ui/repo-treemap.js +++ b/bin/ui/repo-treemap.js @@ -452,15 +452,24 @@ function renderDetails(tiles, options) { * @returns {string[]} */ function renderOverview(report, width) { - const worktreeLabel = report.scope === 'repository' - ? `${report.worktreeMode} paths ${report.summary.worktreePaths}` - : 'logical source weighting'; + if (report.scope === 'source') { + return [ + clip(`scope ${report.scope}`, width), + clip(`source ${sourceLabel(report.source)}`, width), + clip(`root ${tailClip(report.cwd, Math.max(1, width - 5))}`, width), + clip(`total ${formatBytes(report.totalValue)}`, width), + clip('logical source weighting', 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(`root ${tailClip(report.cwd, Math.max(1, width - 5))}`, width), clip(`total ${formatBytes(report.totalValue)}`, width), - clip(worktreeLabel, width), + clip(`${report.worktreeMode} paths ${report.summary.worktreePaths}`, 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), 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/test/unit/cli/dashboard-cmds.test.js b/test/unit/cli/dashboard-cmds.test.js index 7f6e585..87394e7 100644 --- a/test/unit/cli/dashboard-cmds.test.js +++ b/test/unit/cli/dashboard-cmds.test.js @@ -2,7 +2,7 @@ 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, readSourceEntries } from '../../../bin/ui/dashboard-cmds.js'; +import { buildRepoTreemapReport, readRefInventory, readSourceEntries } from '../../../bin/ui/dashboard-cmds.js'; function makePersistence(overrides = {}) { return { @@ -112,7 +112,36 @@ function makeRepositoryTreemapCas(plumbing) { 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 } }), + 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')), + }, + }), }; } @@ -272,7 +301,7 @@ describe('buildRepoTreemapReport repository scope', () => { expect(labels).toEqual(expect.arrayContaining([ 'README.md', 'src', - '.git/objects', + '.git/objects/pack-1', 'refs/heads', 'refs/warp', 'vault', @@ -280,6 +309,7 @@ describe('buildRepoTreemapReport repository scope', () => { ])); expect(labels).not.toContain('node_modules'); expect(report.notes).toEqual(expect.arrayContaining([ + expect.stringContaining('Press r to browse refs'), expect.stringContaining('git ls-files'), ])); }); @@ -312,6 +342,44 @@ describe('buildRepoTreemapReport source scope', () => { kind: 'cas', }), ]); - expect(report.notes[0]).toContain('logical manifest size'); + 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.test.js b/test/unit/cli/dashboard.test.js index 72c5da5..17b8ef5 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -64,14 +64,27 @@ function makeTable(filtered = [], options = {}) { }; } +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: [], filterText: '', @@ -82,9 +95,13 @@ function makeModel(overrides = {}) { 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, @@ -142,6 +159,33 @@ function makeDoctorReport() { }; } +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 makeTreemapReport(overrides = {}) { return { scope: 'repository', @@ -284,7 +328,8 @@ describe('dashboard palette and overlay commands', () => { const [withPalette] = app.update(keyMsg('p', { ctrl: true }), makeModel()); const [onTreemap] = app.update(keyMsg('down'), withPalette); const [onTreemapScope] = app.update(keyMsg('down'), onTreemap); - const [onStats] = app.update(keyMsg('down'), onTreemapScope); + const [onTreemapWorktree] = app.update(keyMsg('down'), onTreemapScope); + const [onStats] = app.update(keyMsg('down'), onTreemapWorktree); const [next, cmds] = app.update(keyMsg('enter'), onStats); expect(next.palette).toBeNull(); expect(next.activeDrawer).toBe('stats'); @@ -366,10 +411,31 @@ describe('dashboard treemap shortcuts', () => { }); }); +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); @@ -380,16 +446,24 @@ describe('dashboard data loading', () => { 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 }, makeModel({ activeDrawer: 'stats', statsStatus: 'loading' })); + 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(); @@ -398,7 +472,7 @@ describe('dashboard report loading', () => { it('loaded-doctor stores the doctor report', () => { const app = createDashboardApp(makeDeps()); const report = makeDoctorReport(); - const [next] = app.update({ type: 'loaded-doctor', report }, makeModel({ activeDrawer: 'doctor', doctorStatus: 'loading' })); + 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(); @@ -469,7 +543,7 @@ describe('dashboard filter mode', () => { 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'); @@ -488,7 +562,7 @@ describe('dashboard filter edge cases', () => { it('load-error from entries sets error and status on model', () => { const app = createDashboardApp(makeDeps()); - const [next, cmds] = 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); @@ -501,7 +575,7 @@ 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, cmds] = 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); @@ -516,7 +590,7 @@ describe('dashboard loading edge cases', () => { filterText: 'al', table: makeTable([], { overrides: { focusRow: 5 } }), }); - 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.table.focusRow).toBe(0); expect(next.filtered).toHaveLength(1); @@ -650,6 +724,7 @@ describe('dashboard report overlay rendering', () => { const rendered = renderView(app.view(model), deps.ctx); expect(rendered).toContain('Source Stats'); expect(rendered).toContain('dedup-ratio'); + expect(rendered).not.toContain('\t'); }); it('renders the doctor drawer loading state', () => { @@ -661,7 +736,7 @@ describe('dashboard report overlay rendering', () => { }); }); -describe('dashboard treemap and palette rendering', () => { +describe('dashboard treemap rendering', () => { it('renders the treemap as a full-screen view with a details sidebar', () => { const { rendered } = renderDashboardWithModel(makeFullScreenTreemapModel()); expect(rendered).toContain('treemap view'); @@ -677,6 +752,54 @@ describe('dashboard treemap and palette rendering', () => { 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', + totalValue: 0, + tiles: [{ label: 'empty source', kind: 'meta', value: 1, detail: 'No CAS entries resolved for this source' }], + 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('refs view'); + expect(rendered).toContain('Refs'); + expect(rendered).toContain('Ref Details'); + expect(rendered).toContain('refs/warp/demo/seek-cache'); + expect(rendered).toContain('Press enter to switch source'); + }); +}); + +describe('dashboard palette rendering', () => { it('renders the palette badge when the command palette is open', () => { const deps = makeDeps(); const app = createDashboardApp(deps); 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'); }); }); From 2519a9901ff80bbee201017fe2efdf9623dd2ef1 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 19 Mar 2026 03:36:33 -0700 Subject: [PATCH 16/22] feat(ui): add treemap drilldown and refs explorer --- bin/ui/dashboard-cmds.js | 925 +++++++++++++++++++-------- bin/ui/dashboard-view.js | 138 +++- bin/ui/dashboard.js | 525 +++++++++++---- bin/ui/repo-treemap.js | 98 ++- test/unit/cli/dashboard-cmds.test.js | 63 +- test/unit/cli/dashboard.test.js | 162 ++++- test/unit/cli/repo-treemap.test.js | 42 +- 7 files changed, 1467 insertions(+), 486 deletions(-) diff --git a/bin/ui/dashboard-cmds.js b/bin/ui/dashboard-cmds.js index 8cb1751..72ccfeb 100644 --- a/bin/ui/dashboard-cmds.js +++ b/bin/ui/dashboard-cmds.js @@ -13,7 +13,8 @@ import { buildVaultStats, inspectVaultHealth } from './vault-report.js'; /** @typedef {'tracked' | 'ignored'} TreemapWorktreeMode */ /** @typedef {'oid' | 'manifest' | 'tree' | 'index' | 'hint' | 'opaque'} RefResolutionKind */ /** @typedef {'worktree' | 'git' | 'ref' | 'vault' | 'cas' | 'meta'} RepoTreemapKind */ -/** @typedef {{ label: string, kind: RepoTreemapKind, value: number, detail: string }} RepoTreemapTile */ +/** @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, @@ -34,6 +35,8 @@ import { buildVaultStats, inspectVaultHealth } from './vault-report.js'; * worktreeMode: TreemapWorktreeMode, * cwd: string, * source: DashSource, + * drillPath: TreemapPathNode[], + * breadcrumb: string[], * totalValue: number, * tiles: RepoTreemapTile[], * notes: string[], @@ -49,6 +52,7 @@ import { buildVaultStats, inspectVaultHealth } from './vault-report.js'; * } * }} RepoTreemapReport */ +/** @typedef {{ segments: string[], value: number, detail: string }} HierarchyRecord */ /** * Namespace bucket label for a git ref. @@ -64,6 +68,73 @@ function refNamespace(ref) { 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. * @@ -189,50 +260,6 @@ async function resolveRepoInfo(plumbing) { }; } -/** - * Measure a filesystem path recursively without following symlinks. - * - * @param {string} targetPath - * @returns {Promise} - */ -async function measurePathBytes(targetPath) { - let stat; - try { - stat = await lstat(targetPath); - } catch { - return 0; - } - if (!stat.isDirectory() || stat.isSymbolicLink()) { - return stat.size; - } - const entries = await readdir(targetPath, { withFileTypes: true }); - const childSizes = await Promise.all(entries.map((entry) => measurePathBytes(path.join(targetPath, entry.name)))); - return childSizes.reduce((sum, size) => sum + size, 0); -} - -/** - * Measure a single filesystem path selected by Git. - * - * Ignored-mode listings may collapse whole ignored directories, so that path - * needs a recursive byte count. Tracked listings should stay file-level. - * - * @param {string} targetPath - * @param {{ recurseDirectory?: boolean }} [options] - * @returns {Promise} - */ -async function measureListedPathBytes(targetPath, options = {}) { - let stat; - try { - stat = await lstat(targetPath); - } catch { - return 0; - } - if (stat.isDirectory() && !stat.isSymbolicLink() && options.recurseDirectory) { - return measurePathBytes(targetPath); - } - return stat.size; -} - /** * Parse null-delimited Git output into raw repo-relative paths. * @@ -249,27 +276,52 @@ function parseNullPaths(output) { * @param {string} repoPath * @returns {string} */ -function topLevelLabel(repoPath) { - const normalized = repoPath.replace(/\\/g, '/').replace(/\/+$/, ''); - if (!normalized) { - return ''; +/** + * 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 []; } - return normalized.split('/')[0] ?? normalized; + 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(); } /** - * Aggregate top-level worktree tiles from Git-reported paths. + * 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<{ tiles: RepoTreemapTile[], pathCount: number }>} + * @returns {Promise<{ records: HierarchyRecord[], pathCount: number }>} */ -async function readWorktreeTiles({ plumbing, repo, worktreeMode }) { +async function collectWorktreeRecords({ plumbing, repo, worktreeMode }) { if (repo.bare) { - return { tiles: [], pathCount: 0 }; + return { records: [], pathCount: 0 }; } const args = worktreeMode === 'ignored' @@ -280,85 +332,46 @@ async function readWorktreeTiles({ plumbing, repo, worktreeMode }) { try { output = await plumbing.execute({ args }); } catch { - return { tiles: [], pathCount: 0 }; + return { records: [], pathCount: 0 }; } const repoPaths = parseNullPaths(output); - const buckets = new Map(); - - await Promise.all(repoPaths.map(async (repoPath) => { - const normalizedPath = repoPath.replace(/\/+$/, ''); - const label = topLevelLabel(normalizedPath); - if (!label || label === '.git') { - return; + 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 value = await measureListedPathBytes(fullPath, { - recurseDirectory: worktreeMode === 'ignored' && repoPath.endsWith('/'), - }); - const bucket = buckets.get(label) ?? { value: 0, count: 0 }; - bucket.value += value; - bucket.count += 1; - buckets.set(label, bucket); - })); + const stat = await lstat(fullPath).catch(() => null); + if (!stat) { + return []; + } - const tiles = Array.from(buckets.entries()) - .map(([label, bucket]) => ({ - label, - kind: /** @type {const} */ ('worktree'), - value: Math.max(1, bucket.value), - detail: `${bucket.count} ${worktreeMode} path${bucket.count === 1 ? '' : 's'} · ${formatBytes(bucket.value)} on disk`, - })) - .sort((left, right) => right.value - left.value || left.label.localeCompare(right.label)); + if (stat.isDirectory() && !stat.isSymbolicLink()) { + return collectFilesystemRecords(fullPath, normalizedPath.split('/')); + } + + return [{ segments: normalizedPath.split('/'), value: stat.size }]; + }))).flat(); return { - tiles, + records: rawRecords.map((record) => ({ + ...record, + detail: `1 ${worktreeMode} path · ${formatBytes(record.value)} on disk`, + })), pathCount: repoPaths.length, }; } /** - * Build semantic tiles from the direct children of a directory. - * - * @param {string} directory - * @param {RepoTreemapKind} kind - * @param {(name: string) => string} labelFor - * @returns {Promise} - */ -async function scanDirectoryTiles(directory, kind, labelFor) { - let entries; - try { - entries = await readdir(directory, { withFileTypes: true }); - } catch { - return []; - } - - const tiles = await Promise.all(entries.map(async (entry) => { - const entryPath = path.join(directory, entry.name); - const value = await measurePathBytes(entryPath); - return { - label: labelFor(entry.name), - kind, - value: Math.max(1, value), - detail: `${formatBytes(value)} on disk`, - }; - })); - - return tiles - .filter((tile) => tile.value > 0) - .sort((left, right) => right.value - left.value || left.label.localeCompare(right.label)); -} - -/** - * Read semantic git-dir tiles, expanding `.git/objects` one more level so the - * repository view can show pack, info, and loose-object fanout buckets instead - * of collapsing everything into one opaque rectangle. + * 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} + * @returns {Promise} */ -async function readGitDirTiles(repo) { +async function collectGitRecords(repo) { let entries; try { entries = await readdir(repo.gitDir, { withFileTypes: true }); @@ -366,69 +379,99 @@ async function readGitDirTiles(repo) { return []; } - /** @type {RepoTreemapTile[]} */ - const tiles = []; - - for (const entry of entries) { + const rawRecords = (await Promise.all(entries.map(async (entry) => { if (entry.name === 'refs') { - continue; + return []; } + const rootLabel = repo.bare ? entry.name : `.git/${entry.name}`; + return collectFilesystemRecords(path.join(repo.gitDir, entry.name), [rootLabel]); + }))).flat(); - const entryPath = path.join(repo.gitDir, entry.name); - if (entry.name === 'objects') { - const objectTiles = await scanDirectoryTiles(entryPath, 'git', (name) => repo.bare ? `objects/${name}` : `.git/objects/${name}`); - if (objectTiles.length > 0) { - tiles.push(...objectTiles); - continue; - } - } + return rawRecords.map((record) => ({ + ...record, + detail: `${formatBytes(record.value)} on disk`, + })); +} - const value = await measurePathBytes(entryPath); - tiles.push({ - label: repo.bare ? entry.name : `.git/${entry.name}`, - kind: 'git', - value: Math.max(1, value), - detail: `${formatBytes(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)}`, + })); +} - return tiles.sort((left, right) => right.value - left.value || left.label.localeCompare(right.label)); +/** + * 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}`, + }; + }); } /** - * Group refs by their top-level namespace. + * Build one visible hierarchy level from leaf records. * - * @param {{ execute: ({ args }: { args: string[] }) => Promise }} plumbing - * @returns {Promise<{ tiles: RepoTreemapTile[], totalRefs: number }>} + * @param {HierarchyRecord[]} records + * @param {{ + * kind: Exclude, + * prefixSegments?: string[], + * aggregateDetail: (bucket: { segments: string[], records: HierarchyRecord[], value: number }) => string, + * }} options + * @returns {RepoTreemapTile[]} */ -async function readRefNamespaceTiles(plumbing) { - let output = ''; - try { - output = await plumbing.execute({ args: ['show-ref'] }); - } catch { - return { tiles: [], totalRefs: 0 }; - } +function buildHierarchyTiles(records, options) { + const prefixSegments = options.prefixSegments ?? []; + const buckets = new Map(); - const namespaces = new Map(); - for (const line of output.split('\n').map((row) => row.trim()).filter(Boolean)) { - const [, ref = ''] = line.split(' '); - const label = refNamespace(ref); - namespaces.set(label, (namespaces.get(label) ?? 0) + 1); + 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); } - const tiles = Array.from(namespaces.entries()) - .map(([label, count]) => ({ - label, - kind: /** @type {const} */ ('ref'), - value: Math.max(1, count * 4096), - detail: `${count} refs`, - })) + 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)); - - return { - tiles, - totalRefs: Array.from(namespaces.values()).reduce((sum, count) => sum + count, 0), - }; } /** @@ -450,29 +493,6 @@ async function loadEntryRecords(cas, entries) { })); } -/** - * Convert logical CAS records into treemap tiles. - * - * @param {Array} records - * @param {RepoTreemapKind} kind - * @returns {RepoTreemapTile[]} - */ -function buildLogicalTiles(records, kind) { - return records - .map((record) => { - const data = manifestData(record.manifest); - const format = data.compression?.algorithm ?? 'raw'; - const crypto = data.encryption ? 'enc' : 'plain'; - return { - label: record.slug, - kind, - value: Math.max(1, record.size), - detail: `${formatBytes(record.size)} logical · ${data.chunks?.length ?? 0} chunks · ${crypto}/${format}`, - }; - }) - .sort((left, right) => right.value - left.value || left.label.localeCompare(right.label)); -} - /** * Collapse low-value tiles into a single "other" bucket so the treemap stays legible. * @@ -489,33 +509,56 @@ function compactTiles(tiles, limit = 14) { 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; } /** - * Build a one-line aggregate tile for a source collection. + * Aggregate detail line for worktree hierarchy buckets. * - * @param {string} label - * @param {RepoTreemapKind} kind - * @param {Array} records - * @returns {RepoTreemapTile | null} + * @param {TreemapWorktreeMode} worktreeMode + * @param {{ records: HierarchyRecord[], value: number }} bucket + * @returns {string} */ -function aggregateLogicalTile(label, kind, records) { - if (records.length === 0) { - return null; - } - const total = records.reduce((sum, record) => sum + record.size, 0); - return { - label, - kind, - value: Math.max(1, total), - detail: `${records.length} entries · ${formatBytes(total)} logical`, - }; +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`; } /** @@ -523,11 +566,14 @@ function aggregateLogicalTile(label, kind, records) { * * @param {{ gitDir: string, bare: boolean }} repo * @param {TreemapWorktreeMode} worktreeMode + * @param {TreemapPathNode[]} drillPath * @returns {string[]} */ -function buildRepositoryNotes(repo, worktreeMode) { +function buildRepositoryNotes(repo, worktreeMode, drillPath) { return [ - 'Repository view mixes Git-reported worktree paths, .git on-disk bytes, and logical CAS region sizes.', + 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.' @@ -536,6 +582,88 @@ function buildRepositoryNotes(repo, worktreeMode) { ]; } +/** + * 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. * @@ -543,46 +671,159 @@ function buildRepositoryNotes(repo, worktreeMode) { * repo: { cwd: string, gitDir: string, bare: boolean }, * source: DashSource, * sourceResult: { entries: ExplorerEntry[] }, - * sourceRecords: Array, + * sourceRecords: Array, + * drillPath: TreemapPathNode[], * }} options * @returns {RepoTreemapReport} */ -function buildSourceScopeReport({ repo, source, sourceResult, sourceRecords }) { - const sourceTiles = compactTiles(buildLogicalTiles(sourceRecords, source.type === 'vault' ? 'vault' : 'cas')); +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 : [{ - label: 'empty source', - kind: 'meta', - value: 1, - detail: 'No CAS entries resolved for this source', - }], - notes: [ - sourceResult.entries.length === 0 - ? `No CAS entries resolved for ${source.type === 'vault' ? 'the vault' : source.type === 'ref' ? source.ref : source.treeOid}. Press r to browse refs or T to return to repository scope.` - : `Loaded ${sourceResult.entries.length} source entr${sourceResult.entries.length === 1 ? 'y' : 'ies'} for ${source.type === 'vault' ? 'the vault' : source.type === 'ref' ? source.ref : source.treeOid}.`, - 'Source view weights tiles by logical manifest size.', - ], - summary: { - 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, - }, + tiles: sourceTiles.length > 0 ? sourceTiles : [emptySourceTile()], + notes: buildSourceNotes({ source, sourceResult, drillPath }), + summary: buildSourceSummary({ repo, source, sourceResult }), }; } /** - * Build repository-scope physical and logical tiles. + * 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, @@ -593,49 +834,187 @@ function buildSourceScopeReport({ repo, source, sourceResult, sourceRecords }) { * sourceRecords: Array, * worktreeMode: TreemapWorktreeMode, * }} options - * @returns {Promise} + * @returns {Promise<{ + * worktreeRecords: HierarchyRecord[], + * worktreePaths: number, + * gitRecords: HierarchyRecord[], + * refInventory: RefInventory, + * refRecords: HierarchyRecord[], + * vaultResult: { entries: ExplorerEntry[] }, + * vaultLogicalRecords: HierarchyRecord[], + * sourceLogicalRecords: HierarchyRecord[], + * }>} */ -async function buildRepositoryScopeReport({ cas, source, repo, plumbing, sourceResult, sourceRecords, worktreeMode }) { - const { tiles: worktreeTiles, pathCount: worktreePaths } = await readWorktreeTiles({ plumbing, repo, worktreeMode }); - const gitTiles = await readGitDirTiles(repo); - const { tiles: refTiles, totalRefs } = await readRefNamespaceTiles(plumbing); +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); - const vaultTile = aggregateLogicalTile('vault', 'vault', vaultRecords); - const activeSourceTile = source.type !== 'vault' - ? aggregateLogicalTile('active source', 'cas', sourceRecords) - : null; - const tiles = compactTiles([ - ...worktreeTiles, - ...gitTiles, - ...refTiles, - ...(vaultTile ? [vaultTile] : []), - ...(activeSourceTile ? [activeSourceTile] : []), - ]); + + 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, - cwd: repo.cwd, + }); + const tiles = repositoryTilesForDrillPath({ + drillPath, + worktreeRecords, + gitRecords, + refRecords, + vaultLogicalRecords, + sourceLogicalRecords, + worktreeMode, + }); + return repositoryScopeReport({ + repo, source, - totalValue: tiles.reduce((sum, tile) => sum + tile.value, 0), - tiles: tiles.length > 0 ? tiles : [{ - label: 'empty repo', - kind: 'meta', - value: 1, - detail: 'No worktree, ref, or CAS regions were detected', - }], - notes: buildRepositoryNotes(repo, worktreeMode), - summary: { - bare: repo.bare, - gitDir: repo.gitDir, - worktreeItems: worktreeTiles.length, - worktreePaths, - refNamespaces: refTiles.length, - refCount: totalRefs, - vaultEntries: vaultResult.entries.length, - sourceEntries: sourceResult.entries.length, - }, - }; + worktreeMode, + drillPath, + tiles, + worktreeRecords, + worktreePaths, + refInventory, + vaultEntries: vaultResult.entries.length, + sourceEntries: sourceResult.entries.length, + }); } /** @@ -1000,6 +1379,7 @@ export async function readRefInventory(cas) { * source?: DashSource, * scope?: TreemapScope, * worktreeMode?: TreemapWorktreeMode, + * drillPath?: TreemapPathNode[], * }} [options] * @returns {Promise} */ @@ -1008,6 +1388,7 @@ export async function buildRepoTreemapReport(cas, options = {}) { source = { type: 'vault' }, scope = 'repository', worktreeMode = 'tracked', + drillPath = [], } = options; const service = await cas.getService(); const repo = await resolveRepoInfo(service.persistence.plumbing); @@ -1015,7 +1396,7 @@ export async function buildRepoTreemapReport(cas, options = {}) { const sourceRecords = await loadEntryRecords(cas, sourceResult.entries); if (scope === 'source') { - return buildSourceScopeReport({ repo, source, sourceResult, sourceRecords }); + return buildSourceScopeReport({ repo, source, sourceResult, sourceRecords, drillPath }); } return buildRepositoryScopeReport({ cas, @@ -1025,6 +1406,7 @@ export async function buildRepoTreemapReport(cas, options = {}) { sourceResult, sourceRecords, worktreeMode, + drillPath, }); } @@ -1133,6 +1515,7 @@ export function loadDoctorCmd(cas, source = { type: 'vault' }, entries = []) { * source?: DashSource, * scope?: TreemapScope, * worktreeMode?: TreemapWorktreeMode, + * drillPath?: TreemapPathNode[], * }} [options] */ export function loadTreemapCmd(cas, options = {}) { @@ -1140,10 +1523,11 @@ export function loadTreemapCmd(cas, options = {}) { source = { type: 'vault' }, scope = 'repository', worktreeMode = 'tracked', + drillPath = [], } = options; return async () => { try { - const report = await buildRepoTreemapReport(cas, { source, scope, worktreeMode }); + const report = await buildRepoTreemapReport(cas, { source, scope, worktreeMode, drillPath }); return /** @type {const} */ ({ type: 'loaded-treemap', report }); } catch (/** @type {any} */ err) { return /** @type {const} */ ({ @@ -1151,6 +1535,7 @@ export function loadTreemapCmd(cas, options = {}) { 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 a062cc6..0ae6d92 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -139,6 +139,11 @@ function appendSelectionBadges(parts, model, ctx) { if (model.treemapScope === 'repository') { parts.push(badge(`files ${model.treemapWorktreeMode}`, { variant: 'accent', ctx })); } + parts.push(badge(`level ${treemapLevelLabel(model)}`, { variant: 'info', ctx })); + const tile = selectedTreemapTile(model); + if (tile) { + parts.push(badge(`focus ${tile.label}`, { variant: 'warning', ctx })); + } } if (model.activeDrawer && model.activeDrawer !== 'treemap') { parts.push(badge(`${model.activeDrawer} drawer`, { variant: 'info', ctx })); @@ -164,6 +169,29 @@ function sourceLabel(source) { 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; + } + return model.treemapReport.tiles[Math.max(0, Math.min(model.treemapFocus, model.treemapReport.tiles.length - 1))] ?? null; +} + /** * Render the header surface. * @@ -229,6 +257,81 @@ function limitWrappedLines(lines, width, maxLines) { return capped; } +/** + * Build one titled sidebar section within a line budget. + * + * @param {{ title: string, body: string, width: number, bodyLines: number }} options + * @returns {string[]} + */ +function sidebarSection(options) { + const lines = options.body.length === 0 ? [''] : options.body.split('\n'); + return [ + options.title, + ...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 }} options + * @returns {string} + */ +function composeTreemapSidebarText(options) { + const sectionBlocks = [ + sidebarSection({ + title: 'Overview', + body: options.sections.overview, + width: options.width, + bodyLines: 4, + }), + sidebarSection({ + title: 'Focused Region', + body: options.sections.focused, + width: options.width, + bodyLines: 3, + }), + sidebarSection({ + title: 'Legend', + body: options.sections.legend, + width: options.width, + bodyLines: 6, + }), + sidebarSection({ + title: 'Largest Regions', + body: options.sections.regions || 'No regions to display.', + width: options.width, + bodyLines: 4, + }), + sidebarSection({ + title: 'Notes', + body: options.sections.notes || 'No notes.', + width: options.width, + bodyLines: Math.max(2, options.height - 23), + }), + ]; + return sectionBlocks.flat().join('\n'); +} + /** * Wrap toast copy with simple fixed-width chunks. * @@ -826,6 +929,7 @@ function renderTreemapMapBody(model, deps, size) { 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.'; @@ -838,33 +942,21 @@ function renderTreemapMapBody(model, deps, size) { * @returns {string} */ function renderTreemapSidebarText(options) { - if (options.model.treemapStatus === 'loading') { - return `Overview\nLoading ${options.model.treemapScope} treemap...`; - } - if (options.model.treemapStatus === 'error') { - return `Overview\nFailed to load treemap\n\n${options.model.treemapError ?? 'unknown error'}`; - } - if (!options.model.treemapReport) { - return 'Overview\nTreemap has not been loaded yet.'; + 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, + width: options.width, + height: options.height, }); - return [ - 'Overview', - sections.overview, - '', - 'Legend', - sections.legend, - '', - 'Largest Regions', - sections.regions || 'No regions to display.', - '', - 'Notes', - sections.notes || 'No notes.', - ].join('\n'); } /** @@ -881,7 +973,7 @@ function renderTreemapView(model, deps, options) { const mapHeight = options.height; const sidebarHeight = options.height; - const mapTitle = model.treemapScope === 'repository' ? 'Repository Map' : 'Source Map'; + const mapTitle = `${model.treemapScope === 'repository' ? 'Repository Map' : 'Source Map'} · ${treemapLevelLabel(model)}`; const mapPanel = renderPanel({ title: mapTitle, body: renderTreemapMapBody(model, deps, { mapWidth, mapHeight }), @@ -919,9 +1011,9 @@ function renderFooterSurface(model, ctx, width) { const lines = model.activeDrawer === 'treemap' ? [ '─'.repeat(Math.max(1, width)), + `${kbd('j/k', { ctx })} regions ${kbd('d/u', { ctx })} page ${kbd('+', { ctx })} descend ${kbd('-', { ctx })} ascend`, `${kbd('T', { ctx })} scope ${kbd('i', { ctx })} files ${kbd('r', { ctx })} refs ${kbd('ctrl+p', { ctx })} palette`, `${kbd('s', { ctx })} stats ${kbd('g', { ctx })} doctor ${kbd('esc', { ctx })} back ${kbd('q', { ctx })} quit`, - '', ] : model.activeDrawer === 'refs' ? [ diff --git a/bin/ui/dashboard.js b/bin/ui/dashboard.js index ea8f348..0b6cbcc 100644 --- a/bin/ui/dashboard.js +++ b/bin/ui/dashboard.js @@ -25,6 +25,8 @@ import { renderDashboard } from './dashboard-view.js'; * @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 @@ -50,6 +52,8 @@ import { renderDashboard } from './dashboard-view.js'; * | { type: 'open-refs' } * | { type: 'toggle-treemap-scope' } * | { type: 'toggle-treemap-worktree' } + * | { type: 'treemap-drill-in' } + * | { type: 'treemap-drill-out' } * | { type: 'overlay-close' } * } DashAction */ @@ -72,7 +76,7 @@ import { renderDashboard } from './dashboard-view.js'; * | { type: 'loaded-doctor', report: any, source: DashSource } * | { type: 'loaded-treemap', report: any } * | { type: 'dismiss-toast', id: number } - * | { type: 'load-error', source: string, slug?: string, forSource?: DashSource, scopeId?: TreemapScope, worktreeMode?: TreemapWorktreeMode, error: string } + * | { type: 'load-error', source: string, slug?: string, forSource?: DashSource, scopeId?: TreemapScope, worktreeMode?: TreemapWorktreeMode, drillPath?: TreemapPathNode[], error: string } * } DashMsg */ @@ -107,6 +111,8 @@ import { renderDashboard } from './dashboard-view.js'; * @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 @@ -152,6 +158,8 @@ export function createKeyBindings() { .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 }); @@ -205,6 +213,20 @@ const PALETTE_ITEMS = [ 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', @@ -369,6 +391,45 @@ function sourceEquals(left, right) { return left.type === 'oid' && right.type === 'oid' && left.treeOid === right.treeOid; } +/** + * 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 {DashModel} model + * @returns {RepoTreemapTile | null} + */ +function selectedTreemapTile(model) { + return model.treemapReport?.tiles[clampTreemapFocus(model.treemapFocus, model.treemapReport?.tiles ?? [])] ?? null; +} + /** * Build rows for the refs browser table. * @@ -481,7 +542,7 @@ function dismissToast(model, id) { /** * Return true when a treemap load message is stale for the current model. * - * @param {{ scopeId?: TreemapScope, worktreeMode?: TreemapWorktreeMode }} msg + * @param {{ scopeId?: TreemapScope, worktreeMode?: TreemapWorktreeMode, drillPath?: TreemapPathNode[] }} msg * @param {DashModel} model * @returns {boolean} */ @@ -489,6 +550,9 @@ function isStaleTreemapLoad(msg, model) { if (msg.scopeId && msg.scopeId !== model.treemapScope) { return true; } + if (msg.drillPath && !treemapPathEquals(msg.drillPath, model.treemapPath)) { + return true; + } return msg.scopeId === 'repository' && Boolean(msg.worktreeMode) && msg.worktreeMode !== model.treemapWorktreeMode; @@ -649,6 +713,8 @@ function createInitModel(ctx, source) { doctorError: null, treemapScope: 'repository', treemapWorktreeMode: 'tracked', + treemapPath: [], + treemapFocus: 0, treemapStatus: 'idle', treemapReport: null, treemapError: null, @@ -684,6 +750,139 @@ function handleRefsViewAction(action, model, deps) { return null; } +/** + * Handle cursor movement inside the treemap view. + * + * @param {{ type: 'move', delta: number }} action + * @param {DashModel} model + * @returns {[DashModel, DashCmd[]]} + */ +function handleTreemapMove(action, model) { + const total = model.treemapReport?.tiles.length ?? 0; + if (total === 0) { + return [model, []]; + } + const treemapFocus = (model.treemapFocus + action.delta + total) % total; + return [{ ...model, treemapFocus }, []]; +} + +/** + * Handle page-wise movement inside the treemap view. + * + * @param {{ type: 'page', delta: number }} action + * @param {DashModel} model + * @returns {[DashModel, DashCmd[]]} + */ +function handleTreemapPage(action, model) { + const total = model.treemapReport?.tiles.length ?? 0; + if (total === 0) { + return [model, []]; + } + 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. * @@ -917,6 +1116,23 @@ function openDoctorDrawer(model, deps) { }, [/** @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. * @@ -945,11 +1161,74 @@ function openTreemapDrawer(model, deps) { palette: null, treemapStatus: 'loading', treemapError: null, - }, [/** @type {DashCmd} */ (loadTreemapCmd(deps.cas, { - source: model.source, - scope: model.treemapScope, - worktreeMode: model.treemapWorktreeMode, - }))]]; + }, [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, + }; } /** @@ -961,10 +1240,15 @@ function openTreemapDrawer(model, deps) { */ function toggleTreemapScope(model, deps) { const treemapScope = model.treemapScope === 'repository' ? 'source' : 'repository'; - if (treemapReportMatches({ ...model, treemapScope }, model.treemapReport)) { + const nextModel = { + ...model, + treemapScope, + treemapPath: [], + treemapFocus: 0, + }; + if (treemapReportMatches(nextModel, model.treemapReport)) { return [{ - ...model, - treemapScope, + ...nextModel, activeDrawer: 'treemap', palette: null, treemapStatus: 'ready', @@ -972,17 +1256,12 @@ function toggleTreemapScope(model, deps) { }, []]; } return [{ - ...model, - treemapScope, + ...nextModel, activeDrawer: 'treemap', palette: null, treemapStatus: 'loading', treemapError: null, - }, [/** @type {DashCmd} */ (loadTreemapCmd(deps.cas, { - source: model.source, - scope: treemapScope, - worktreeMode: model.treemapWorktreeMode, - }))]]; + }, [treemapLoad(nextModel, deps)]]; } /** @@ -1001,6 +1280,8 @@ function toggleTreemapWorktreeMode(model, deps) { ...model, treemapScope: 'repository', treemapWorktreeMode, + treemapPath: [], + treemapFocus: 0, activeDrawer: 'treemap', palette: null, }; @@ -1015,11 +1296,7 @@ function toggleTreemapWorktreeMode(model, deps) { ...nextModel, treemapStatus: 'loading', treemapError: null, - }, [/** @type {DashCmd} */ (loadTreemapCmd(deps.cas, { - source: model.source, - scope: 'repository', - worktreeMode: treemapWorktreeMode, - }))]]; + }, [treemapLoad(nextModel, deps)]]; } /** @@ -1086,40 +1363,7 @@ function switchSource(model, deps, source) { activeDrawer: null, }, []]; } - const clearedTable = syncTable(model.table, { - entries: [], - manifestCache: new Map(), - rows: model.rows, - focusRow: 0, - scrollY: 0, - }); - return [{ - ...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, - }, [/** @type {DashCmd} */ (loadEntriesCmd(deps.cas, source))]]; + return [buildSourceSwitchModel(model, source), [/** @type {DashCmd} */ (loadEntriesCmd(deps.cas, source))]]; } /** @@ -1185,6 +1429,8 @@ function handlePaletteSelect(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'), @@ -1321,31 +1567,19 @@ function resizeSplitPane(model, delta) { * @returns {[DashModel, DashCmd[]] | null} */ function handleOverlayAction(action, model, deps) { - if (action.type === 'open-palette') { - return setPalette(model, createPalette(model.rows)); - } - if (action.type === 'open-stats') { - return openStatsDrawer(model, deps); - } - if (action.type === 'open-doctor') { - return openDoctorDrawer(model, deps); - } - if (action.type === 'open-refs') { - return openRefsDrawer(model, deps); - } - if (action.type === 'open-treemap') { - return openTreemapDrawer(model, deps); - } - if (action.type === 'toggle-treemap-scope') { - return toggleTreemapScope(model, deps); - } - if (action.type === 'toggle-treemap-worktree') { - return toggleTreemapWorktreeMode(model, deps); - } - if (action.type === 'overlay-close') { - return closeOverlay(model); - } - return null; + 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; } /** @@ -1425,6 +1659,10 @@ function handleAction(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, []]; } @@ -1448,55 +1686,100 @@ function handleAction(action, model, deps) { */ function handleLoadedReport(msg, model) { if (msg.type === 'loaded-refs') { - 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, - }), - }, []]; + return handleLoadedRefs(msg, model); } if (msg.type === 'loaded-stats') { - if (!sourceEquals(msg.source, model.source)) { - return [model, []]; - } - return [{ - ...model, - statsStatus: 'ready', - statsReport: msg.stats, - statsError: null, - }, []]; + return handleLoadedStats(msg, model); } if (msg.type === 'loaded-doctor') { - if (!sourceEquals(msg.source, model.source)) { - return [model, []]; - } - return [{ - ...model, - doctorStatus: 'ready', - doctorReport: msg.report, - doctorError: null, - }, []]; + return handleLoadedDoctor(msg, model); } if (msg.type === 'loaded-treemap') { - if (!treemapReportMatches(model, msg.report)) { - return [model, []]; - } - return [{ - ...model, - treemapStatus: 'ready', - treemapReport: msg.report, - treemapError: null, - }, []]; + 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. * @@ -1583,7 +1866,7 @@ function handleUpdate(msg, model, deps) { * @returns {boolean} */ function treemapReportMatches(model, report) { - if (!report || report.scope !== model.treemapScope || !sourceEquals(report.source, model.source)) { + if (!report || report.scope !== model.treemapScope || !sourceEquals(report.source, model.source) || !treemapPathEquals(report.drillPath ?? [], model.treemapPath)) { return false; } if (report.scope !== 'repository') { diff --git a/bin/ui/repo-treemap.js b/bin/ui/repo-treemap.js index 77123c1..864250d 100644 --- a/bin/ui/repo-treemap.js +++ b/bin/ui/repo-treemap.js @@ -85,6 +85,16 @@ function clip(text, 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. * @@ -316,6 +326,19 @@ function putCell(grid, cell) { 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. * @@ -325,28 +348,30 @@ const LABEL_FOREGROUND = [255, 255, 255]; * * @param {ReturnType} grid * @param {{ tile: RepoTreemapTile, x: number, y: number, width: number, height: number }} rect + * @param {boolean} focused */ -function outlineRect(grid, rect) { +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: '─', kind: rect.tile.kind }); - putCell(grid, { row: bottom, col, ch: '─', kind: rect.tile.kind }); + 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: '│', kind: rect.tile.kind }); - putCell(grid, { row, col: right, ch: '│', kind: rect.tile.kind }); + 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: '┌', kind: rect.tile.kind }); - putCell(grid, { row: top, col: right, ch: '┐', kind: rect.tile.kind }); - putCell(grid, { row: bottom, col: left, ch: '└', kind: rect.tile.kind }); - putCell(grid, { row: bottom, col: right, ch: '┘', 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 }); } /** @@ -375,8 +400,9 @@ function paintLabel(grid, rect) { * * @param {ReturnType} grid * @param {{ tile: RepoTreemapTile, x: number, y: number, width: number, height: number }} rect + * @param {string | null} selectedTileId */ -function paintRect(grid, rect) { +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++) { @@ -386,7 +412,7 @@ function paintRect(grid, rect) { } } } - outlineRect(grid, rect); + outlineRect(grid, rect, rect.tile.id === selectedTileId); paintLabel(grid, rect); } @@ -456,9 +482,11 @@ function renderOverview(report, 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('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), ]; @@ -467,15 +495,52 @@ function renderOverview(report, 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. * @@ -492,7 +557,7 @@ function renderNotes(report, width, lines) { * Render only the treemap grid. * * @param {RepoTreemapReport} report - * @param {{ ctx: BijouContext, width: number, height: number }} options + * @param {{ ctx: BijouContext, width: number, height: number, selectedTileId?: string | null }} options * @returns {string} */ export function renderRepoTreemapMap(report, options) { @@ -501,7 +566,7 @@ export function renderRepoTreemapMap(report, options) { 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); + paintRect(grid, rect, options.selectedTileId ?? null); } return renderGrid(grid, options.ctx).join('\n'); } @@ -510,14 +575,15 @@ export function renderRepoTreemapMap(report, options) { * Build text sections for the treemap sidebar. * * @param {RepoTreemapReport} report - * @param {{ ctx: BijouContext, width: number, height: number }} options - * @returns {{ overview: string, legend: string, regions: string, notes: string }} + * @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, @@ -531,7 +597,7 @@ export function renderRepoTreemapSidebar(report, options) { * Render a repository treemap as ANSI-aware text. * * @param {RepoTreemapReport} report - * @param {{ ctx: BijouContext, width: number, height: number }} options + * @param {{ ctx: BijouContext, width: number, height: number, selectedTileId?: string | null }} options * @returns {string} */ export function renderRepoTreemap(report, options) { diff --git a/test/unit/cli/dashboard-cmds.test.js b/test/unit/cli/dashboard-cmds.test.js index 87394e7..23d4b32 100644 --- a/test/unit/cli/dashboard-cmds.test.js +++ b/test/unit/cli/dashboard-cmds.test.js @@ -153,6 +153,24 @@ function makeSourceTreemapCas(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, { @@ -164,19 +182,7 @@ async function buildRepositoryReport({ source = { type: 'oid', treeOid: 'source- ignoredPaths: ['node_modules/', 'coverage/'], })); const cas = makeRepositoryTreemapCas(plumbing); - const report = await buildRepoTreemapReport(cas, { source, scope: 'repository', worktreeMode }); - return { report, 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', - }); + return run({ repoDir, plumbing, cas }); }); } @@ -301,11 +307,11 @@ describe('buildRepoTreemapReport repository scope', () => { expect(labels).toEqual(expect.arrayContaining([ 'README.md', 'src', - '.git/objects/pack-1', + '.git/objects', 'refs/heads', 'refs/warp', - 'vault', - 'active source', + 'vault:alpha', + 'oid:source-tree', ])); expect(labels).not.toContain('node_modules'); expect(report.notes).toEqual(expect.arrayContaining([ @@ -330,6 +336,31 @@ describe('buildRepoTreemapReport repository scope', () => { }); }); +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(); diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index 17b8ef5..155ba4b 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -110,6 +110,8 @@ function makeModel(overrides = {}) { doctorError: null, treemapScope: 'repository', treemapWorktreeMode: 'tracked', + treemapPath: [], + treemapFocus: 0, treemapStatus: 'idle', treemapReport: null, treemapError: null, @@ -186,32 +188,62 @@ function makeRefItems() { ]; } +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: [ - { label: 'src', kind: 'worktree', value: 4096, detail: '2 tracked paths · 4.0K on disk' }, - { label: '.git/objects', kind: 'git', value: 2048, detail: '2.0K on disk' }, - { label: 'vault', kind: 'vault', value: 2048, detail: '2 entries · 2.0K logical' }, + 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, and logical CAS region sizes.', + '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: { - bare: false, - gitDir: '/tmp/git-cas-fixture/.git', - worktreeItems: 1, - worktreePaths: 2, - refNamespaces: 1, - refCount: 3, - vaultEntries: 2, - sourceEntries: 2, - }, + summary: makeTreemapSummary(), ...overrides, }; } @@ -233,9 +265,15 @@ function makeFullScreenTreemapModel() { treemapStatus: 'ready', treemapReport: makeTreemapReport({ tiles: [ - { label: 'src', kind: 'worktree', value: 4096, detail: '2 tracked paths · 4.0K on disk' }, - { label: '.git/objects', kind: 'git', value: 2048, detail: '2.0K on disk' }, - { label: 'other', kind: 'meta', value: 1024, detail: '2 smaller regions' }, + 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, @@ -329,7 +367,9 @@ describe('dashboard palette and overlay commands', () => { const [onTreemap] = app.update(keyMsg('down'), withPalette); const [onTreemapScope] = app.update(keyMsg('down'), onTreemap); const [onTreemapWorktree] = app.update(keyMsg('down'), onTreemapScope); - const [onStats] = app.update(keyMsg('down'), onTreemapWorktree); + 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'); @@ -369,7 +409,7 @@ describe('dashboard toast dismissal', () => { }); }); -describe('dashboard treemap shortcuts', () => { +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()); @@ -377,7 +417,9 @@ describe('dashboard treemap shortcuts', () => { 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({ @@ -409,6 +451,59 @@ describe('dashboard treemap shortcuts', () => { 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); + }); +}); + +describe('dashboard treemap drill 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('- ascends to the parent treemap level', () => { + const app = createDashboardApp(makeDeps()); + const model = 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' }, + }], + }), + }); + const [next, cmds] = app.update(keyMsg('-'), model); + expect(next.treemapPath).toEqual([]); + expect(next.treemapStatus).toBe('loading'); + expect(cmds).toHaveLength(1); + }); }); describe('dashboard refs shortcuts', () => { @@ -482,13 +577,17 @@ describe('dashboard report loading', () => { describe('dashboard treemap report and toast messages', () => { it('loaded-treemap stores the report for the active scope', () => { const app = createDashboardApp(makeDeps()); - const report = { - scope: 'repository', - worktreeMode: 'tracked', - cwd: '/tmp/git-cas-fixture', - source: { type: 'vault' }, + const report = makeTreemapReport({ totalValue: 2048, - tiles: [{ label: 'src', kind: 'worktree', value: 2048, detail: '2.0K on disk' }], + 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, @@ -500,7 +599,7 @@ describe('dashboard treemap report and toast messages', () => { 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); @@ -673,6 +772,7 @@ describe('dashboard footer rendering', () => { expect(rendered).toContain('treemap'); expect(rendered).toContain('scope'); expect(rendered).toContain('files'); + expect(rendered).toContain('refs'); expect(rendered).toContain('clos'); expect(rendered).toContain('quit'); }); @@ -685,6 +785,8 @@ describe('dashboard footer rendering', () => { columns: 120, }); expect(rendered).toContain('back'); + expect(rendered).toContain('descend'); + expect(rendered).toContain('ascend'); }); }); @@ -743,12 +845,15 @@ describe('dashboard treemap rendering', () => { expect(rendered).toContain('Repository Map'); expect(rendered).toContain('Treemap Details'); 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('tracked paths 2'); + expect(rendered).toContain('2 tracked paths'); expect(rendered).toContain('Repository view mixes Git-reported'); }); @@ -759,8 +864,9 @@ describe('dashboard treemap rendering', () => { treemapStatus: 'ready', treemapReport: makeTreemapReport({ scope: 'source', + breadcrumb: ['source'], totalValue: 0, - tiles: [{ label: 'empty source', kind: 'meta', value: 1, detail: 'No CAS entries resolved for this source' }], + 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.', diff --git a/test/unit/cli/repo-treemap.test.js b/test/unit/cli/repo-treemap.test.js index d0af25b..87b0630 100644 --- a/test/unit/cli/repo-treemap.test.js +++ b/test/unit/cli/repo-treemap.test.js @@ -8,21 +8,23 @@ function makeReport(overrides = {}) { worktreeMode: 'tracked', cwd: '/tmp/git-cas-fixture', source: { type: 'vault' }, + drillPath: [], + breadcrumb: ['repository'], totalValue: 21_400_000, tiles: [ - { label: 'docs', kind: 'worktree', value: 3_638_000, detail: '33 tracked paths · 3.5M on disk' }, - { label: 'public', kind: 'worktree', value: 107_000, detail: '5 tracked paths · 104.5K on disk' }, - { label: 'package-lock.json', kind: 'worktree', value: 107_000, detail: '1 tracked path · 104.5K on disk' }, - { label: 'test', kind: 'worktree', value: 64_000, detail: '17 tracked paths · 62.5K on disk' }, - { label: 'pnpm-lock.yaml', kind: 'worktree', value: 64_000, detail: '1 tracked path · 62.5K on disk' }, - { label: 'src', kind: 'worktree', value: 43_000, detail: '6 tracked paths · 42.0K on disk' }, - { label: 'scripts', kind: 'worktree', value: 21_000, detail: '13 tracked paths · 20.5K on disk' }, - { label: '.git/objects', kind: 'git', value: 8_000_000, detail: '7.6M on disk' }, - { label: 'refs/git-cms', kind: 'ref', value: 2_000_000, detail: '17 refs' }, - { label: 'vault', kind: 'vault', value: 1_500_000, detail: '2 entries · 1.4M logical' }, + { 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, and logical CAS region sizes.', + 'Repository view mixes Git-reported worktree paths, .git on-disk bytes, ref namespaces, and logical CAS region sizes.', ], summary: { bare: false, @@ -67,7 +69,7 @@ describe('repo treemap map rendering', () => { it('renders label text as bold white without painting stripe backgrounds', () => { const output = renderRepoTreemapMap(makeReport({ totalValue: 10, - tiles: [{ label: 'docs', kind: 'worktree', value: 10, detail: '10 tracked paths' }], + 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, @@ -95,6 +97,22 @@ describe('repo treemap sidebar rendering', () => { 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'], From 5355380f0091938b85904e2944c6a30c523ecb89 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 19 Mar 2026 11:49:36 -0700 Subject: [PATCH 17/22] fix(ui): accept treemap drill symbol keys --- bin/ui/dashboard.js | 25 ++++++++++++- test/unit/cli/dashboard.test.js | 62 ++++++++++++++++++++++++--------- 2 files changed, 69 insertions(+), 18 deletions(-) diff --git a/bin/ui/dashboard.js b/bin/ui/dashboard.js index 0b6cbcc..020bd95 100644 --- a/bin/ui/dashboard.js +++ b/bin/ui/dashboard.js @@ -1817,6 +1817,29 @@ function handleAppMsg(msg, model, cas) { 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. * @@ -1833,7 +1856,7 @@ function handleUpdate(msg, model, deps) { return handleFilterKey(msg, model); } if (msg.type === 'key') { - const action = deps.keyMap.handle(msg); + const action = runtimeSymbolAction(msg) ?? deps.keyMap.handle(msg); if (action) { return handleAction(action, model, deps); } return [model, []]; } diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index 155ba4b..659f548 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -464,7 +464,29 @@ describe('dashboard treemap view shortcuts', () => { }); }); -describe('dashboard treemap drill shortcuts', () => { +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({ @@ -478,32 +500,38 @@ describe('dashboard treemap drill shortcuts', () => { expect(cmds).toHaveLength(1); }); - it('- ascends to the parent treemap level', () => { + it('raw + key events also drill into the focused treemap region', () => { const app = createDashboardApp(makeDeps()); const model = 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' }, - }], - }), + 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', () => { From 8663c1768ef5e1765e49727ce32f29ee6db6bdb4 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 19 Mar 2026 18:57:17 -0700 Subject: [PATCH 18/22] feat(ui): give the git-cas tui a distinct theme --- bin/ui/dashboard-view.js | 169 +++++++++++++++++----------- bin/ui/encryption-card.js | 14 ++- bin/ui/manifest-view.js | 37 +++--- bin/ui/repo-treemap.js | 16 +-- bin/ui/theme.js | 168 +++++++++++++++++++++++++++ test/unit/cli/dashboard.test.js | 24 ++-- test/unit/cli/manifest-view.test.js | 6 +- 7 files changed, 329 insertions(+), 105 deletions(-) create mode 100644 bin/ui/theme.js diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index 0ae6d92..51c3bdf 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -2,9 +2,10 @@ * Pure render functions for the vault dashboard. */ -import { badge, boxV3, createSurface, parseAnsiToSurface, kbd } from '@flyingrobots/bijou'; +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'; @@ -20,10 +21,10 @@ const SPLIT_MIN_LIST_WIDTH = 28; const SPLIT_MIN_DETAIL_WIDTH = 32; const SPLIT_DIVIDER_SIZE = 1; const TOAST_THEME = { - error: { label: 'Error', bg: [185, 28, 28], fg: [255, 255, 255] }, - warning: { label: 'Warning', bg: [202, 138, 4], fg: [17, 24, 39] }, - info: { label: 'Info', bg: [37, 99, 235], fg: [255, 255, 255] }, - success: { label: 'Success', bg: [22, 163, 74], fg: [255, 255, 255] }, + 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 }, }; /** @@ -100,20 +101,20 @@ function blitInline(target, options) { */ function headerParts(model, ctx) { const parts = [ - badge(`${model.filtered.length}/${model.entries.length || model.filtered.length} visible`, { variant: 'info', ctx }), + 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(badge(model.filtering ? 'filtering' : `filter ${model.filterText}`, { variant: 'accent', ctx })); + parts.push(chipSurface(ctx, model.filtering ? 'filtering' : `filter ${model.filterText}`, 'accent')); } if (model.activeDrawer === 'treemap') { - parts.push(badge('treemap view', { variant: 'primary', ctx })); + parts.push(chipSurface(ctx, 'atlas view', 'brand')); } else if (model.activeDrawer === 'refs') { - parts.push(badge('refs view', { variant: 'primary', ctx })); + parts.push(chipSurface(ctx, 'ref index', 'brand')); } else { - parts.push(badge(`pane ${model.splitPane.focused === 'a' ? 'entries' : 'inspector'}`, { variant: 'primary', ctx })); + parts.push(chipSurface(ctx, model.splitPane.focused === 'a' ? 'entries ledger' : 'manifest inspector', 'brand')); } appendSelectionBadges(parts, model, ctx); return parts; @@ -129,27 +130,27 @@ function headerParts(model, ctx) { function appendSelectionBadges(parts, model, ctx) { const selected = model.filtered[model.table.focusRow]; if (selected && model.activeDrawer !== 'treemap') { - parts.push(badge(`selected ${selected.slug}`, { variant: 'accent', ctx })); + parts.push(chipSurface(ctx, `selected ${selected.slug}`, 'accent')); } if (model.toasts.length > 0) { - parts.push(badge(`alerts ${model.toasts.length}`, { variant: 'warning', ctx })); + parts.push(chipSurface(ctx, `alerts ${model.toasts.length}`, 'warning')); } if (model.activeDrawer === 'treemap') { - parts.push(badge(`scope ${model.treemapScope}`, { variant: 'primary', ctx })); + parts.push(chipSurface(ctx, `scope ${model.treemapScope}`, 'brand')); if (model.treemapScope === 'repository') { - parts.push(badge(`files ${model.treemapWorktreeMode}`, { variant: 'accent', ctx })); + parts.push(chipSurface(ctx, `files ${model.treemapWorktreeMode}`, 'accent')); } - parts.push(badge(`level ${treemapLevelLabel(model)}`, { variant: 'info', ctx })); + parts.push(chipSurface(ctx, `level ${treemapLevelLabel(model)}`, 'info')); const tile = selectedTreemapTile(model); if (tile) { - parts.push(badge(`focus ${tile.label}`, { variant: 'warning', ctx })); + parts.push(chipSurface(ctx, `focus ${tile.label}`, 'warning')); } } if (model.activeDrawer && model.activeDrawer !== 'treemap') { - parts.push(badge(`${model.activeDrawer} drawer`, { variant: 'info', ctx })); + parts.push(chipSurface(ctx, `${model.activeDrawer} drawer`, 'info')); } if (model.palette) { - parts.push(badge('palette', { variant: 'warning', ctx })); + parts.push(chipSurface(ctx, 'command deck', 'warning')); } } @@ -201,15 +202,31 @@ function selectedTreemapTile(model) { */ function renderHeaderSurface(model, deps) { const surface = createSurface(Math.max(1, model.columns), 4); - surface.blit(textSurface('git-cas repository explorer', surface.width, 1), 0, 0); - surface.blit(textSurface(tailClip(`cwd ${deps.cwdLabel ?? '-'}`, surface.width), surface.width, 1), 0, 1); + 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: [sourceLabel(model.source), ...headerParts(model, deps.ctx)], + parts: [inlineSurface(deps.ctx, sourceLabel(model.source), { tone: 'primary' }), ...headerParts(model, deps.ctx)], maxWidth: surface.width, }); - surface.blit(textSurface('─'.repeat(surface.width), surface.width, 1), 0, 3); + surface.blit(textSurface(shellRule(deps.ctx, surface.width), surface.width, 1), 0, 3); return surface; } @@ -260,13 +277,13 @@ function limitWrappedLines(lines, width, maxLines) { /** * Build one titled sidebar section within a line budget. * - * @param {{ title: string, body: string, width: number, bodyLines: number }} options + * @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 [ - options.title, + sectionHeading(options.ctx, options.title, options.tone ?? 'brand'), ...limitWrappedLines(lines, options.width, Math.max(1, options.bodyLines)), ]; } @@ -293,7 +310,7 @@ function treemapSidebarStateText(model) { /** * Compose the full set of sidebar sections for the treemap view. * - * @param {{ sections: ReturnType, width: number, height: number }} options + * @param {{ sections: ReturnType, width: number, height: number, ctx: BijouContext }} options * @returns {string} */ function composeTreemapSidebarText(options) { @@ -301,30 +318,40 @@ function composeTreemapSidebarText(options) { 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), }), @@ -396,9 +423,10 @@ function renderToastSurface(toast, opts) { * Build drawer copy for the stats overlay. * * @param {DashModel} model + * @param {BijouContext} ctx * @returns {string} */ -function statsDrawerBody(model) { +function statsDrawerBody(model, ctx) { if (model.statsStatus === 'loading') { return 'Loading source stats...'; } @@ -406,7 +434,7 @@ function statsDrawerBody(model) { return `Failed to load stats\n\n${model.statsError ?? 'unknown error'}`; } return model.statsReport - ? renderVaultStats(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.'; } @@ -414,9 +442,10 @@ function statsDrawerBody(model) { * Build drawer copy for the doctor overlay. * * @param {DashModel} model + * @param {BijouContext} ctx * @returns {string} */ -function doctorDrawerBody(model) { +function doctorDrawerBody(model, ctx) { if (model.doctorStatus === 'loading') { return 'Loading doctor report...'; } @@ -426,7 +455,7 @@ function doctorDrawerBody(model) { return typeof model.doctorReport === 'string' ? model.doctorReport : model.doctorReport - ? renderDoctorReport(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.'; } @@ -439,8 +468,8 @@ function doctorDrawerBody(model) { */ function renderStatsDrawer(model, opts) { return renderOverlayPanel({ - title: 'Source Stats', - body: statsDrawerBody(model), + 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, @@ -456,8 +485,8 @@ function renderStatsDrawer(model, opts) { */ function renderDoctorDrawer(model, opts) { return renderOverlayPanel({ - title: 'Doctor Report', - body: doctorDrawerBody(model), + 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, @@ -534,7 +563,7 @@ function renderPaletteSurface(model, opts) { ctx: opts.ctx, }); return renderOverlayPanel({ - title: 'Command Palette', + title: 'Command Deck', body, width, height: Math.min(opts.height, model.palette.height + 3), @@ -638,13 +667,17 @@ function renderListPane(model, opts) { const innerWidth = Math.max(1, opts.width - 2); const innerHeight = Math.max(1, opts.height - 2); const metaLines = [ - clip(model.filtering ? `filter /${model.filterText}\u2588` : model.filterText ? `filter ${model.filterText}` : 'filter all', innerWidth), - clip(`${model.filtered.length} assets focus row ${model.table.rows.length ? model.table.focusRow + 1 : 0}`, innerWidth), + 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' ? 'Loading...' : model.error ? `Error: ${model.error}` : 'No entries'); + 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, @@ -655,7 +688,7 @@ function renderListPane(model, opts) { return boxV3(textSurface(metaLines.join('\n'), innerWidth, innerHeight), { ctx: opts.ctx, - title: model.splitPane.focused === 'a' ? 'Entries *' : 'Entries', + title: model.splitPane.focused === 'a' ? 'Entries Ledger *' : 'Entries Ledger', width: opts.width, }); } @@ -677,24 +710,26 @@ function renderDetailPane(model, opts) { content.blit(textSurface('Select an entry to inspect it.', innerWidth, innerHeight), 0, 0); return boxV3(content, { ctx: opts.ctx, - title: model.splitPane.focused === 'b' ? 'Inspector *' : 'Inspector', + title: model.splitPane.focused === 'b' ? 'Manifest Inspector *' : 'Manifest Inspector', width: opts.width, }); } const manifest = model.manifestCache.get(entry.slug); const summary = [ - `asset ${entry.slug}`, - `tree ${entry.treeOid.slice(0, 12)}...`, + `${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 ? 'Loading manifest...' : 'Manifest not loaded yet.'; + 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' ? 'Inspector *' : 'Inspector', + title: model.splitPane.focused === 'b' ? 'Manifest Inspector *' : 'Manifest Inspector', width: opts.width, }); } @@ -708,7 +743,7 @@ function renderDetailPane(model, opts) { return boxV3(content, { ctx: opts.ctx, - title: model.splitPane.focused === 'b' ? 'Inspector *' : 'Inspector', + title: model.splitPane.focused === 'b' ? 'Manifest Inspector *' : 'Manifest Inspector', width: opts.width, }); } @@ -816,9 +851,10 @@ function renderRefsListBody(model, deps, size) { * Render the refs-browser detail sidebar. * * @param {DashModel} model + * @param {BijouContext} ctx * @returns {string} */ -function renderRefsDetailBody(model) { +function renderRefsDetailBody(model, ctx) { const current = selectedRef(model); const namespaceCounts = new Map(); for (const ref of model.refsItems) { @@ -826,6 +862,7 @@ function renderRefsDetailBody(model) { } const sidebarLines = [ + sectionHeading(ctx, 'Inventory', 'brand'), `refs ${model.refsItems.length} under ${namespaceCounts.size} namespaces`, `current ${sourceLabel(model.source)}`, '', @@ -833,6 +870,7 @@ function renderRefsDetailBody(model) { if (current) { sidebarLines.push( + sectionHeading(ctx, 'Selected Ref', 'accent'), `ref ${current.ref}`, `namespace ${current.namespace}`, `oid ${current.oid}`, @@ -843,7 +881,7 @@ function renderRefsDetailBody(model) { current.detail, ); if (current.previewSlugs.length > 0) { - sidebarLines.push('', 'preview', ...current.previewSlugs.map((slug) => `- ${slug}`)); + sidebarLines.push('', sectionHeading(ctx, 'Preview', 'info'), ...current.previewSlugs.map((slug) => `- ${slug}`)); } sidebarLines.push('', current.browsable ? 'Press enter to switch source to this ref.' @@ -855,7 +893,7 @@ function renderRefsDetailBody(model) { if (namespaceCounts.size > 0) { sidebarLines.push( '', - 'namespaces', + sectionHeading(ctx, 'Namespaces', 'warning'), ...Array.from(namespaceCounts.entries()) .slice(0, 8) .map(([namespace, count]) => `- ${namespace} (${count})`), @@ -878,7 +916,7 @@ function renderRefsView(model, deps, options) { const listWidth = Math.max(18, options.screen.width - sidebarWidth - 1); const viewHeight = options.height; const listPanel = renderPanel({ - title: 'Refs', + title: 'Ref Index', body: renderRefsListBody(model, deps, { width: Math.max(8, listWidth - 2), height: Math.max(4, viewHeight - 2), @@ -888,8 +926,8 @@ function renderRefsView(model, deps, options) { ctx: deps.ctx, }); const detailPanel = renderPanel({ - title: 'Ref Details', - body: renderRefsDetailBody(model), + title: 'Ref Dispatch', + body: renderRefsDetailBody(model, deps.ctx), width: sidebarWidth, height: viewHeight, ctx: deps.ctx, @@ -954,6 +992,7 @@ function renderTreemapSidebarText(options) { }); return composeTreemapSidebarText({ sections, + ctx: options.deps.ctx, width: options.width, height: options.height, }); @@ -973,7 +1012,7 @@ function renderTreemapView(model, deps, options) { const mapHeight = options.height; const sidebarHeight = options.height; - const mapTitle = `${model.treemapScope === 'repository' ? 'Repository Map' : 'Source Map'} · ${treemapLevelLabel(model)}`; + const mapTitle = `${model.treemapScope === 'repository' ? 'Repository Atlas' : 'Source Atlas'} · ${treemapLevelLabel(model)}`; const mapPanel = renderPanel({ title: mapTitle, body: renderTreemapMapBody(model, deps, { mapWidth, mapHeight }), @@ -982,7 +1021,7 @@ function renderTreemapView(model, deps, options) { ctx: deps.ctx, }); const sidebarPanel = renderPanel({ - title: 'Treemap Details', + title: 'Atlas Briefing', body: renderTreemapSidebarText({ model, deps, @@ -1010,23 +1049,23 @@ function renderTreemapView(model, deps, options) { function renderFooterSurface(model, ctx, width) { const lines = model.activeDrawer === 'treemap' ? [ - '─'.repeat(Math.max(1, width)), - `${kbd('j/k', { ctx })} regions ${kbd('d/u', { ctx })} page ${kbd('+', { ctx })} descend ${kbd('-', { ctx })} ascend`, - `${kbd('T', { ctx })} scope ${kbd('i', { ctx })} files ${kbd('r', { ctx })} refs ${kbd('ctrl+p', { ctx })} palette`, - `${kbd('s', { ctx })} stats ${kbd('g', { ctx })} doctor ${kbd('esc', { ctx })} back ${kbd('q', { ctx })} quit`, + 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' ? [ - '─'.repeat(Math.max(1, width)), - `${kbd('j/k', { ctx })} refs ${kbd('d/u', { ctx })} page ${kbd('enter', { ctx })} switch source`, - `${kbd('t', { ctx })} treemap ${kbd('s', { ctx })} stats ${kbd('g', { ctx })} doctor ${kbd('ctrl+p', { ctx })} palette`, - `${kbd('esc', { ctx })} back ${kbd('q', { ctx })} quit`, + 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`, ] : [ - '─'.repeat(Math.max(1, width)), - `${kbd('j/k', { ctx })} rows ${kbd('d/u', { ctx })} page ${kbd('J/K', { ctx })} scroll ${kbd('enter', { ctx })} inspect`, - `${kbd('tab', { ctx })} pane ${kbd('H/L', { ctx })} resize ${kbd('ctrl+p', { ctx })} palette`, - `${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`, + 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); } diff --git a/bin/ui/encryption-card.js b/bin/ui/encryption-card.js index e5961aa..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, surfaceToString } 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 - ? surfaceToString(badge('unlocked', { variant: 'success', ctx }), ctx.style) - : surfaceToString(badge('locked', { variant: 'error', ctx }), ctx.style); + ? 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 ad6e043..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, surfaceToString } 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,16 +38,19 @@ function formatBytes(bytes) { * @returns {string} */ function renderBadges(m, ctx) { - const renderBadge = (label, options = {}) => surfaceToString(badge(label, { ...options, ctx }), ctx.style); - const badges = [renderBadge(`v${m.version}`)]; + 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(renderBadge('encrypted', { variant: 'warning' })); + badges.push(renderBadge('encrypted', 'warning')); } if (m.compression) { - badges.push(renderBadge(m.compression.algorithm, { variant: 'info' })); + badges.push(renderBadge(m.compression.algorithm, 'info')); } if (m.subManifests?.length) { - badges.push(renderBadge('merkle', { variant: 'info' })); + badges.push(renderBadge('merkle', 'accent')); } return badges.join(' '); } @@ -75,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 })}`; } /** @@ -101,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}`; } /** @@ -113,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 })}`; } /** @@ -133,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 })}`; } /** @@ -146,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 index 864250d..e7d4070 100644 --- a/bin/ui/repo-treemap.js +++ b/bin/ui/repo-treemap.js @@ -9,13 +9,15 @@ * @typedef {import('@flyingrobots/bijou').BijouContext} BijouContext */ +import { GIT_CAS_PALETTE } from './theme.js'; + const TILE_COLOR = { - worktree: [59, 207, 212], - git: [252, 147, 5], - ref: [242, 0, 148], - vault: [166, 227, 1], - cas: [137, 180, 250], - meta: [148, 163, 184], + 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 = { @@ -450,7 +452,7 @@ function renderLegendLines(ctx, width) { .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); + return clip(ctx.style.rgb(color[0], color[1], color[2], `${fill} ${TILE_LABEL[kind]}`), width); }); } 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/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index 659f548..975fa68 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -356,7 +356,7 @@ describe('dashboard palette and overlay commands', () => { 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 Palette'); + expect(rendered).toContain('Command Deck'); expect(rendered).toContain('Open Repo Treemap'); expect(rendered).toContain('Open Source Stats'); }); @@ -754,7 +754,7 @@ describe('dashboard view rendering', () => { expect(rendered).toContain('cwd /tmp/git-cas-fixture'); expect(rendered).toContain('source vault refs/cas/vault'); expect(rendered).toContain('Entries'); - expect(rendered).toContain('Inspector'); + expect(rendered).toContain('Manifest Inspector'); }); it('renders entry list when entries exist', () => { @@ -838,7 +838,7 @@ describe('dashboard inspector rendering', () => { 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('Inspector *'); + expect(rendered).toContain('Manifest Inspector *'); }); }); @@ -852,7 +852,7 @@ describe('dashboard report overlay rendering', () => { statsReport: makeStatsReport(), }); const rendered = renderView(app.view(model), deps.ctx); - expect(rendered).toContain('Source Stats'); + expect(rendered).toContain('Vault Metrics'); expect(rendered).toContain('dedup-ratio'); expect(rendered).not.toContain('\t'); }); @@ -861,7 +861,7 @@ describe('dashboard report overlay rendering', () => { const deps = makeDeps(); const app = createDashboardApp(deps); const rendered = renderView(app.view(makeModel({ activeDrawer: 'doctor', doctorStatus: 'loading' })), deps.ctx); - expect(rendered).toContain('Doctor Report'); + expect(rendered).toContain('Vault Doctor'); expect(rendered).toContain('Loading doctor report'); }); }); @@ -869,9 +869,9 @@ describe('dashboard report overlay rendering', () => { describe('dashboard treemap rendering', () => { it('renders the treemap as a full-screen view with a details sidebar', () => { const { rendered } = renderDashboardWithModel(makeFullScreenTreemapModel()); - expect(rendered).toContain('treemap view'); - expect(rendered).toContain('Repository Map'); - expect(rendered).toContain('Treemap Details'); + 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'); @@ -925,9 +925,9 @@ describe('dashboard refs rendering', () => { columns: 120, rows: 36, }); - expect(rendered).toContain('refs view'); - expect(rendered).toContain('Refs'); - expect(rendered).toContain('Ref Details'); + 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'); }); @@ -940,7 +940,7 @@ describe('dashboard palette rendering', () => { 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 Palette'); + expect(rendered).toContain('Command Deck'); expect(rendered).toContain('Open Repo Treemap'); expect(rendered).toContain('Open Source Stats'); }); diff --git a/test/unit/cli/manifest-view.test.js b/test/unit/cli/manifest-view.test.js index c9fea91..31d6160 100644 --- a/test/unit/cli/manifest-view.test.js +++ b/test/unit/cli/manifest-view.test.js @@ -33,7 +33,7 @@ describe('renderManifestView', () => { it('renders chunk table', () => { const output = renderManifestView({ manifest: makeManifest() }); - expect(output).toContain('Chunks (2)'); + expect(output).toContain('Chunk Ledger (2)'); expect(output).toContain('aaaaaaaaaaaa...'); }); @@ -54,13 +54,13 @@ describe('renderManifestView', () => { 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)'); }); }); From afe2258353cb6d88177df112d91ea5fe64f06276 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 19 Mar 2026 19:58:33 -0700 Subject: [PATCH 19/22] feat(ui): strengthen dashboard toasts --- bin/ui/dashboard-view.js | 140 ++++++++++++++++++++++++++++---- bin/ui/dashboard.js | 91 ++++++++++++++++++++- test/unit/cli/dashboard.test.js | 57 +++++++++---- 3 files changed, 251 insertions(+), 37 deletions(-) diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index 51c3bdf..40d8837 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -360,7 +360,74 @@ function composeTreemapSidebarText(options) { } /** - * Wrap toast copy with simple fixed-width chunks. + * 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; +} + +/** + * Wrap one paragraph to a width using whitespace boundaries when available. + * + * @param {string} text + * @param {number} width + * @returns {string[]} + */ +function wrapToastParagraph(text, width) { + const lines = []; + 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 lines; +} + +/** + * 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 @@ -368,10 +435,9 @@ function composeTreemapSidebarText(options) { * @returns {string[]} */ function wrapToastText(text, width, maxLines) { - const chunkPattern = new RegExp(`.{1,${Math.max(1, width)}}`, 'g'); const lines = text .split('\n') - .flatMap((part) => part.length === 0 ? [''] : (part.match(chunkPattern) ?? [''])); + .flatMap((part) => wrapToastParagraph(part, Math.max(1, width))); return limitWrappedLines(lines, width, maxLines); } @@ -398,25 +464,58 @@ function styleToastLine(options) { /** * Render one toast box surface. * - * @param {{ id: number, level: 'error' | 'warning' | 'info' | 'success', title: string, message: string }} toast + * @param {{ id: number, level: 'error' | 'warning' | 'info' | 'success', title: string, message: string, 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 width = Math.max(28, Math.min(46, opts.width)); + const width = measureToastWidth(toast, Math.max(32, Math.min(48, opts.width))); const innerWidth = Math.max(1, width - 2); - const bodyLines = wrapToastText(toast.message, innerWidth, 3).map((line) => styleToastLine({ + const bodyWidth = Math.max(1, innerWidth - 3); + const bodyLines = wrapToastText(toast.message, bodyWidth, 3).map((line) => styleToastLine({ text: line, theme, ctx: opts.ctx, - width: innerWidth, + width: bodyWidth, })); - return boxV3(textSurface(bodyLines.join('\n'), innerWidth, bodyLines.length), { - ctx: opts.ctx, - title: `${theme.label}: ${toast.title}`, - width, + 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], '╝'))}`; + return textSurface([topLine, titleLine, dividerLine, ...contentLines, bottomLine].join('\n'), width, contentLines.length + 4); +} + +/** + * Render a soft drop shadow behind a toast. + * + * @param {number} width + * @param {number} height + * @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)); + return Math.round((1 - progress) * 16); } /** @@ -531,18 +630,25 @@ function renderDrawerSurface(model, opts) { * @param {{ top: number, height: number, screen: Surface }} options */ function renderToastStack(model, deps, options) { - let cursorY = options.top + options.height; + const marginTop = 1; + const marginRight = 4; + let cursorY = options.top + marginTop; for (const toast of model.toasts) { const surface = renderToastSurface(toast, { - width: Math.min(54, Math.max(34, Math.floor(options.screen.width * 0.55))), + width: Math.min(52, Math.max(40, Math.floor(options.screen.width * 0.44))), ctx: deps.ctx, }); - cursorY -= surface.height; - if (cursorY < options.top) { + if (cursorY + surface.height > options.top + options.height) { break; } - options.screen.blit(surface, Math.max(0, options.screen.width - surface.width), cursorY); - cursorY -= 1; + 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; } } diff --git a/bin/ui/dashboard.js b/bin/ui/dashboard.js index 020bd95..383da10 100644 --- a/bin/ui/dashboard.js +++ b/bin/ui/dashboard.js @@ -7,6 +7,7 @@ import { 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'; @@ -31,7 +32,8 @@ import { renderDashboard } from './dashboard-view.js'; * @typedef {import('./dashboard-cmds.js').RefInventoryItem} RefInventoryItem * @typedef {{ slug: string, treeOid: string }} VaultEntry * @typedef {'error' | 'warning' | 'info' | 'success'} ToastLevel - * @typedef {{ id: number, level: ToastLevel, title: string, message: string }} ToastRecord + * @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 */ @@ -75,6 +77,8 @@ import { renderDashboard } from './dashboard-view.js'; * | { 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 @@ -183,6 +187,8 @@ 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 = [ { @@ -517,12 +523,15 @@ function setPalette(model, palette) { */ function addToast(model, toastSpec) { const id = model.nextToastId; - const toast = { id, ...toastSpec }; + const toast = { id, ...toastSpec, phase: 'entering', progress: 0 }; return [{ ...model, nextToastId: id + 1, toasts: [toast, ...model.toasts].slice(0, TOAST_LIMIT), - }, [/** @type {DashCmd} */ (tick(TOAST_TTL_MS, { type: 'dismiss-toast', id }))]]; + }, [ + animateToast(id, 0, 1), + /** @type {DashCmd} */ (tick(TOAST_TTL_MS, { type: 'toast-expire', id })), + ]]; } /** @@ -539,6 +548,70 @@ function dismissToast(model, id) { }, []]; } +/** + * 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 {DashModel} model + * @param {number} id + * @param {(toast: ToastRecord) => ToastRecord} updater + * @returns {DashModel} + */ +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; +} + +/** + * Begin toast exit animation when a toast is dismissed or expires. + * + * @param {DashModel} model + * @param {number} id + * @returns {[DashModel, DashCmd[]]} + */ +function startToastExit(model, id) { + const toast = model.toasts.find((entry) => entry.id === id); + if (!toast) { + return [model, []]; + } + if (toast.phase === 'exiting') { + return [model, []]; + } + 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. * @@ -1313,7 +1386,7 @@ function closeOverlay(model) { return [{ ...model, activeDrawer: null }, []]; } if (model.toasts.length > 0) { - return dismissToast(model, model.toasts[0].id); + return startToastExit(model, model.toasts[0].id); } return [model, []]; } @@ -1812,6 +1885,16 @@ function handleLoadError(msg, model) { 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); diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index 975fa68..cfac6f5 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -248,6 +248,18 @@ function makeTreemapReport(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); @@ -395,17 +407,27 @@ describe('dashboard drawer shortcuts', () => { }); describe('dashboard toast dismissal', () => { - it('escape dismisses the latest toast when no overlay is open', () => { + it('escape starts the latest toast exit animation when no overlay is open', () => { const app = createDashboardApp(makeDeps()); - const [next] = app.update(keyMsg('escape'), makeModel({ + const [next, cmds] = app.update(keyMsg('escape'), makeModel({ toasts: [ - { id: 2, level: 'warning', title: 'Heads up', message: 'yellow alert' }, - { id: 1, level: 'error', title: 'Failed to load repo treemap', message: 'boom' }, + 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).toEqual([ - { 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); }); }); @@ -638,13 +660,13 @@ describe('dashboard treemap report and toast messages', () => { const app = createDashboardApp(makeDeps()); const model = makeModel({ toasts: [ - { id: 1, level: 'error', title: 'Failed to load entries', message: 'boom' }, - { id: 2, level: 'warning', title: 'Heads up', message: 'careful' }, + 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([ - { id: 2, level: 'warning', title: 'Heads up', message: 'careful' }, + makeToast({ id: 2, level: 'warning', title: 'Heads up', message: 'careful' }), ]); }); }); @@ -694,7 +716,8 @@ describe('dashboard filter edge cases', () => { expect(next.status).toBe('error'); expect(next.toasts).toHaveLength(1); expect(next.toasts[0].title).toBe('Failed to load entries'); - expect(cmds).toHaveLength(1); + expect(next.toasts[0]).toMatchObject({ phase: 'entering', progress: 0 }); + expect(cmds).toHaveLength(2); }); }); @@ -707,7 +730,8 @@ describe('dashboard loading edge cases', () => { expect(next.error).toBeNull(); expect(next.toasts).toHaveLength(1); expect(next.toasts[0].title).toBe('Failed to load alpha'); - expect(cmds).toHaveLength(1); + expect(next.toasts[0]).toMatchObject({ phase: 'entering', progress: 0 }); + expect(cmds).toHaveLength(2); }); it('loaded-entries clamps table focus to filtered bounds', () => { @@ -950,12 +974,13 @@ describe('dashboard palette rendering', () => { const app = createDashboardApp(deps); const rendered = renderView(app.view(makeModel({ toasts: [ - { id: 2, level: 'warning', title: 'Heads up', message: 'yellow alert' }, - { id: 1, level: 'error', title: 'Failed to load repo treemap', message: 'boom' }, + 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('ERROR // Failed to load repo treemap'); + expect(rendered).toContain('WARNING // Heads up'); + expect(rendered).toContain('yellow alert with more words'); }); }); From 5f16a97c672cd155521eb01995b006966da72ada Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 19 Mar 2026 20:06:40 -0700 Subject: [PATCH 20/22] feat(ui): animate dashboard toast motion --- bin/ui/dashboard-view.js | 72 ++++++++++++++++++++++++++++++--- test/unit/cli/dashboard.test.js | 15 ++++++- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index 40d8837..7bc8124 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -461,19 +461,72 @@ function styleToastLine(options) { ); } +/** + * 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, progress?: number }} toast + * @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 width = measureToastWidth(toast, Math.max(32, Math.min(48, opts.width))); + 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 bodyLines = wrapToastText(toast.message, bodyWidth, 3).map((line) => styleToastLine({ + const bodyLineBudget = toastBodyLineBudget(toast); + const bodyLines = wrapToastText(toast.message, bodyWidth, bodyLineBudget).map((line) => styleToastLine({ text: line, theme, ctx: opts.ctx, @@ -491,7 +544,10 @@ function renderToastSurface(toast, opts) { 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], '╝'))}`; - return textSurface([topLine, titleLine, dividerLine, ...contentLines, bottomLine].join('\n'), width, contentLines.length + 4); + const lines = contentLines.length > 0 + ? [topLine, titleLine, dividerLine, ...contentLines, bottomLine] + : [topLine, titleLine, bottomLine]; + return textSurface(lines.join('\n'), width, lines.length); } /** @@ -515,7 +571,13 @@ function renderToastShadow(width, height, ctx) { */ function toastSlideOffset(toast) { const progress = Math.max(0, Math.min(1, toast.progress ?? 1)); - return Math.round((1 - progress) * 16); + if (toast.phase === 'entering') { + return Math.round((1 - easeOutBack(progress)) * 18); + } + if (toast.phase === 'exiting') { + return Math.round((1 - progress) * 24); + } + return 0; } /** diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index cfac6f5..a4a54d3 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -624,7 +624,7 @@ describe('dashboard report loading', () => { }); }); -describe('dashboard treemap report and toast messages', () => { +describe('dashboard treemap reports', () => { it('loaded-treemap stores the report for the active scope', () => { const app = createDashboardApp(makeDeps()); const report = makeTreemapReport({ @@ -655,7 +655,9 @@ describe('dashboard treemap report and toast messages', () => { 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({ @@ -669,6 +671,17 @@ describe('dashboard treemap report and toast messages', () => { 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', () => { From 0a0143955f5fa773a2fe6ea1f6628cdc959e4fc0 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 19 Mar 2026 20:40:26 -0700 Subject: [PATCH 21/22] fix(ui): guard collapsing toast exit frames --- bin/ui/dashboard-view.js | 3 +++ test/unit/cli/dashboard.test.js | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index 7bc8124..1d0e942 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -266,6 +266,9 @@ function padToWidth(text, width) { * @returns {string[]} */ function limitWrappedLines(lines, width, maxLines) { + if (maxLines <= 0) { + return []; + } if (lines.length <= maxLines) { return lines; } diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index a4a54d3..b0da290 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -996,4 +996,23 @@ describe('dashboard palette rendering', () => { 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'); + }); }); From 20305f0a2a7939df01914c70467808d87ca3ff73 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 19 Mar 2026 20:59:33 -0700 Subject: [PATCH 22/22] fix(ui): wrap refs browser content --- bin/ui/dashboard-view.js | 324 +++++++++++++++++++++----------- test/unit/cli/dashboard.test.js | 27 +++ 2 files changed, 239 insertions(+), 112 deletions(-) diff --git a/bin/ui/dashboard-view.js b/bin/ui/dashboard-view.js index 1d0e942..fbe8012 100644 --- a/bin/ui/dashboard-view.js +++ b/bin/ui/dashboard-view.js @@ -385,7 +385,7 @@ function lastWhitespaceBoundary(text, index) { * @param {number} width * @returns {string[]} */ -function wrapToastParagraph(text, width) { +function wrapWhitespaceParagraph(text, width) { const lines = []; let remaining = text.trimEnd(); if (remaining.length === 0) { @@ -412,6 +412,22 @@ function wrapToastParagraph(text, width) { return lines; } +/** + * Wrap plain text on whitespace with hard-break fallback. + * + * @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. * @@ -438,9 +454,7 @@ function measureToastWidth(toast, maxWidth) { * @returns {string[]} */ function wrapToastText(text, width, maxLines) { - const lines = text - .split('\n') - .flatMap((part) => wrapToastParagraph(part, Math.max(1, width))); + const lines = wrapWhitespaceText(text, width); return limitWrappedLines(lines, width, maxLines); } @@ -920,82 +934,107 @@ function renderDetailPane(model, opts) { } /** - * Choose a responsive table schema for the refs browser. + * Return the selected ref item from the refs browser. * - * @param {number} width - * @returns {{ columns: { header: string, width: number, align?: 'left' | 'right' | 'center' }[], indexes: number[] }} + * @param {DashModel} model + * @returns {import('./dashboard-cmds.js').RefInventoryItem | undefined} */ -function refTableSchema(width) { - if (width >= 80) { - return { - columns: [ - { header: 'Namespace', width: 14 }, - { header: 'Ref', width: Math.max(16, width - 47) }, - { header: 'Kind', width: 10 }, - { header: 'Entries', width: 7, align: 'right' }, - { header: 'OID', width: 12 }, - ], - indexes: [0, 1, 2, 3, 4], - }; - } - if (width >= 58) { - return { - columns: [ - { header: 'Ref', width: Math.max(18, width - 20) }, - { header: 'Kind', width: 10 }, - { header: 'Entries', width: 7, align: 'right' }, - ], - indexes: [1, 2, 3], - }; +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}`); } - return { - columns: [ - { header: 'Ref', width: Math.max(16, width - 12) }, - { header: 'CAS', width: 10 }, - ], - indexes: [1, 2], - }; } /** - * Clamp the refs table to the current pane size. + * 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 {{ width: number, height: number }} size - * @returns {import('@flyingrobots/bijou-tui').NavigableTableState} + * @param {number} width + * @returns {string} */ -function refsTableViewState(model, size) { - const schema = refTableSchema(size.width); - const rows = model.refsTable.rows.map((row) => schema.indexes.map((index) => row[index] ?? '')); - const focusRow = Math.max(0, Math.min(model.refsTable.focusRow, rows.length - 1)); - let scrollY = model.refsTable.scrollY; - if (focusRow < scrollY) { - scrollY = focusRow; - } else if (focusRow >= scrollY + size.height) { - scrollY = focusRow - size.height + 1; +function renderRefsListStatusBody(model, width) { + if (model.refsStatus === 'loading') { + return wrapWhitespaceText('Loading refs...', width).join('\n'); } - return { - ...model.refsTable, - columns: schema.columns, - rows, - height: size.height, - focusRow, - scrollY: Math.min(scrollY, Math.max(0, rows.length - size.height)), - }; + 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'); } /** - * Return the selected ref item from the refs browser. + * Build a visible refs-list viewport. * - * @param {DashModel} model - * @returns {import('./dashboard-cmds.js').RefInventoryItem | undefined} + * @param {{ items: import('./dashboard-cmds.js').RefInventoryItem[], focusRow: number, startIndex: number, width: number, height: number, ctx: BijouContext }} options + * @returns {{ lines: string[], visibleFocus: boolean }} */ -function selectedRef(model) { - return model.refsItems[model.refsTable.focusRow]; +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 table body. + * Render the refs-browser list body with whitespace-aware wrapping. * * @param {DashModel} model * @param {DashDeps} deps @@ -1003,19 +1042,110 @@ function selectedRef(model) { * @returns {string} */ function renderRefsListBody(model, deps, size) { - if (model.refsStatus === 'loading') { - return 'Loading refs...'; - } - if (model.refsStatus === 'error') { - return `Failed to load refs\n\n${model.refsError ?? 'unknown error'}`; + if (model.refsStatus !== 'ready') { + return renderRefsListStatusBody(model, size.width); } - if (model.refsItems.length === 0) { - return 'No refs found.'; - } - return navigableTable(refsTableViewState(model, size), { + + 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, - focusIndicator: '▸', }); + 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: '- ' }); + } } /** @@ -1023,54 +1153,24 @@ function renderRefsListBody(model, deps, size) { * * @param {DashModel} model * @param {BijouContext} ctx + * @param {number} width * @returns {string} */ -function renderRefsDetailBody(model, ctx) { +function renderRefsDetailBody(model, ctx, width) { const current = selectedRef(model); - const namespaceCounts = new Map(); - for (const ref of model.refsItems) { - namespaceCounts.set(ref.namespace, (namespaceCounts.get(ref.namespace) ?? 0) + 1); - } + const namespaceCounts = refNamespaceCounts(model.refsItems); - const sidebarLines = [ - sectionHeading(ctx, 'Inventory', 'brand'), - `refs ${model.refsItems.length} under ${namespaceCounts.size} namespaces`, - `current ${sourceLabel(model.source)}`, - '', - ]; + const sidebarLines = []; + appendRefsInventory({ lines: sidebarLines, model, ctx, width, namespaceCounts }); if (current) { - sidebarLines.push( - sectionHeading(ctx, 'Selected Ref', 'accent'), - `ref ${current.ref}`, - `namespace ${current.namespace}`, - `oid ${current.oid}`, - `status ${current.browsable ? 'browsable' : 'opaque'}`, - `kind ${current.resolution}`, - `entries ${current.entryCount}`, - '', - current.detail, - ); - if (current.previewSlugs.length > 0) { - sidebarLines.push('', sectionHeading(ctx, 'Preview', 'info'), ...current.previewSlugs.map((slug) => `- ${slug}`)); - } - sidebarLines.push('', current.browsable - ? 'Press enter to switch source to this ref.' - : 'This ref does not currently resolve to CAS entries.'); + appendSelectedRefDetails({ lines: sidebarLines, current, ctx, width }); } else if (model.refsStatus === 'ready') { - sidebarLines.push('Select a ref to inspect it.'); - } - - if (namespaceCounts.size > 0) { - sidebarLines.push( - '', - sectionHeading(ctx, 'Namespaces', 'warning'), - ...Array.from(namespaceCounts.entries()) - .slice(0, 8) - .map(([namespace, count]) => `- ${namespace} (${count})`), - ); + sidebarLines.push(''); + pushWrappedText(sidebarLines, 'Select a ref to inspect it.', { width }); } + appendNamespaceSummary({ lines: sidebarLines, namespaceCounts, ctx, width }); return sidebarLines.join('\n'); } @@ -1098,7 +1198,7 @@ function renderRefsView(model, deps, options) { }); const detailPanel = renderPanel({ title: 'Ref Dispatch', - body: renderRefsDetailBody(model, deps.ctx), + body: renderRefsDetailBody(model, deps.ctx, Math.max(8, sidebarWidth - 2)), width: sidebarWidth, height: viewHeight, ctx: deps.ctx, diff --git a/test/unit/cli/dashboard.test.js b/test/unit/cli/dashboard.test.js index b0da290..59f8c45 100644 --- a/test/unit/cli/dashboard.test.js +++ b/test/unit/cli/dashboard.test.js @@ -968,6 +968,33 @@ describe('dashboard refs rendering', () => { 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', () => {