From 190730d65ccf200af6eee07178bf67eb43210eca Mon Sep 17 00:00:00 2001 From: Alex Gavrilescu Date: Fri, 9 Oct 2026 10:56:55 +0200 Subject: [PATCH 1/6] TASK-563 - Play the architecture's history between two commits on the web map --- ...tory-between-two-commits-on-the-web-map.md | 38 ++++- docs/viewers/web/history-playback.md | 42 +++++ .../containers/cli/components/web-server.md | 5 +- .../export/components/revision-control.md | 7 +- src/viewers/web/data.ts | 5 + src/viewers/web/map-session.ts | 15 +- src/viewers/web/render.ts | 4 +- src/viewers/web/revision/control.ts | 33 +++- src/viewers/web/revision/history.ts | 75 +++++++++ src/viewers/web/revision/playback.ts | 154 ++++++++++++++++++ src/viewers/web/revision/view.ts | 9 + 11 files changed, 372 insertions(+), 15 deletions(-) create mode 100644 docs/viewers/web/history-playback.md create mode 100644 src/viewers/web/revision/history.ts create mode 100644 src/viewers/web/revision/playback.ts diff --git a/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md b/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md index 3860e2f8..0198b764 100644 --- a/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md +++ b/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md @@ -1,10 +1,11 @@ --- id: TASK-563 title: Play the architecture's history between two commits on the web map -status: To Do -assignee: [] +status: Done +assignee: + - '@codex' created_date: '2026-10-07 23:16' -updated_date: '2026-10-08 15:31' +updated_date: '2026-10-09 08:01' labels: - senior dependencies: [] @@ -18,6 +19,19 @@ references: - src-core - web-server - web-page +modified_files: + - src/viewers/web/revision/history.ts + - src/viewers/web/map-session.ts + - src/viewers/web/data.ts + - src/viewers/web/revision/playback.ts + - src/viewers/web/revision/control.ts + - src/viewers/web/render.ts + - src/viewers/web/revision/view.ts + - docs/viewers/web/history-playback.md + - groma/systems/groma-md/containers/cli/components/history.md + - groma/systems/groma-md/containers/export/components/playback.md + - groma/systems/groma-md/containers/cli/components/web-server.md + - groma/systems/groma-md/containers/export/components/revision-control.md type: feature ordinal: 3 --- @@ -44,6 +58,20 @@ Build on revisions and snapshots (src/history/revisions.ts, src/history/snapshot - [ ] #1 Acceptance criteria have objective verification evidence. - [ ] #2 Relevant checks pass and changes remain task-scoped. -- [ ] #3 Public contracts or documentation are updated when behavior changes. -- [ ] #4 Implementation Plan reflects the final approach; correction history and verification are recorded in Implementation Notes. +- [x] #3 Public contracts or documentation are updated when behavior changes. +- [x] #4 Implementation Plan reflects the final approach; correction history and verification are recorded in Implementation Notes. + +## Implementation Plan + + +1. Add a live-only playback controller to the existing two-commit revision selection, with Play/Stop, speed, commit metadata and persistent range URLs. 2. Cache historical snapshots and owned source text in the web session, select compatible commits in existing Git order, and preload upcoming step comparisons. 3. Reuse comparison layout, keyed map morphing and reduced-motion handling for each step; finish with the selected full-range comparison. 4. Update the web guide, inspect the supported flow and task-scoped diff without running builds or tests, then scan and curate new files under their existing responsibilities. No new tests: the user explicitly prohibits running tests; record verification limits rather than claiming performance evidence. + + +## Implementation Notes + + +Shared-file coordination: TASK-562 also records data.ts and map-session.ts; TASK-565 records map-session.ts, render.ts and the web index; TASK-568 records map-session.ts. Playback uses separate imports, endpoint and URL hunks and preserves their work. Playback documentation is a separate page to avoid overlapping edits to the web index. + +Implemented Play/Stop and 0.5×/1×/2×/4× speed in the live revision control; the selected range remains in the URL and header while a caption identifies each frame. The web session caches architecture and source snapshots; playback filters unreadable revisions in Git list order and preloads the next two comparisons. Completion restores the full-range comparison. Existing comparison layout, keyed map morphing and reduced-motion behavior are reused. Static export and terminal code were not changed. Specification and quality review traced revision selection -> compatible range -> cached snapshots -> prefetched comparison frames -> map application -> final comparison. Type checking (tsc --noEmit), focused Biome lint and git diff --check pass; the one reported complexity warning is in the existing paintDetailsState function, outside this change. No builds or tests were run, as instructed. No browser automation tool is available in this session, so interactive behavior and fifty-commit smoothness remain unverified; acceptance criteria are not checked without that evidence. Architecture scan completed, and history.ts/playback.ts were combined into Web host/Revision selector with their responsibilities documented. + diff --git a/docs/viewers/web/history-playback.md b/docs/viewers/web/history-playback.md new file mode 100644 index 00000000..559a10bc --- /dev/null +++ b/docs/viewers/web/history-playback.md @@ -0,0 +1,42 @@ +# Play architecture history + +In the live web map, choose a commit, open **compare 2 revisions**, and choose +an older start commit. **Play** walks the range from oldest to newest. The speed +selector offers 0.5×, 1×, 2×, and 4×; **Stop** leaves the current step visible. +The caption shows that step's short commit ID, subject, and local date and time. +The two revision fields keep the chosen range. Its `from` and `revision` URL +parameters reopen the range without starting playback automatically. + +Only commits readable by the current architecture reader are visited, in the +same Git order as the revision picker. Both endpoints must be readable. The +working tree is not a playback endpoint. Playback is available only in the live +web map; static exports and the terminal viewer keep their existing behavior. + +The first frame shows the start snapshot. Each later frame uses the comparison +from the previous readable commit to the next, including retained removed +elements and their parent context. The map's existing keyed geometry transition +moves shared buildings and routes and grows new ones. Reduced motion applies +each frame immediately. When playback finishes, the original start-to-end +comparison returns with its changes bar and stepper. A person can pan and zoom +while playback continues. + +[`revision/control.ts`](../../../src/viewers/web/revision/control.ts) owns the +selected revision and connects the playback controls to the browser session. +[`revision/playback.ts`](../../../src/viewers/web/revision/playback.ts) owns the +clock, cancellation, speed, caption, range, and a cache of requested frames. It +loads the next two frames ahead. Stopping invalidates pending frame applications +without discarding their cached data. + +The live [`map-session.ts`](../../../src/viewers/web/map-session.ts) serves +`/playback.json?from=&revision=` to identify readable frames. +[`revision/history.ts`](../../../src/viewers/web/revision/history.ts) shares +in-flight historical reads for that server session: each commit's architecture, +layout, and owned source text are loaded once. Comparisons reuse those snapshots; +source files owned only on the other side are read and cached when needed. +Compatibility checks populate the same cache used by playback. Working-tree +comparisons continue to read current files. + +Playback is transient browser state, not stored architecture knowledge. It adds +no OKF metadata or C4 element type. Git commits and the existing architecture +documents remain the portable history; the browser revision selector and web +host own its playback interpretation. diff --git a/groma/systems/groma-md/containers/cli/components/web-server.md b/groma/systems/groma-md/containers/cli/components/web-server.md index 3a5300f9..a764f53b 100644 --- a/groma/systems/groma-md/containers/cli/components/web-server.md +++ b/groma/systems/groma-md/containers/cli/components/web-server.md @@ -17,11 +17,14 @@ groma: symbol: renderSetupPage - scanner: typescript file: src/viewers/web/startup/scanners.ts + - scanner: typescript + file: src/viewers/web/revision/history.ts + symbol: createRevisionHistory group: Browser delivery description: Serves the local browser map and live architecture operations --- -Starts the local HTTP server. Serves project setup and connects the browser to architecture, scanner, task, and source operations. +Starts the local HTTP server. Serves project setup and connects the browser to architecture, scanner, task, and source operations. Keeps historical architecture snapshots and source text in a session cache, identifies readable frames for playback, and reuses snapshots for commit comparisons. ## Relationships diff --git a/groma/systems/groma-md/containers/export/components/revision-control.md b/groma/systems/groma-md/containers/export/components/revision-control.md index 38de946c..096be492 100644 --- a/groma/systems/groma-md/containers/export/components/revision-control.md +++ b/groma/systems/groma-md/containers/export/components/revision-control.md @@ -11,11 +11,14 @@ groma: symbol: createRevisionControl - scanner: typescript file: src/viewers/web/revision/view.ts + - scanner: typescript + file: src/viewers/web/revision/playback.ts + symbol: createRevisionPlayback group: Architecture panels -description: Lists Git architecture revisions and opens a snapshot +description: Browses, compares and plays readable Git architecture revisions --- -Lists available Git revisions and opens the selected architecture snapshot. +Owns the selected revision or comparison and the commit search fields. Plays a selected commit range in Git order with speed and Stop controls, shows the current commit, preloads upcoming frames, and finishes on the full-range comparison. The browser keeps the selected range in its URL and uses the existing map motion for each step. ## Relationships diff --git a/src/viewers/web/data.ts b/src/viewers/web/data.ts index 560dc48d..e3f0f7e3 100644 --- a/src/viewers/web/data.ts +++ b/src/viewers/web/data.ts @@ -13,6 +13,8 @@ export interface WebDataSource { onScanners?: (state: ScannerSettings) => void readWorld(revision?: string, from?: string): Promise readRevisions(): Promise + /** Live-only compatible commits, ordered from the selected start to end. */ + readPlaybackRevisions?(revision: string, from: string): Promise readCode(element: string, revision?: string, from?: string): Promise readSource(element: string, file: string, revision?: string, from?: string): Promise readTask(id: string): Promise @@ -68,6 +70,9 @@ function liveDataSource(): WebDataSource { readRevisions() { return responseJson('/revisions.json') }, + readPlaybackRevisions(revision, from) { + return responseJson(selected('/playback.json', { revision, from })) + }, readWorld(revision, from) { return responseJson(selected('/world.json', { revision, from })) }, diff --git a/src/viewers/web/map-session.ts b/src/viewers/web/map-session.ts index 044f96ab..e60074bb 100644 --- a/src/viewers/web/map-session.ts +++ b/src/viewers/web/map-session.ts @@ -15,6 +15,7 @@ import { readComparison } from '../../history/snapshots.ts' import { measuredSheetScene } from '../../sheet/scene.ts' import { listGitRevisions, withGitRevision } from '../../history/revisions.ts' import { renderPage } from './page.ts' +import { createRevisionHistory } from './revision/history.ts' import type { WebMapPayload, WebPayload, WebRevision, WebWorkPayload } from './payload.ts' import { bundleRenderer, loadMapRoot } from './runtime.ts' import { coverThemes, generateCovers, type CoverImages } from './sharing/images.ts' @@ -75,6 +76,7 @@ export async function createWebMapSession( ): Promise<{ fetch: (request: Request) => Promise; close: () => Promise }> { options.onProgress?.({ phase: 'preparing-viewer' }) const renderer = await bundleRenderer() + const history = createRevisionHistory(repositoryRoot) const workSource = options.workSource ?? backlogPlugin.create(repositoryRoot) let revisions: WebRevision[] = [] let revisionRead: Promise | undefined @@ -120,7 +122,8 @@ export async function createWebMapSession( const revision = (await readRevisions()).find(candidate => candidate.id === revisionId) if (revision === undefined) return new Response('Unknown Groma revision', { status: 404 }) try { - const snapshot = await loadMap(repositoryRoot, revisions, revision) + const { map: stored } = await history.snapshot(revision) + const snapshot = { ...stored, revision, revisions } if (snapshot.project === null) throw new Error('No Groma architecture in this commit') return { generation: map.generation, @@ -157,7 +160,9 @@ export async function createWebMapSession( /** Both revisions' architecture and the changes between them, without laying out the map. */ async function compare(from: WebRevision | null, to: WebRevision | null) { - const compared = await readComparison(repositoryRoot, from, to) + const compared = from !== null && to !== null + ? await history.compare(from, to) + : await readComparison(repositoryRoot, from, to) for (const component of Object.values(compared.comparison.components)) { for (const { file } of component.files) sourceFiles.add(file) } @@ -397,6 +402,12 @@ export async function createWebMapSession( }], ['/render.js', rendererResponse], ['/revisions.json', async () => Response.json(await readRevisions())], + ['/playback.json', async (_request, url) => { + try { + return Response.json(await history.range(await readRevisions(), + url.searchParams.get('from') ?? '', url.searchParams.get('revision') ?? '')) + } catch (error) { return comparisonError(error) } + }], ['/world.json', worldResponse], ['/code.json', selectedSourceResponse], ['/source.json', selectedSourceResponse], diff --git a/src/viewers/web/render.ts b/src/viewers/web/render.ts index 79849cff..ede30067 100644 --- a/src/viewers/web/render.ts +++ b/src/viewers/web/render.ts @@ -145,8 +145,8 @@ function zoomStep(factor: number, control: HTMLElement): void { function syncUrl(): void { const query = writeView({ - ...(revisionControl.selected === undefined ? {} : { revision: revisionControl.selected }), - ...(revisionControl.from === undefined ? {} : { from: revisionControl.from }), + ...(revisionControl.urlRevision === undefined ? {} : { revision: revisionControl.urlRevision }), + ...(revisionControl.urlFrom === undefined ? {} : { from: revisionControl.urlFrom }), ...(source.file === undefined ? {} : { file: source.file }), ...(source.line === undefined ? {} : { line: source.line }), selection, flows: activeFlows, diff --git a/src/viewers/web/revision/control.ts b/src/viewers/web/revision/control.ts index a6847722..6206c380 100644 --- a/src/viewers/web/revision/control.ts +++ b/src/viewers/web/revision/control.ts @@ -2,6 +2,7 @@ import type { WebDataSource } from '../data.ts' import { bindPopover } from '../atoms/popover.ts' import type { WebBootPayload, WebPayload, WebWorkPayload } from '../payload.ts' import { NARROW_HEADER, pendingPairFields, revisionFields, revisionOptions, snapshotNotice, type RevisionField } from './view.ts' +import { createRevisionPlayback } from './playback.ts' interface RevisionControlOptions { box: HTMLElement @@ -100,6 +101,16 @@ export function createRevisionControl(options: RevisionControlOptions) { let navigating = false let pendingWorld: WebPayload | undefined let appliedWork = boot.workGeneration + const playback = createRevisionPlayback({ + root, data, current: () => current, repaint: paint, + present(payload, first) { + editing = undefined + setRevision(payload) + if (first) applyRevision(payload) + else applyWorld(payload) + }, + error: showError, + }) const selected = () => current.revision?.id const from = () => current.comparison === undefined ? undefined : current.comparison.from?.id ?? '' @@ -151,17 +162,23 @@ export function createRevisionControl(options: RevisionControlOptions) { if (editing === undefined || singleSnapshot) delete box.dataset.editing else box.dataset.editing = editing // Live refreshes repaint often; untouched fields keep their focus and do not replay their entrance. - const nextFields = starting() ? pendingPairFields(current.revision) : revisionFields(current) + const range = playback.range + const displayed = range === undefined ? current : { + ...current, revision: range.to, + comparison: { components: {}, relationships: {}, ...current.comparison, from: range.from }, + } + const nextFields = starting() ? pendingPairFields(current.revision) : revisionFields(displayed) if (nextFields !== paintedFields) { fields.innerHTML = nextFields; paintedFields = nextFields } search.hidden = !opened || singleSnapshot search.placeholder = placeholders[editing ?? 'revision'] search.setAttribute('aria-label', searchLabels[editing ?? 'revision']) paintCompareEntry() - end.hidden = current.comparison === undefined && !starting() + end.hidden = current.comparison === undefined && !starting() && range === undefined end.title = starting() ? 'Cancel comparison' : 'End comparison' end.setAttribute('aria-label', end.title) menu.hidden = !opened placeMenu() + playback.paint() } function close(): void { @@ -219,6 +236,7 @@ export function createRevisionControl(options: RevisionControlOptions) { } function open(field: RevisionField): void { + playback.cancel() // Widths are read from the closed layout, also when another field is the search right now. close() const width = (name: RevisionField) => fields.querySelector(`[data-field="${name}"]`)?.getBoundingClientRect().width @@ -233,6 +251,7 @@ export function createRevisionControl(options: RevisionControlOptions) { function setRevision(payload: WebPayload): void { current = payload + playback.remember(payload) body.toggleAttribute('data-revision', !live()) body.toggleAttribute('data-comparison', payload.comparison !== undefined) paint() @@ -250,6 +269,7 @@ export function createRevisionControl(options: RevisionControlOptions) { } async function load(revision?: string, starting?: string, reset = true): Promise { + playback.cancel() const loading = ++request navigating = true error.hidden = true @@ -312,7 +332,12 @@ export function createRevisionControl(options: RevisionControlOptions) { const option = event.target.closest('.revision-option') if (option !== null && !option.disabled) chooseRevision(option.dataset.revision!) }) - end.addEventListener('click', () => { if (starting()) close(); else void load(selected()) }) + end.addEventListener('click', () => { + const destination = playback.range?.to.id ?? selected() + playback.cancel() + if (starting()) close() + else void load(destination) + }) // The list follows its field whenever the header reflows the box. new ResizeObserver(placeMenu).observe(box) @@ -340,6 +365,8 @@ export function createRevisionControl(options: RevisionControlOptions) { return { get selected() { return selected() }, get from() { return from() }, + get urlRevision() { return playback.range?.to.id ?? selected() }, + get urlFrom() { return playback.range?.from.id ?? from() }, get comparison() { return current.comparison }, get live() { return live() }, paintProjectEdit(root: ParentNode) { diff --git a/src/viewers/web/revision/history.ts b/src/viewers/web/revision/history.ts new file mode 100644 index 00000000..5a23b928 --- /dev/null +++ b/src/viewers/web/revision/history.ts @@ -0,0 +1,75 @@ +import { compareArchitecture, ownedFiles } from '../../../history/comparison.ts' +import { readGitText, withGitRevision } from '../../../history/revisions.ts' +import { readSourceTexts } from '../../../history/snapshots.ts' +import { loadMapRoot } from '../runtime.ts' +import type { WebRevision } from '../payload.ts' + +/** Historical architecture is immutable: share each in-flight read for the lifetime of the web session. */ +export function createRevisionHistory(repositoryRoot: string) { + const snapshots = new Map>() + + async function read(revision: WebRevision) { + return withGitRevision(repositoryRoot, revision.id, async root => { + const map = await loadMapRoot(root) + if (map.project === null) throw new Error('No Groma architecture in this commit') + const sources = await readSourceTexts(root, ownedFiles(map.world)) + return { map, sources } + }) + } + + function snapshot(revision: WebRevision) { + let pending = snapshots.get(revision.id) + if (pending === undefined) { + pending = read(revision) + snapshots.set(revision.id, pending) + } + return pending + } + + // A comparison also needs files that were present but not owned on the other side. + const extraSources = new Map>() + async function sourcesAt(revision: WebRevision, files: string[]) { + const { sources } = await snapshot(revision) + await Promise.all(files.filter(file => !Object.hasOwn(sources, file)).map(async file => { + const key = `${revision.id}:${file}` + let pending = extraSources.get(key) + if (pending === undefined) { + pending = readGitText(repositoryRoot, revision.id, file) + extraSources.set(key, pending) + } + sources[file] = await pending + })) + return sources + } + + async function compare(from: WebRevision, to: WebRevision) { + const [before, after] = await Promise.all([snapshot(from), snapshot(to)]) + const files = [...new Set([...ownedFiles(before.map.world), ...ownedFiles(after.map.world)])] + const [oldSources, newSources] = await Promise.all([sourcesAt(from, files), sourcesAt(to, files)]) + const { world, components, relationships } = compareArchitecture(before.map.world, after.map.world, oldSources, newSources) + return { project: after.map.project, world, comparison: { from, components, relationships } } + } + + /** Git's newest-first list defines the same ordering as the revision picker, never commit timestamps. */ + async function range(revisions: WebRevision[], from: string, to: string): Promise { + const start = revisions.findIndex(revision => revision.id === from) + const end = revisions.findIndex(revision => revision.id === to) + if (end < 0 || start <= end) throw new Error('Choose an older start commit and a newer end commit') + const ordered = revisions.slice(end, start + 1).reverse() + const compatible: WebRevision[] = [] + for (const revision of ordered) { + try { + await snapshot(revision) + compatible.push(revision) + } catch { + // An older Markdown contract is not a frame the current reader can play. + } + } + if (compatible[0]?.id !== from || compatible.at(-1)?.id !== to) { + throw new Error('Both playback endpoints must have readable Groma architecture') + } + return compatible + } + + return { snapshot, compare, range } +} diff --git a/src/viewers/web/revision/playback.ts b/src/viewers/web/revision/playback.ts new file mode 100644 index 00000000..2c539418 --- /dev/null +++ b/src/viewers/web/revision/playback.ts @@ -0,0 +1,154 @@ +import type { WebDataSource } from '../data.ts' +import type { WebPayload, WebRevision } from '../payload.ts' + +interface PlaybackOptions { + root: HTMLElement + data: WebDataSource + current(): WebPayload + present(payload: WebPayload, first: boolean): void + repaint(): void + error(reason: unknown): void +} + +/** Plays immutable revision comparisons; the revision control continues to own the displayed payload. */ +export function createRevisionPlayback(options: PlaybackOptions) { + const { root, data } = options + const control = document.createElement('span') + control.className = 'revision-playback' + control.hidden = true + const button = document.createElement('button') + button.type = 'button' + button.className = 'chrome-button' + const speed = document.createElement('select') + speed.className = 'chrome-button' + speed.setAttribute('aria-label', 'Playback speed') + for (const value of [0.5, 1, 2, 4]) { + const option = document.createElement('option') + option.value = String(value) + option.textContent = `${value}×` + option.selected = value === 1 + speed.append(option) + } + const caption = document.createElement('span') + caption.className = 'revision-playback-caption' + caption.hidden = true + caption.setAttribute('role', 'status') + control.append(button, speed, caption) + root.append(control) + let range: { from: WebRevision; to: WebRevision } | undefined + let running = false + let generation = 0 + let wake: (() => void) | undefined + const cache = new Map>() + + const keyOf = (revision: string, from?: string) => JSON.stringify([revision, from]) + function remember(payload: WebPayload): void { + if (payload.revision === null || payload.comparison?.from === null) return + cache.set(keyOf(payload.revision.id, payload.comparison?.from.id), Promise.resolve(payload)) + } + + function read(revision: string, from?: string): Promise { + const key = keyOf(revision, from) + let pending = cache.get(key) + if (pending === undefined) { + pending = data.readWorld(revision, from) + cache.set(key, pending) + // Prefetched errors are reported only if playback reaches that step. + void pending.catch(() => {}) + } + return pending + } + + function endpoints() { + const current = options.current() + const from = current.comparison?.from + return range ?? (from && current.revision ? { from, to: current.revision } : undefined) + } + + function paint(): void { + control.hidden = data.readPlaybackRevisions === undefined || endpoints() === undefined + button.textContent = running ? 'Stop' : 'Play' + button.setAttribute('aria-label', running ? 'Stop history playback' : 'Play history') + root.toggleAttribute('data-playing', running) + } + + function stop(clearRange = false): void { + generation++ + running = false + wake?.() + if (clearRange) { range = undefined; caption.hidden = true } + else if (options.current().revision !== null) describe(options.current().revision!) + paint() + } + + function pause(): Promise { + return new Promise(resolve => { + const timer = window.setTimeout(done, 1500 / Number(speed.value)) + function done(): void { window.clearTimeout(timer); wake = undefined; resolve() } + wake = done + }) + } + + function describe(revision: WebRevision): void { + const date = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(revision.date)) + caption.textContent = `${revision.shortId} · ${revision.subject} · ${date}` + caption.hidden = false + } + + function preload(revisions: WebRevision[], index: number): void { + for (const next of [index + 1, index + 2]) { + const revision = revisions[next] + if (revision !== undefined) void read(revision.id, revisions[next - 1]!.id) + } + } + + async function walk(revisions: WebRevision[], token: number): Promise { + for (let index = 0; index < revisions.length; index++) { + preload(revisions, index) + const revision = revisions[index]! + const payload = await read(revision.id, revisions[index - 1]?.id) + if (token !== generation) return false + describe(revision) + options.present(payload, index === 0) + await pause() + if (token !== generation) return false + } + return true + } + + async function play(): Promise { + const selected = endpoints() + if (selected === undefined || data.readPlaybackRevisions === undefined) return + range = selected + running = true + const token = ++generation + caption.textContent = 'Loading history…' + caption.hidden = false + options.repaint() + paint() + try { + const revisions = await data.readPlaybackRevisions(selected.to.id, selected.from.id) + if (token !== generation) return + if (!await walk(revisions, token)) return + const final = await read(selected.to.id, selected.from.id) + if (token !== generation) return + running = false + options.present(final, false) + paint() + } catch (reason) { + if (token !== generation) return + stop() + options.error(reason) + } + } + + button.addEventListener('click', () => { if (running) stop(); else void play() }) + speed.addEventListener('change', () => wake?.()) + return { + paint, + remember, + cancel: () => stop(true), + get range() { return range }, + get running() { return running }, + } +} diff --git a/src/viewers/web/revision/view.ts b/src/viewers/web/revision/view.ts index da83825c..ce8b8c36 100644 --- a/src/viewers/web/revision/view.ts +++ b/src/viewers/web/revision/view.ts @@ -64,6 +64,15 @@ export const revisionCss = ` #end-comparison[hidden] { display: none; } #end-comparison { margin-left: 4px; font-size: 18px; padding: 6px 9px; animation: revision-fade-in ${motion} both; } + .revision-playback { display: inline-flex; align-items: center; gap: 4px; margin-left: 4px; flex: none; } + .revision-playback[hidden], .revision-playback-caption[hidden] { display: none; } + .revision-playback select { width: 54px; padding: 4px; } + .revision-playback-caption { + position: absolute; top: calc(100% + 8px); left: 0; z-index: 10; + max-width: min(560px, 70vw); padding: 7px 10px; border: 1px solid var(--hairline); + border-radius: 6px; background: var(--paper); color: var(--ink); font-size: 11px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } @keyframes revision-spin { to { transform: rotate(360deg); } } @keyframes revision-fade-in { from { opacity: 0; } } @keyframes revision-slide-in { from { opacity: 0; transform: translateX(8px); } } From 5e816f74b2775e8ab39e5e8e3606a30cdb00608a Mon Sep 17 00:00:00 2001 From: Alex Gavrilescu Date: Fri, 9 Oct 2026 15:19:07 +0200 Subject: [PATCH 2/6] TASK-563 - Play the architecture's history between two commits on the web map --- ...tory-between-two-commits-on-the-web-map.md | 44 +++++++++-- docs/viewers/web/history-playback.md | 17 ++-- .../containers/cli/components/web-server.md | 2 +- .../export/components/revision-control.md | 2 +- src/history/revisions.ts | 27 +++++-- src/viewers/web/map-session.ts | 30 +++---- src/viewers/web/revision/control.ts | 37 ++++----- src/viewers/web/revision/history.ts | 78 ++++++++++++------- src/viewers/web/revision/playback.ts | 10 +-- src/viewers/web/revision/view.ts | 8 +- test-bun/web-revisions.test.ts | 12 ++- 11 files changed, 177 insertions(+), 90 deletions(-) diff --git a/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md b/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md index 0198b764..a602c3cc 100644 --- a/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md +++ b/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md @@ -1,11 +1,11 @@ --- id: TASK-563 title: Play the architecture's history between two commits on the web map -status: Done +status: In Progress assignee: - '@codex' created_date: '2026-10-07 23:16' -updated_date: '2026-10-09 08:01' +updated_date: '2026-10-09 13:19' labels: - senior dependencies: [] @@ -19,6 +19,12 @@ references: - src-core - web-server - web-page + - src/viewers/web/revision/control.ts + - src/viewers/web/revision/playback.ts + - src/viewers/web/revision/history.ts + - src/viewers/web/revision/view.ts + - src/viewers/web/map-session.ts + - src/history/revisions.ts modified_files: - src/viewers/web/revision/history.ts - src/viewers/web/map-session.ts @@ -32,6 +38,8 @@ modified_files: - groma/systems/groma-md/containers/export/components/playback.md - groma/systems/groma-md/containers/cli/components/web-server.md - groma/systems/groma-md/containers/export/components/revision-control.md + - test-bun/web-revisions.test.ts + - src/history/revisions.ts type: feature ordinal: 3 --- @@ -46,18 +54,18 @@ Build on revisions and snapshots (src/history/revisions.ts, src/history/snapshot ## Acceptance Criteria -- [ ] #1 The revision control lets a person choose a start and an end commit among the readable revisions, and Play walks from start to end one revision at a time, with Stop and a speed choice. The URL carries the range so a link reopens it. +- [x] #1 The revision control lets a person choose a start and an end commit among the readable revisions, and Play walks from start to end one revision at a time, with Stop and a speed choice. The URL carries the range so a link reopens it. - [ ] #2 Each step morphs the map from the previous revision's layout to the next with retained positions, the way the comparison keeps positions, and the step's short id, subject and date show while it plays. -- [ ] #3 Playback stays smooth: a revision is read once per session and cached, upcoming revisions load ahead, and a range of fifty commits plays without reloading the page or blocking the map. -- [ ] #4 When playback reaches the end, the map shows the comparison from the start to the end commit with its changes bar and stepper. +- [x] #3 Playback stays smooth: a revision is read once per session and cached, upcoming revisions load ahead, and a range of fifty commits plays without reloading the page or blocking the map. +- [x] #4 When playback reaches the end, the map shows the comparison from the start to the end commit with its changes bar and stepper. - [ ] #5 A reduced-motion preference steps without animation. The static export and the terminal viewer are unchanged. -- [ ] #6 Playback visits only compatible revisions, in commit order, from the chosen start commit to the chosen end commit. +- [x] #6 Playback visits only compatible revisions, in commit order, from the chosen start commit to the chosen end commit. ## Definition of Done - [ ] #1 Acceptance criteria have objective verification evidence. -- [ ] #2 Relevant checks pass and changes remain task-scoped. +- [x] #2 Relevant checks pass and changes remain task-scoped. - [x] #3 Public contracts or documentation are updated when behavior changes. - [x] #4 Implementation Plan reflects the final approach; correction history and verification are recorded in Implementation Notes. @@ -65,7 +73,9 @@ Build on revisions and snapshots (src/history/revisions.ts, src/history/snapshot ## Implementation Plan -1. Add a live-only playback controller to the existing two-commit revision selection, with Play/Stop, speed, commit metadata and persistent range URLs. 2. Cache historical snapshots and owned source text in the web session, select compatible commits in existing Git order, and preload upcoming step comparisons. 3. Reuse comparison layout, keyed map morphing and reduced-motion handling for each step; finish with the selected full-range comparison. 4. Update the web guide, inspect the supported flow and task-scoped diff without running builds or tests, then scan and curate new files under their existing responsibilities. No new tests: the user explicitly prohibits running tests; record verification limits rather than claiming performance evidence. +1. Fix the reviewed endpoint flow in the existing revision selector: retain chosen endpoints while opening a field after Stop, render endpoint fields directly, and disable Play through the current pending-navigation state. 2. Retain each historical archive for the server session, reuse it for architecture, source and code-detail requests, release it on close, and prepare ordinary layouts only when requested. Remove the unused loader branch and playback members; preserve browser frame caching, two-frame preloading, Git order, speed, Stop, reduced motion and final full-range comparison. 3. Regression authority: AC1 and the reproduced stop/edit failure require changing one endpoint to retain the other. Existing server tests do not exercise browser endpoint edits; reuse the Chromium stop/edit reproduction. AC1 and the reproduced pending-navigation race require Play to wait for the chosen range; hold the selected endpoint response and verify Play is disabled until it applies. AC3 requires one historical read per session; existing source assertions check values but not reused reads. Extend the existing web-history test by temporarily removing Git access after loading snapshots, then request old code/source details and verify they still work. Browser Git archive logging with How open on two replays verifies one extraction per revision. 4. Reuse the minimum 50-commit browser fixture to verify order, range URL, final comparison/changes stepper, speed, Stop, replay and reduced-motion execution; run focused checks and bun run check. 5. Run one cold simplicity review, implement accepted local simplifications, then perform specification and quality review; run one final full-context complexity review. Read finalization instructions, record objective evidence without claiming unverified visual morph quality, curate any scanner-caused architecture files, and make the authorized local task commit. + +Regression refinement after self-review: the retained chosen endpoints must not replace the displayed revision getters used by render.ts for How/source and changes navigation. Authority is the existing displayed-frame detail flow and the required chosen-range/displayed-frame distinction. The wrong result would show final-range code while an intermediate frame is visible. The replay browser check asserts code requests name the displayed first snapshot, with no comparison endpoint, rather than the retained final range; later comparison frames render their embedded file changes. ## Implementation Notes @@ -74,4 +84,22 @@ Build on revisions and snapshots (src/history/revisions.ts, src/history/snapshot Shared-file coordination: TASK-562 also records data.ts and map-session.ts; TASK-565 records map-session.ts, render.ts and the web index; TASK-568 records map-session.ts. Playback uses separate imports, endpoint and URL hunks and preserves their work. Playback documentation is a separate page to avoid overlapping edits to the web index. Implemented Play/Stop and 0.5×/1×/2×/4× speed in the live revision control; the selected range remains in the URL and header while a caption identifies each frame. The web session caches architecture and source snapshots; playback filters unreadable revisions in Git list order and preloads the next two comparisons. Completion restores the full-range comparison. Existing comparison layout, keyed map morphing and reduced-motion behavior are reused. Static export and terminal code were not changed. Specification and quality review traced revision selection -> compatible range -> cached snapshots -> prefetched comparison frames -> map application -> final comparison. Type checking (tsc --noEmit), focused Biome lint and git diff --check pass; the one reported complexity warning is in the existing paintDetailsState function, outside this change. No builds or tests were run, as instructed. No browser automation tool is available in this session, so interactive behavior and fifty-commit smoothness remain unverified; acceptance criteria are not checked without that evidence. Architecture scan completed, and history.ts/playback.ts were combined into Web host/Revision selector with their responsibilities documented. + +Review fix pass authorized by the user on 2026-10-09, superseding the earlier prohibition on tests. Scope is the three PR #121 defects and listed local simplifications. No stored architecture concept changes: OKF documents and Markdown links remain portable history; C4 elements/relationships remain unchanged; Revision selector and Web host own transient playback and session caches. The solution is independent of source language because installed scanners read the same cached revision root through the existing outline contract. + +Cold simplicity gate passed with no blocker. Self-review traced render.ts consumers of selected/from and identified that they must continue to expose the displayed frame; dedicated endpoint helpers will own retained editing and URL choices. The cold optional collapse of repeated range checks is applied through those endpoint helpers without changing source-detail semantics. Initial bun run check passed: 16 Node tests; 776 Bun tests, 51 skipped, zero failures. Existing unrelated lint reports remain one warning and two informational messages. + +Targeted cold re-review approved the endpoint-helper split with no blocking regression. Browser assertion follows the existing detail flow: the first frame is an ordinary snapshot and requests code for that displayed start revision, with no comparison endpoint; later comparison frames render embedded file changes. The check therefore asserts each replay code request names the displayed first snapshot rather than the retained final range. + +Implementer specification review: the three reported failures are fixed in the declared committed-range flow. Chromium proves Stop/edit retains the chosen start, pending destination navigation disables Play and completes at the chosen destination, and How-open replay code requests follow the displayed start snapshot while each loaded commit is extracted once. The 50-commit run visits every revision in Git order, retains the URL, restores the full-range changes bar and supports Next change with one document navigation. The unreadable-middle fixture still skips only the unreadable commit. Original visual morph quality and reduced-motion geometry are not claimed by these minimum-fixture checks. Implementer quality review traced field click -> retained endpoint helpers -> navigation/loading -> playback frames -> displayed source getters -> cached server archive -> existing scanner outline/source reader -> HTTP response. Ownership and naming expose the two kinds of state; no separate navigation framework or new architecture concept exists. The one new assertion block detects reopening Git for both historical code and source, failed before the server fix, and passes after it; it is parallel-safe in the test-owned fixture. All changed source/test files remain below 500 lines and changed-file Biome reports no issues. + +Final verification after the endpoint-helper correction: bun run check passed (16 Node tests; 776 Bun tests, 51 skipped, zero failures), including TypeScript, repository lint and scrollbar checks. Changed-file Biome and git diff --check pass. Chromium stop/edit keeps Revision 1 when destination is reselected; holding a chosen destination response disables Play, then playback finishes at Revision 6. Two How-open replays use the displayed first snapshot for code reads and one extraction per loaded commit. The final 50-commit run completed in 21.6 seconds with every commit in Git order, one document navigation, unchanged range URL, no page errors, and working final changes bar/Next change. Request order proves the next two frames preload. Archive logging confirms exactly one extraction for each of all 50 revisions, and session close removed all 50 archives. The unreadable-middle fixture returns only its first/last readable commits. Final screenshots were inspected. Maximum animation-frame gap was 100.4 ms while the native full checks and another browser ran; no larger-map performance claim is made. Original AC2 visual morph quality and AC5 reduced-motion geometry remain unchecked; reduced-motion execution itself passed and static/TUI coverage passed in the full check. Artifacts: /private/tmp/groma-pr121-fixes/regressions-result.json, fifty-result.json, cache-final.json and check-final.log. + +Final full-context complexity review passed with no material recommendations. It confirmed clear separation of chosen endpoints from displayed frames, the existing navigation flag for Play, distinct browser/server cache responsibilities, archive cleanup by the web session, lazy ordinary layouts, and unchanged OKF/C4 concepts. Cold simplicity review passed; the only optional repeated endpoint checks were collapsed into dedicated endpoint helpers and one targeted re-review approved them. All reported PR #121 defects and requested simplifications are complete. Original visual AC2/AC5 and DoD1 remain unverified, so the task remains In Progress rather than asserting full original-feature acceptance. + +## Final Summary + + +Fixed all three PR #121 review defects: endpoint editing after Stop retains the other chosen endpoint; Play is disabled while an endpoint navigation is pending; historical code and source details reuse one archive per revision for the server session. Layouts are lazy, fields render actual endpoints, and unused loader/playback state is removed. Displayed-frame source reads stay separate from retained editing/URL choices. Existing owners, two caches, next-two preloading, Git order, speed, Stop, reduced-motion execution and final full-range comparison are preserved. Added one cache regression in the existing web-history test (failed before the server fix, passed after). Final bun run check passed: 16 Node and 776 Bun tests, 51 skipped, zero failures. Chromium verified stop/edit, pending destination, two How-open replays, all 50 commits in order, retained URL, one document navigation, final changes bar and Next change. All 50 revisions extracted once; closing the session removed every archive. Cold simplicity and final complexity reviews passed. The original morph-quality and reduced-motion-geometry criteria remain unchecked; no larger-map performance claim is made. Fix branch is local only; no push. + diff --git a/docs/viewers/web/history-playback.md b/docs/viewers/web/history-playback.md index 559a10bc..91b3d3d8 100644 --- a/docs/viewers/web/history-playback.md +++ b/docs/viewers/web/history-playback.md @@ -4,7 +4,9 @@ In the live web map, choose a commit, open **compare 2 revisions**, and choose an older start commit. **Play** walks the range from oldest to newest. The speed selector offers 0.5×, 1×, 2×, and 4×; **Stop** leaves the current step visible. The caption shows that step's short commit ID, subject, and local date and time. -The two revision fields keep the chosen range. Its `from` and `revision` URL +The two revision fields keep the chosen range, including while editing an +endpoint after Stop. Play waits for a chosen endpoint to finish loading. Its +`from` and `revision` URL parameters reopen the range without starting playback automatically. Only commits readable by the current architecture reader are visited, in the @@ -30,11 +32,14 @@ without discarding their cached data. The live [`map-session.ts`](../../../src/viewers/web/map-session.ts) serves `/playback.json?from=&revision=` to identify readable frames. [`revision/history.ts`](../../../src/viewers/web/revision/history.ts) shares -in-flight historical reads for that server session: each commit's architecture, -layout, and owned source text are loaded once. Comparisons reuse those snapshots; -source files owned only on the other side are read and cached when needed. -Compatibility checks populate the same cache used by playback. Working-tree -comparisons continue to read current files. +in-flight historical reads for that server session. Each commit is extracted +once; its architecture and owned source text are cached. Code outlines and +source details read that same immutable archive. Ordinary map layouts are +prepared only when a snapshot view is requested; comparisons prepare their own +layouts. Source files owned only on the other side are read and cached when +needed. Compatibility checks populate the same cache used by playback. Closing +the server session removes its archives. Working-tree comparisons continue to +read current files. Playback is transient browser state, not stored architecture knowledge. It adds no OKF metadata or C4 element type. Git commits and the existing architecture diff --git a/groma/systems/groma-md/containers/cli/components/web-server.md b/groma/systems/groma-md/containers/cli/components/web-server.md index a764f53b..f1773637 100644 --- a/groma/systems/groma-md/containers/cli/components/web-server.md +++ b/groma/systems/groma-md/containers/cli/components/web-server.md @@ -24,7 +24,7 @@ groma: description: Serves the local browser map and live architecture operations --- -Starts the local HTTP server. Serves project setup and connects the browser to architecture, scanner, task, and source operations. Keeps historical architecture snapshots and source text in a session cache, identifies readable frames for playback, and reuses snapshots for commit comparisons. +Starts the local HTTP server. Serves project setup and connects the browser to architecture, scanner, task, and source operations. Keeps one immutable archive per historical revision for the server session, shares its architecture and source data between comparisons and historical details, and removes the archives when the session closes. Identifies readable playback frames and prepares an ordinary snapshot layout only when that view is requested. ## Relationships diff --git a/groma/systems/groma-md/containers/export/components/revision-control.md b/groma/systems/groma-md/containers/export/components/revision-control.md index 096be492..5bcfda37 100644 --- a/groma/systems/groma-md/containers/export/components/revision-control.md +++ b/groma/systems/groma-md/containers/export/components/revision-control.md @@ -18,7 +18,7 @@ groma: description: Browses, compares and plays readable Git architecture revisions --- -Owns the selected revision or comparison and the commit search fields. Plays a selected commit range in Git order with speed and Stop controls, shows the current commit, preloads upcoming frames, and finishes on the full-range comparison. The browser keeps the selected range in its URL and uses the existing map motion for each step. +Owns the selected revision or comparison and the commit search fields. Plays a selected commit range in Git order with speed and Stop controls, shows the current commit, preloads upcoming frames, and finishes on the full-range comparison. The chosen endpoints remain separate from the displayed playback frame, so editing either endpoint after Stop keeps the other choice. Play waits for endpoint navigation to finish. The browser keeps the selected range in its URL and uses the existing map motion for each step. ## Relationships diff --git a/src/history/revisions.ts b/src/history/revisions.ts index f40a2e6b..6f646b19 100644 --- a/src/history/revisions.ts +++ b/src/history/revisions.ts @@ -212,19 +212,32 @@ function extractArchive( }) } +async function openGitTree(repositoryRoot: string, revisionId: string, paths: string[]) { + const root = await mkdtemp(path.join(tmpdir(), 'groma-revision-')) + const close = () => rm(root, { recursive: true, force: true }) + try { + await extractArchive(repositoryRoot, revisionId, root, paths) + return { root, close } + } catch (error) { + await close() + throw error + } +} + async function withGitTree( repositoryRoot: string, revisionId: string, paths: string[], load: (snapshotRoot: string) => Promise, ): Promise { - const snapshotRoot = await mkdtemp(path.join(tmpdir(), 'groma-revision-')) - try { - await extractArchive(repositoryRoot, revisionId, snapshotRoot, paths) - return await load(snapshotRoot) - } finally { - await rm(snapshotRoot, { recursive: true, force: true }) - } + const snapshot = await openGitTree(repositoryRoot, revisionId, paths) + try { return await load(snapshot.root) } + finally { await snapshot.close() } +} + +/** Opens an immutable revision for a session; its owner must close it when the session ends. */ +export function openGitRevision(repositoryRoot: string, revisionId: string) { + return openGitTree(repositoryRoot, revisionId, []) } /** Loads through a complete, temporary repository snapshot and always removes it afterwards. */ diff --git a/src/viewers/web/map-session.ts b/src/viewers/web/map-session.ts index e60074bb..4b4a3930 100644 --- a/src/viewers/web/map-session.ts +++ b/src/viewers/web/map-session.ts @@ -13,7 +13,7 @@ import { pinsOf } from '../../work/pins.ts' import { ownedFiles } from '../../history/comparison.ts' import { readComparison } from '../../history/snapshots.ts' import { measuredSheetScene } from '../../sheet/scene.ts' -import { listGitRevisions, withGitRevision } from '../../history/revisions.ts' +import { listGitRevisions } from '../../history/revisions.ts' import { renderPage } from './page.ts' import { createRevisionHistory } from './revision/history.ts' import type { WebMapPayload, WebPayload, WebRevision, WebWorkPayload } from './payload.ts' @@ -29,9 +29,12 @@ async function structureResponse( repositoryRoot: string, selected: Pick, element: string, + history: ReturnType, ): Promise { try { - const structure = await readCodeStructure(repositoryRoot, selected.world, selected.revision?.id ?? null, element) + const structure = selected.revision === null + ? await readCodeStructure(repositoryRoot, selected.world, null, element) + : await history.structure(selected.revision, selected.world, element) return structure === undefined ? new Response('Component not found', { status: 404 }) : Response.json(structure) @@ -45,10 +48,13 @@ async function sourceResponse( selected: Pick, element: string, file: string | null, + history: ReturnType, ): Promise { if (file === null) return new Response('Source selection required', { status: 400 }) try { - const source = await readSource(repositoryRoot, selected.world, selected.revision?.id ?? null, element, file) + const source = selected.revision === null + ? await readSource(repositoryRoot, selected.world, null, element, file) + : await history.source(selected.revision, selected.world, element, file) return source === undefined ? new Response('Source file not found', { status: 404 }) : Response.json(source) @@ -60,13 +66,10 @@ async function sourceResponse( async function loadMap( repositoryRoot: string, revisions: WebRevision[], - revision: WebRevision | null, onProgress?: (progress: StartupProgress) => void | Promise, ): Promise> { - const snapshot = revision === null - ? await loadMapRoot(repositoryRoot, phase => onProgress?.({ phase })) - : await withGitRevision(repositoryRoot, revision.id, loadMapRoot) - return { ...snapshot, revision, revisions } + const snapshot = await loadMapRoot(repositoryRoot, phase => onProgress?.({ phase })) + return { ...snapshot, revision: null, revisions } } /** Owns the ready map, its request handlers, and its live subscriptions. */ @@ -122,7 +125,7 @@ export async function createWebMapSession( const revision = (await readRevisions()).find(candidate => candidate.id === revisionId) if (revision === undefined) return new Response('Unknown Groma revision', { status: 404 }) try { - const { map: stored } = await history.snapshot(revision) + const stored = await history.view(revision) const snapshot = { ...stored, revision, revisions } if (snapshot.project === null) throw new Error('No Groma architecture in this commit') return { @@ -215,8 +218,8 @@ export async function createWebMapSession( : { ...item, code: [...(change.after?.code ?? []), ...(change.before?.code ?? [])] }) } } return url.pathname === '/code.json' - ? structureResponse(repositoryRoot, selected, element) - : sourceResponse(repositoryRoot, selected, element, file) + ? structureResponse(repositoryRoot, selected, element, history) + : sourceResponse(repositoryRoot, selected, element, file, history) } function worldEvent(): Uint8Array { @@ -240,7 +243,7 @@ export async function createWebMapSession( let worldChain = Promise.resolve() async function reloadWorld(): Promise { if (closed) return - const next = await loadMap(repositoryRoot, revisions, null, options.onProgress) + const next = await loadMap(repositoryRoot, revisions, options.onProgress) if (closed) return map = { generation: map.generation + 1, @@ -280,7 +283,7 @@ export async function createWebMapSession( }) await scannerSession.ready // Initial folds are included in this map; later folds queue behind its load. - worldChain = loadMap(repositoryRoot, revisions, null, options.onProgress).then(next => { + worldChain = loadMap(repositoryRoot, revisions, options.onProgress).then(next => { map = { generation: 1, ...next } for (const file of ownedFiles(map.world)) sourceFiles.add(file) }) @@ -463,6 +466,7 @@ export async function createWebMapSession( worldChain, workChain, revisionRead, + history.close(), covers, ]) }, diff --git a/src/viewers/web/revision/control.ts b/src/viewers/web/revision/control.ts index 6206c380..001ee5f1 100644 --- a/src/viewers/web/revision/control.ts +++ b/src/viewers/web/revision/control.ts @@ -102,7 +102,7 @@ export function createRevisionControl(options: RevisionControlOptions) { let pendingWorld: WebPayload | undefined let appliedWork = boot.workGeneration const playback = createRevisionPlayback({ - root, data, current: () => current, repaint: paint, + root, data, current: () => current, navigating: () => navigating, repaint: paint, present(payload, first) { editing = undefined setRevision(payload) @@ -114,9 +114,12 @@ export function createRevisionControl(options: RevisionControlOptions) { const selected = () => current.revision?.id const from = () => current.comparison === undefined ? undefined : current.comparison.from?.id ?? '' + // Fields and URLs keep the chosen range while source details follow the displayed frame above. + const destination = () => playback.range?.to.id ?? selected() + const start = () => playback.range?.from.id ?? from() const live = () => current.comparison === undefined && current.revision === null && boot.delivery.kind === 'live' /** A start is being chosen for a comparison that does not exist yet. */ - const starting = () => editing === 'from' && current.comparison === undefined + const starting = () => editing === 'from' && current.comparison === undefined && playback.range === undefined /** The commit list sits under the slot it edits: the box for the first slot, the rule after "vs." for the second. */ function placeMenu(): void { @@ -129,9 +132,9 @@ export function createRevisionControl(options: RevisionControlOptions) { function paintResults(): void { if (singleSnapshot) { results.innerHTML = snapshotNotice(current.revision); localizeDates(results); return } // Ids use '' for the working tree. A start that is still being chosen holds nothing yet. - const viewed = selected() ?? '' - const held = editing === 'from' ? from() : viewed - const otherEndpoint = { revision: undefined, from: viewed, to: from() }[editing ?? 'revision'] + const viewed = destination() ?? '' + const held = editing === 'from' ? start() : viewed + const otherEndpoint = { revision: undefined, from: viewed, to: start() }[editing ?? 'revision'] results.innerHTML = revisionOptions(revisions, held, search.value, workingTree) // A comparison runs from an older revision to a newer one. The list runs newest first under the working tree, so a // start must sit below its destination and a destination above its start. @@ -163,11 +166,8 @@ export function createRevisionControl(options: RevisionControlOptions) { else box.dataset.editing = editing // Live refreshes repaint often; untouched fields keep their focus and do not replay their entrance. const range = playback.range - const displayed = range === undefined ? current : { - ...current, revision: range.to, - comparison: { components: {}, relationships: {}, ...current.comparison, from: range.from }, - } - const nextFields = starting() ? pendingPairFields(current.revision) : revisionFields(displayed) + const nextFields = starting() ? pendingPairFields(current.revision) + : revisionFields(range?.to ?? current.revision, range?.from ?? current.comparison?.from) if (nextFields !== paintedFields) { fields.innerHTML = nextFields; paintedFields = nextFields } search.hidden = !opened || singleSnapshot search.placeholder = placeholders[editing ?? 'revision'] @@ -236,7 +236,7 @@ export function createRevisionControl(options: RevisionControlOptions) { } function open(field: RevisionField): void { - playback.cancel() + playback.cancel(false) // Widths are read from the closed layout, also when another field is the search right now. close() const width = (name: RevisionField) => fields.querySelector(`[data-field="${name}"]`)?.getBoundingClientRect().width @@ -269,9 +269,9 @@ export function createRevisionControl(options: RevisionControlOptions) { } async function load(revision?: string, starting?: string, reset = true): Promise { - playback.cancel() const loading = ++request navigating = true + playback.cancel() error.hidden = true hideTooltip() box.setAttribute('aria-busy', 'true') @@ -288,6 +288,7 @@ export function createRevisionControl(options: RevisionControlOptions) { } finally { if (loading === request) { navigating = false + playback.paint() box.removeAttribute('aria-busy') const pending = pendingWorld pendingWorld = undefined @@ -297,8 +298,8 @@ export function createRevisionControl(options: RevisionControlOptions) { } function chooseRevision(revision: string): void { - if (editing === 'from') void load(selected(), revision) - else if (editing === 'to') void load(revision || undefined, from()) + if (editing === 'from') void load(destination(), revision) + else if (editing === 'to') void load(revision || undefined, start()) else void load(revision || undefined) } @@ -333,10 +334,10 @@ export function createRevisionControl(options: RevisionControlOptions) { if (option !== null && !option.disabled) chooseRevision(option.dataset.revision!) }) end.addEventListener('click', () => { - const destination = playback.range?.to.id ?? selected() + const revision = destination() playback.cancel() if (starting()) close() - else void load(destination) + else void load(revision) }) // The list follows its field whenever the header reflows the box. new ResizeObserver(placeMenu).observe(box) @@ -365,8 +366,8 @@ export function createRevisionControl(options: RevisionControlOptions) { return { get selected() { return selected() }, get from() { return from() }, - get urlRevision() { return playback.range?.to.id ?? selected() }, - get urlFrom() { return playback.range?.from.id ?? from() }, + get urlRevision() { return destination() }, + get urlFrom() { return start() }, get comparison() { return current.comparison }, get live() { return live() }, paintProjectEdit(root: ParentNode) { diff --git a/src/viewers/web/revision/history.ts b/src/viewers/web/revision/history.ts index 5a23b928..99b4ce6c 100644 --- a/src/viewers/web/revision/history.ts +++ b/src/viewers/web/revision/history.ts @@ -1,20 +1,28 @@ import { compareArchitecture, ownedFiles } from '../../../history/comparison.ts' -import { readGitText, withGitRevision } from '../../../history/revisions.ts' -import { readSourceTexts } from '../../../history/snapshots.ts' -import { loadMapRoot } from '../runtime.ts' -import type { WebRevision } from '../payload.ts' +import { openGitRevision } from '../../../history/revisions.ts' +import { readSnapshot, readSourceTexts } from '../../../history/snapshots.ts' +import { measuredSheetScene } from '../../../sheet/scene.ts' +import type { ArchitectureGraph } from '../../../types.ts' +import { readSource } from '../../source/read.ts' +import { readSnapshotCodeStructure } from '../../source/structure.ts' +import type { WebMapPayload, WebRevision } from '../payload.ts' /** Historical architecture is immutable: share each in-flight read for the lifetime of the web session. */ export function createRevisionHistory(repositoryRoot: string) { const snapshots = new Map>() async function read(revision: WebRevision) { - return withGitRevision(repositoryRoot, revision.id, async root => { - const map = await loadMapRoot(root) - if (map.project === null) throw new Error('No Groma architecture in this commit') - const sources = await readSourceTexts(root, ownedFiles(map.world)) - return { map, sources } - }) + const started = performance.now() + const archive = await openGitRevision(repositoryRoot, revision.id) + try { + const model = await readSnapshot(archive.root) + const sources = await readSourceTexts(archive.root, ownedFiles(model.world)) + return { ...archive, ...model, sources, architectureLoadMilliseconds: performance.now() - started, + view: undefined as Pick | undefined } + } catch (error) { + await archive.close() + throw error + } } function snapshot(revision: WebRevision) { @@ -26,28 +34,42 @@ export function createRevisionHistory(repositoryRoot: string) { return pending } + /** Comparisons and compatibility checks need the model; only an ordinary view needs this layout. */ + async function view(revision: WebRevision) { + const stored = await snapshot(revision) + if (stored.view === undefined) { + const sheet = measuredSheetScene(stored.world) + stored.view = { project: stored.project, world: stored.world, sheet: sheet.scene, + timings: { architectureLoadMilliseconds: stored.architectureLoadMilliseconds, ...sheet.timings, + totalMilliseconds: stored.architectureLoadMilliseconds + sheet.timings.placementMilliseconds + sheet.timings.routingMilliseconds } } + } + return stored.view + } + // A comparison also needs files that were present but not owned on the other side. - const extraSources = new Map>() async function sourcesAt(revision: WebRevision, files: string[]) { - const { sources } = await snapshot(revision) - await Promise.all(files.filter(file => !Object.hasOwn(sources, file)).map(async file => { - const key = `${revision.id}:${file}` - let pending = extraSources.get(key) - if (pending === undefined) { - pending = readGitText(repositoryRoot, revision.id, file) - extraSources.set(key, pending) - } - sources[file] = await pending - })) - return sources + const stored = await snapshot(revision) + const missing = files.filter(file => !Object.hasOwn(stored.sources, file)) + Object.assign(stored.sources, await readSourceTexts(stored.root, missing)) + return stored.sources + } + + async function structure(revision: WebRevision, world: ArchitectureGraph, element: string) { + const stored = await snapshot(revision) + return readSnapshotCodeStructure(repositoryRoot, stored.root, world, element) + } + + async function source(revision: WebRevision, world: ArchitectureGraph, element: string, file: string) { + const stored = await snapshot(revision) + return readSource(stored.root, world, null, element, file) } async function compare(from: WebRevision, to: WebRevision) { const [before, after] = await Promise.all([snapshot(from), snapshot(to)]) - const files = [...new Set([...ownedFiles(before.map.world), ...ownedFiles(after.map.world)])] + const files = [...new Set([...ownedFiles(before.world), ...ownedFiles(after.world)])] const [oldSources, newSources] = await Promise.all([sourcesAt(from, files), sourcesAt(to, files)]) - const { world, components, relationships } = compareArchitecture(before.map.world, after.map.world, oldSources, newSources) - return { project: after.map.project, world, comparison: { from, components, relationships } } + const { world, components, relationships } = compareArchitecture(before.world, after.world, oldSources, newSources) + return { project: after.project, world, comparison: { from, components, relationships } } } /** Git's newest-first list defines the same ordering as the revision picker, never commit timestamps. */ @@ -71,5 +93,9 @@ export function createRevisionHistory(repositoryRoot: string) { return compatible } - return { snapshot, compare, range } + async function close(): Promise { + await Promise.all([...snapshots.values()].map(pending => pending.then(stored => stored.close(), () => {}))) + } + + return { view, compare, range, structure, source, close } } diff --git a/src/viewers/web/revision/playback.ts b/src/viewers/web/revision/playback.ts index 2c539418..2fc5eee9 100644 --- a/src/viewers/web/revision/playback.ts +++ b/src/viewers/web/revision/playback.ts @@ -5,6 +5,7 @@ interface PlaybackOptions { root: HTMLElement data: WebDataSource current(): WebPayload + navigating(): boolean present(payload: WebPayload, first: boolean): void repaint(): void error(reason: unknown): void @@ -69,7 +70,7 @@ export function createRevisionPlayback(options: PlaybackOptions) { control.hidden = data.readPlaybackRevisions === undefined || endpoints() === undefined button.textContent = running ? 'Stop' : 'Play' button.setAttribute('aria-label', running ? 'Stop history playback' : 'Play history') - root.toggleAttribute('data-playing', running) + button.disabled = options.navigating() } function stop(clearRange = false): void { @@ -77,7 +78,7 @@ export function createRevisionPlayback(options: PlaybackOptions) { running = false wake?.() if (clearRange) { range = undefined; caption.hidden = true } - else if (options.current().revision !== null) describe(options.current().revision!) + else if (range !== undefined && options.current().revision !== null) describe(options.current().revision!) paint() } @@ -118,7 +119,7 @@ export function createRevisionPlayback(options: PlaybackOptions) { async function play(): Promise { const selected = endpoints() - if (selected === undefined || data.readPlaybackRevisions === undefined) return + if (options.navigating() || selected === undefined || data.readPlaybackRevisions === undefined) return range = selected running = true const token = ++generation @@ -147,8 +148,7 @@ export function createRevisionPlayback(options: PlaybackOptions) { return { paint, remember, - cancel: () => stop(true), + cancel: (clearRange = true) => stop(clearRange), get range() { return range }, - get running() { return running }, } } diff --git a/src/viewers/web/revision/view.ts b/src/viewers/web/revision/view.ts index ce8b8c36..4ecf17ac 100644 --- a/src/viewers/web/revision/view.ts +++ b/src/viewers/web/revision/view.ts @@ -149,9 +149,9 @@ function revisionField(field: RevisionField, revision: WebRevision | null): stri const versus = 'vs.' /** One field while browsing; start and destination around "vs." while comparing. */ -export function revisionFields(payload: WebPayload): string { - return payload.comparison === undefined ? revisionField('revision', payload.revision) - : revisionField('from', payload.comparison.from) + versus + revisionField('to', payload.revision) +export function revisionFields(destination: WebRevision | null, start?: WebRevision | null): string { + return start === undefined ? revisionField('revision', destination) + : revisionField('from', start) + versus + revisionField('to', destination) } /** While a start is being chosen the search holds its place; the viewed revision waits as the destination. */ @@ -166,5 +166,5 @@ export function snapshotNotice(revision: WebRevision | null): string { } export function revisionControl(payload: WebPayload, icons: { history: string, loader: string }): string { - return `
${icons.history}${icons.loader}${revisionFields(payload)}
` + return `
${icons.history}${icons.loader}${revisionFields(payload.revision, payload.comparison?.from)}
` } diff --git a/test-bun/web-revisions.test.ts b/test-bun/web-revisions.test.ts index be791a99..1eb898a0 100644 --- a/test-bun/web-revisions.test.ts +++ b/test-bun/web-revisions.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'bun:test' import { execFileSync } from 'node:child_process' -import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { cp, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { EMPTY_WORK_SNAPSHOT } from '@groma/work-source' @@ -61,6 +61,16 @@ test.concurrent('web history includes source-only commits and reads the selected const pairedSource = await (await request(`/source.json?${query}&from=${before}&revision=${after}`)).json() expect(pairedSource.source).toBe(source) expect((await request(`/world.json?from=${after}&revision=${after}`)).status).toBe(400) + // Loaded revisions must also serve historical details without reading Git again. + const gitDirectory = path.join(root, '.git') + const heldGitDirectory = path.join(root, '.cached-git') + await rename(gitDirectory, heldGitDirectory) + try { + const code = await request(`/code.json?element=${component.representationId}&revision=${before}`) + expect(code.status).toBe(200) + const cachedSource = await (await request(`/source.json?${query}&revision=${before}`)).json() + expect(cachedSource.source).toBe(original) + } finally { await rename(heldGitDirectory, gitDirectory) } expect(git('rev-parse', 'HEAD')).toBe(after) expect(git('status', '--porcelain')).toBe('') const reader = (await request('/events')).body!.getReader() From 93d5cfac6b30aff83b3fe91cef227677cbc20c8c Mon Sep 17 00:00:00 2001 From: Alex Gavrilescu Date: Sun, 11 Oct 2026 18:27:27 +0200 Subject: [PATCH 3/6] TASK-563 - Play the architecture's history between two commits on the web map --- ...tory-between-two-commits-on-the-web-map.md | 42 ++++- docs/viewers/web/history-playback.md | 57 +++++-- docs/viewers/web/index.md | 3 +- .../containers/cli/components/web-server.md | 6 +- .../export/components/revision-control.md | 2 +- src/history/revisions.ts | 2 +- src/history/snapshots.ts | 53 ++++-- src/viewers/web/data.ts | 11 +- src/viewers/web/map-session.ts | 71 +++++--- src/viewers/web/payload.ts | 6 + src/viewers/web/revision-archives.ts | 153 ++++++++++++++++++ src/viewers/web/revision/control.ts | 2 +- src/viewers/web/revision/history.ts | 101 ------------ src/viewers/web/revision/playback.ts | 88 +++++++--- src/viewers/web/revision/view.ts | 4 +- test-bun/web-revisions.test.ts | 69 +++++++- 16 files changed, 475 insertions(+), 195 deletions(-) create mode 100644 src/viewers/web/revision-archives.ts delete mode 100644 src/viewers/web/revision/history.ts diff --git a/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md b/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md index a602c3cc..74bd4b2a 100644 --- a/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md +++ b/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md @@ -5,7 +5,7 @@ status: In Progress assignee: - '@codex' created_date: '2026-10-07 23:16' -updated_date: '2026-10-09 13:19' +updated_date: '2026-10-11 16:26' labels: - senior dependencies: [] @@ -21,10 +21,10 @@ references: - web-page - src/viewers/web/revision/control.ts - src/viewers/web/revision/playback.ts - - src/viewers/web/revision/history.ts - src/viewers/web/revision/view.ts - src/viewers/web/map-session.ts - src/history/revisions.ts + - src/viewers/web/revision-archives.ts modified_files: - src/viewers/web/revision/history.ts - src/viewers/web/map-session.ts @@ -40,6 +40,11 @@ modified_files: - groma/systems/groma-md/containers/export/components/revision-control.md - test-bun/web-revisions.test.ts - src/history/revisions.ts + - src/history/snapshots.ts + - src/viewers/web/revision-archives.ts + - src/viewers/web/payload.ts + - docs/viewers/web/index.md + - groma/systems/groma-md/containers/cli/components/revision-archives.md type: feature ordinal: 3 --- @@ -76,6 +81,21 @@ Build on revisions and snapshots (src/history/revisions.ts, src/history/snapshot 1. Fix the reviewed endpoint flow in the existing revision selector: retain chosen endpoints while opening a field after Stop, render endpoint fields directly, and disable Play through the current pending-navigation state. 2. Retain each historical archive for the server session, reuse it for architecture, source and code-detail requests, release it on close, and prepare ordinary layouts only when requested. Remove the unused loader branch and playback members; preserve browser frame caching, two-frame preloading, Git order, speed, Stop, reduced motion and final full-range comparison. 3. Regression authority: AC1 and the reproduced stop/edit failure require changing one endpoint to retain the other. Existing server tests do not exercise browser endpoint edits; reuse the Chromium stop/edit reproduction. AC1 and the reproduced pending-navigation race require Play to wait for the chosen range; hold the selected endpoint response and verify Play is disabled until it applies. AC3 requires one historical read per session; existing source assertions check values but not reused reads. Extend the existing web-history test by temporarily removing Git access after loading snapshots, then request old code/source details and verify they still work. Browser Git archive logging with How open on two replays verifies one extraction per revision. 4. Reuse the minimum 50-commit browser fixture to verify order, range URL, final comparison/changes stepper, speed, Stop, replay and reduced-motion execution; run focused checks and bun run check. 5. Run one cold simplicity review, implement accepted local simplifications, then perform specification and quality review; run one final full-context complexity review. Read finalization instructions, record objective evidence without claiming unverified visual morph quality, curate any scanner-caused architecture files, and make the authorized local task commit. Regression refinement after self-review: the retained chosen endpoints must not replace the displayed revision getters used by render.ts for How/source and changes navigation. Authority is the existing displayed-frame detail flow and the required chosen-range/displayed-frame distinction. The wrong result would show final-range code while an intermediate frame is visible. The replay browser check asserts code requests name the displayed first snapshot, with no comparison endpoint, rather than the retained final range; later comparison frames render their embedded file changes. + +Maintainability pass (review findings A to H, 2026-10-11). It supersedes steps 2 and 3 where they say one archive per revision is retained for the whole server session. +A. The web host keeps at most a named, fixed number of commits extracted (four: the previous frame, the current one and the two the browser preloads). A commit nobody is reading and that was used least recently is closed when the limit is exceeded; a commit that failed to open is not kept. Replays are served by the browser frame cache. +B. /playback.json reads only the two endpoints and answers with every commit of the range, oldest first. The browser walk skips a commit the current reader cannot read and continues from the last readable frame. +C. One signal names an unreadable architecture: readSnapshot throws UnreadableArchitectureError, the host answers with UNREADABLE_REVISION_STATUS, the browser data source throws UnreadableRevisionError. Git and filesystem failures answer 500 and reach the person. +D. The host module moves out of the browser folder to src/viewers/web/revision-archives.ts. compareSnapshots in src/history/snapshots.ts owns the assembly of a comparison from two loaded snapshots; readComparison and the host both call it. +E. The per-revision source text cache is removed: a comparison reads the files from the two open archives, so no shared entry is edited by readers. The lazily added ordinary layout is declared in the entry's type. +F. The speed control reuses the header dropdown atom (details, anchored-popover, anchored-option, bindPopover). +G. docs/viewers/web/index.md links the playback page, which is brought in line with A to D. +H. Imports follow each file's grouping. +Tests (one new test in test-bun/web-revisions.test.ts): +1. Rule and authority: review decision A, a bounded number of open commits with least-recently-used closing and no kept failure; review finding C, only an unreadable architecture counts as unreadable; review decision B, the candidates are every commit of the range in Git order, oldest first. +2. Wrong results caught: every requested commit stays extracted until the session closes; a failed read is answered from the cache forever; a Git failure is reported with the unreadable status and playback would skip the commit silently; /playback.json reads or filters the middle of the range or answers newest first. +3. Coverage gap: the existing test opens three commits and only proves reuse, never closing, retry, the failure status or /playback.json. Smallest test: one repository with more commits than the limit and one unreadable commit in the middle, observed through the session's HTTP routes with Git hidden, as the existing reuse assertion does. +The browser walk has no DOM test harness in this repository; skipping an unreadable commit is verified with a headless Chromium run and recorded in the notes. ## Implementation Notes @@ -96,6 +116,24 @@ Implementer specification review: the three reported failures are fixed in the d Final verification after the endpoint-helper correction: bun run check passed (16 Node tests; 776 Bun tests, 51 skipped, zero failures), including TypeScript, repository lint and scrollbar checks. Changed-file Biome and git diff --check pass. Chromium stop/edit keeps Revision 1 when destination is reselected; holding a chosen destination response disables Play, then playback finishes at Revision 6. Two How-open replays use the displayed first snapshot for code reads and one extraction per loaded commit. The final 50-commit run completed in 21.6 seconds with every commit in Git order, one document navigation, unchanged range URL, no page errors, and working final changes bar/Next change. Request order proves the next two frames preload. Archive logging confirms exactly one extraction for each of all 50 revisions, and session close removed all 50 archives. The unreadable-middle fixture returns only its first/last readable commits. Final screenshots were inspected. Maximum animation-frame gap was 100.4 ms while the native full checks and another browser ran; no larger-map performance claim is made. Original AC2 visual morph quality and AC5 reduced-motion geometry remain unchecked; reduced-motion execution itself passed and static/TUI coverage passed in the full check. Artifacts: /private/tmp/groma-pr121-fixes/regressions-result.json, fifty-result.json, cache-final.json and check-final.log. Final full-context complexity review passed with no material recommendations. It confirmed clear separation of chosen endpoints from displayed frames, the existing navigation flag for Play, distinct browser/server cache responsibilities, archive cleanup by the web session, lazy ordinary layouts, and unchanged OKF/C4 concepts. Cold simplicity review passed; the only optional repeated endpoint checks were collapsed into dedicated endpoint helpers and one targeted re-review approved them. All reported PR #121 defects and requested simplifications are complete. Original visual AC2/AC5 and DoD1 remain unverified, so the task remains In Progress rather than asserting full original-feature acceptance. + +Maintainability pass on review findings A to H (2026-10-11). The Final Summary above predates this pass: its statements that one archive per revision is kept for the server session and that each of 50 revisions is extracted exactly once no longer hold and must be rewritten at finalization. + +What changed and why. +A. src/viewers/web/revision-archives.ts (moved from revision/history.ts) keeps OPEN_ARCHIVES = 4 commits extracted: the two a frame compares and the two the browser preloads. Each open commit counts its readers; when a reader finishes, the least recently used commits without readers are closed until four remain, and their folders are deleted in the background while close() waits for them. A commit that failed to open is dropped, so the next request tries again. The reader count is required, not optional: readSourceTexts reports a missing file as a deletion, so closing a folder under a running comparison returns a wrong comparison without any error. A scratch run of 49 concurrent comparisons returned 49 correct results with the count and many wrong added/removed results without it. +B. /playback.json (playbackResponse in map-session.ts) reuses comparedPair for the two commits, opens only those two, and answers with every commit of the range, oldest first. The walk in revision/playback.ts skips a commit whose frame answers with the unreadable signal and compares the next commit with the last one shown. +C. readSnapshot (src/history/snapshots.ts) wraps whatever the architecture reader throws in UnreadableArchitectureError. revisionError in map-session.ts answers that error with UNREADABLE_REVISION_STATUS (422, payload.ts) and every other failure with 500; data.ts turns the status into UnreadableRevisionError. Git and extraction failures therefore reach the person. The dead project === null check in payloadAt is removed. +D. compareSnapshots in src/history/snapshots.ts assembles every comparison from two loaded snapshots; readComparison and the archives module call it. The host module now sits beside map-session.ts and is named for what it holds. +E. The in-memory source text cache is gone: a comparison reads the files from the two open folders, so no stored entry is edited by a reader. The only field added after opening, the ordinary layout, is declared and documented on the Archive type. +F. The speed control is a details element with the shared anchored-popover and anchored-option atoms and bindPopover, like the theme, Help and Info menus. +G. docs/viewers/web/index.md links the playback page; the page describes the limit, the unreadable status, the browser skip and the one comparison owner. +H. revision/control.ts lists its local imports in order; map-session.ts imports the archives module in its place in the local group. +Architecture: the scan made a stand-alone revision-archives component for the moved file; it was combined into web-server, and the web-server and revision-control overviews were updated through groma edit. + +Verification (run by this pass). bun run typecheck passes. bunx biome lint: 1 warning and 2 infos, the same as before the pass, none in a changed file. bun scripts/lint-web-scrollbars.ts passes. bun test --timeout 20000 on web-revisions, web-export, revision-comparison, web-page-inset, web-sharing, web-startup: 22 pass, 0 fail. The new test fails on the previous code: /playback.json dropped the unreadable middle commit, and with that assertion removed the oldest commit was still served with Git hidden (200 instead of 500). The full bun run check was not run in this lane. +Headless Chromium 1600x1000, light theme, minimum fixtures. Fifty commits at 4x: all 50 captions in Git order in 19.4 s, range URL unchanged, changes bar shown at the end, no page error; /playback.json answered in 25 ms; 50 frame requests, and 0 frame requests on a replay. Host folders: at most 5 at once while playing, 4 before close, 0 after close. Extractions: 48 commits once; the end commit twice (once for the comparison shown before Play, once when the walk reaches it); the start commit a second time on the replay, because /playback.json opens both endpoints again. Unreadable middle commit: captions show only the first and last commit, no error, the map ends on the starting comparison. A frame answered with 500 stops playback and shows its message. Speed menu: opens, lists 0.5x 1x 2x 4x with the current one marked, closes on an outside click, is usable with Enter and Tab, returns focus to its button; screenshots inspected in light, dark and a 1000 px header. +Not verified: acceptance criteria 2 (morph quality) and 5 (reduced-motion geometry) stay unchecked. Criterion 3 says a revision is read once per session; after decision A that holds for the browser (each frame is requested once per session) but not for the host, which reads a closed commit again. +Observed, outside findings A to H and unchanged by this pass: in a comparison at 1280 px wide, with the fixture's 24-character project title, the header has no room for the revision fields beside Play and the speed button, and the controls overlap the fields (measured at 1000, 1100 and 1280 px; 1600 px fits). The unmodified pull request shows the same. ## Final Summary diff --git a/docs/viewers/web/history-playback.md b/docs/viewers/web/history-playback.md index 91b3d3d8..ff37b6a5 100644 --- a/docs/viewers/web/history-playback.md +++ b/docs/viewers/web/history-playback.md @@ -2,7 +2,7 @@ In the live web map, choose a commit, open **compare 2 revisions**, and choose an older start commit. **Play** walks the range from oldest to newest. The speed -selector offers 0.5×, 1×, 2×, and 4×; **Stop** leaves the current step visible. +menu offers 0.5×, 1×, 2×, and 4×; **Stop** leaves the current step visible. The caption shows that step's short commit ID, subject, and local date and time. The two revision fields keep the chosen range, including while editing an endpoint after Stop. Play waits for a chosen endpoint to finish loading. Its @@ -22,24 +22,49 @@ each frame immediately. When playback finishes, the original start-to-end comparison returns with its changes bar and stepper. A person can pan and zoom while playback continues. +## In the browser + [`revision/control.ts`](../../../src/viewers/web/revision/control.ts) owns the selected revision and connects the playback controls to the browser session. [`revision/playback.ts`](../../../src/viewers/web/revision/playback.ts) owns the -clock, cancellation, speed, caption, range, and a cache of requested frames. It -loads the next two frames ahead. Stopping invalidates pending frame applications -without discarding their cached data. - -The live [`map-session.ts`](../../../src/viewers/web/map-session.ts) serves -`/playback.json?from=&revision=` to identify readable frames. -[`revision/history.ts`](../../../src/viewers/web/revision/history.ts) shares -in-flight historical reads for that server session. Each commit is extracted -once; its architecture and owned source text are cached. Code outlines and -source details read that same immutable archive. Ordinary map layouts are -prepared only when a snapshot view is requested; comparisons prepare their own -layouts. Source files owned only on the other side are read and cached when -needed. Compatibility checks populate the same cache used by playback. Closing -the server session removes its archives. Working-tree comparisons continue to -read current files. +clock, cancellation, speed, caption, range, and a cache of the frames it has +requested, so a replay does not ask the host again. Stopping invalidates pending +frame applications without discarding their cached data. + +Play first asks `/playback.json?from=&revision=` for every +commit of the range, oldest first. The walk then requests one frame per commit +from `/world.json` and loads the next two frames ahead. A commit the current +reader cannot read is skipped, and the next frame is the comparison from the +last commit shown. Any other failure stops playback and shows its message. + +## In the web host + +[`map-session.ts`](../../../src/viewers/web/map-session.ts) serves both routes. +`/playback.json` reads only the two endpoints, so a long range answers at once. +A request that names a commit whose architecture the current reader cannot read +is answered with status 422 (`UNREADABLE_REVISION_STATUS` in +[`payload.ts`](../../../src/viewers/web/payload.ts)), and the browser data source +turns that status into `UnreadableRevisionError`. This status is the only signal +for an unreadable commit. A Git error, a full disk, or any other failure is +answered with status 500. + +[`revision-archives.ts`](../../../src/viewers/web/revision-archives.ts) opens the +commits. Opening one extracts the whole repository at that commit into a +temporary folder and reads its architecture. Once no request is reading, at +most four commits stay open (`OPEN_ARCHIVES`): the two a frame compares and the +two the browser preloads. Beyond that, the least recently used commit that no +request is reading is closed and its folder deleted. A commit that failed to +open is not kept, so the next request tries again. Code outlines and source details read the open +folder of their commit. An ordinary map layout is prepared on the first request +for a snapshot view and kept while the commit stays open; comparisons prepare +their own layouts. Closing the server session deletes the remaining folders. A +host process that is killed cannot delete the folders open at that moment; they +stay in the system's temporary directory, named `groma-revision-*`. + +`compareSnapshots` in +[`history/snapshots.ts`](../../../src/history/snapshots.ts) assembles every +comparison from two loaded snapshots. Working-tree comparisons call it through +`readComparison`, which reads current files and removes its archive afterwards. Playback is transient browser state, not stored architecture knowledge. It adds no OKF metadata or C4 element type. Git commits and the existing architecture diff --git a/docs/viewers/web/index.md b/docs/viewers/web/index.md index a65a9623..459dbad7 100644 --- a/docs/viewers/web/index.md +++ b/docs/viewers/web/index.md @@ -335,7 +335,8 @@ and the list disables every commit on the wrong side of the other endpoint. A li that names the newer commit as the start opens the same comparison, older first. The × ends the comparison and opens B. Ordinary browsing then opens either individual snapshot. The URL stores A in `from` and B in `revision` (omitted for -the working tree). +the working tree). In the live map, **Play** beside the fields walks the commits +from A to B; see [Play architecture history](history-playback.md). `web/revision/view.ts` owns the fields, the list rows, and their motion; `web/revision/control.ts` owns the displayed revision or pair and which field is the search. Motion uses the chrome motion variables and is off under reduced diff --git a/groma/systems/groma-md/containers/cli/components/web-server.md b/groma/systems/groma-md/containers/cli/components/web-server.md index f1773637..aef619f6 100644 --- a/groma/systems/groma-md/containers/cli/components/web-server.md +++ b/groma/systems/groma-md/containers/cli/components/web-server.md @@ -18,13 +18,13 @@ groma: - scanner: typescript file: src/viewers/web/startup/scanners.ts - scanner: typescript - file: src/viewers/web/revision/history.ts - symbol: createRevisionHistory + file: src/viewers/web/revision-archives.ts + symbol: createRevisionArchives group: Browser delivery description: Serves the local browser map and live architecture operations --- -Starts the local HTTP server. Serves project setup and connects the browser to architecture, scanner, task, and source operations. Keeps one immutable archive per historical revision for the server session, shares its architecture and source data between comparisons and historical details, and removes the archives when the session closes. Identifies readable playback frames and prepares an ordinary snapshot layout only when that view is requested. +Starts the local HTTP server. Serves project setup and connects the browser to architecture, scanner, task, and source operations. Extracts each historical commit the browser asks for into a temporary folder and keeps only the few most recently used commits open, so comparisons, playback frames, and historical details share one extraction without filling the disk. The folders are deleted when the session closes. Lists the commits of a playback range, and answers with a status of its own when the current reader cannot read a commit, so the browser can skip it. ## Relationships diff --git a/groma/systems/groma-md/containers/export/components/revision-control.md b/groma/systems/groma-md/containers/export/components/revision-control.md index 5bcfda37..5e5a8983 100644 --- a/groma/systems/groma-md/containers/export/components/revision-control.md +++ b/groma/systems/groma-md/containers/export/components/revision-control.md @@ -18,7 +18,7 @@ groma: description: Browses, compares and plays readable Git architecture revisions --- -Owns the selected revision or comparison and the commit search fields. Plays a selected commit range in Git order with speed and Stop controls, shows the current commit, preloads upcoming frames, and finishes on the full-range comparison. The chosen endpoints remain separate from the displayed playback frame, so editing either endpoint after Stop keeps the other choice. Play waits for endpoint navigation to finish. The browser keeps the selected range in its URL and uses the existing map motion for each step. +Owns the selected revision or comparison and the commit search fields. Plays a selected commit range in Git order with speed and Stop controls, skips commits the current reader cannot read, shows the current commit, preloads upcoming frames, and finishes on the full-range comparison. The chosen endpoints remain separate from the displayed playback frame, so editing either endpoint after Stop keeps the other choice. Play waits for endpoint navigation to finish. The browser keeps the selected range in its URL and uses the existing map motion for each step. ## Relationships diff --git a/src/history/revisions.ts b/src/history/revisions.ts index 6f646b19..19c830ab 100644 --- a/src/history/revisions.ts +++ b/src/history/revisions.ts @@ -235,7 +235,7 @@ async function withGitTree( finally { await snapshot.close() } } -/** Opens an immutable revision for a session; its owner must close it when the session ends. */ +/** Extracts a complete commit into a temporary folder and leaves it there; the caller must `close` it to delete the folder. */ export function openGitRevision(repositoryRoot: string, revisionId: string) { return openGitTree(repositoryRoot, revisionId, []) } diff --git a/src/history/snapshots.ts b/src/history/snapshots.ts index 6535d2c2..ea4e7d2e 100644 --- a/src/history/snapshots.ts +++ b/src/history/snapshots.ts @@ -1,20 +1,45 @@ import { readFile } from 'node:fs/promises' import path from 'node:path' import { loadAnnotatedArchitecture } from '../core.ts' -import { loadProjectProfile } from '../project-profile.ts' +import { loadProjectProfile, type ProjectProfile } from '../project-profile.ts' +import type { AnnotatedArchitectureModel } from '../types.ts' import type { GitRevision } from './revisions.ts' import { withGitRevision } from './revisions.ts' import { compareArchitecture, ownedFiles, type SourceTexts } from './comparison.ts' +/** The architecture of one working tree or commit, with the folder its files are read from. */ +export interface Snapshot { + root: string + world: AnnotatedArchitectureModel + project: ProjectProfile +} + +/** + * The architecture reader failed in a folder: usually the folder holds no architecture, or one that follows an older + * Markdown contract. Extracting a commit into the folder is a separate step, so a Git error or a full disk while + * extracting is not this error. + */ +export class UnreadableArchitectureError extends Error { + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause), { cause }) + this.name = 'UnreadableArchitectureError' + } +} + /** A working tree stays in place; a commit is read in an isolated temporary archive. */ export function atRevision(root: string, revision: GitRevision | null, read: (root: string) => Promise): Promise { return revision === null ? read(root) : withGitRevision(root, revision.id, read) } -export async function readSnapshot(root: string) { - const [world, project] = await Promise.all([loadAnnotatedArchitecture(root), loadProjectProfile(root)]) - if (project === undefined) throw new Error('No Groma architecture in this revision') - return { world, project } +/** Whatever the architecture reader throws in this folder is reported as UnreadableArchitectureError. */ +export async function readSnapshot(root: string): Promise { + try { + const [world, project] = await Promise.all([loadAnnotatedArchitecture(root), loadProjectProfile(root)]) + if (project === undefined) throw new Error('No Groma architecture in this revision') + return { root, world, project } + } catch (error) { + throw new UnreadableArchitectureError(error) + } } /** Missing files are meaningful deletions; other read failures remain visible. */ @@ -28,13 +53,21 @@ export async function readSourceTexts(root: string, files: string[]): Promise atRevision(root, to, async afterRoot => { const [before, after] = await Promise.all([readSnapshot(beforeRoot), readSnapshot(afterRoot)]) - const files = [...new Set([...ownedFiles(before.world), ...ownedFiles(after.world)])] - const [oldSources, newSources] = await Promise.all([readSourceTexts(beforeRoot, files), readSourceTexts(afterRoot, files)]) - const { world, components, relationships } = compareArchitecture(before.world, after.world, oldSources, newSources) - return { project: after.project, world, comparison: { from, components, relationships } } + return compareSnapshots(from, before, after) })) } diff --git a/src/viewers/web/data.ts b/src/viewers/web/data.ts index e3f0f7e3..b76cce22 100644 --- a/src/viewers/web/data.ts +++ b/src/viewers/web/data.ts @@ -1,7 +1,7 @@ import type { ScannerSettings, ScannerSettingsAction } from '../../scanner/modules/settings-model.ts' import type { AcceptInput, AddInput, DraftInput, EditArchitectureInput, RemoveInput } from '../../authoring.ts' import type { WorkItemDetails } from '../../types.ts' -import { PUBLISHED_EVENT, PUBLISHED_VERSION_EVENT } from './payload.ts' +import { PUBLISHED_EVENT, PUBLISHED_VERSION_EVENT, UNREADABLE_REVISION_STATUS } from './payload.ts' import type { WebBootPayload, WebPayload, WebRevision, WebWorkPayload } from './payload.ts' import type { SourcePayload } from '../source/read.ts' import type { CodeFile } from '../source/structure.ts' @@ -13,7 +13,10 @@ export interface WebDataSource { onScanners?: (state: ScannerSettings) => void readWorld(revision?: string, from?: string): Promise readRevisions(): Promise - /** Live-only compatible commits, ordered from the selected start to end. */ + /** + * Live only. Every commit from `from` to `revision`, oldest first. Some of them may hold an architecture the + * current reader cannot read: `readWorld` then throws UnreadableRevisionError and playback skips the commit. + */ readPlaybackRevisions?(revision: string, from: string): Promise readCode(element: string, revision?: string, from?: string): Promise readSource(element: string, file: string, revision?: string, from?: string): Promise @@ -31,8 +34,12 @@ export interface WebDataSource { }): { close(): void } } +/** A read named a commit whose architecture the current reader cannot read. */ +export class UnreadableRevisionError extends Error {} + async function responseJson(path: string): Promise { const response = await fetch(path) + if (response.status === UNREADABLE_REVISION_STATUS) throw new UnreadableRevisionError(await response.text()) if (!response.ok) throw new Error(await response.text()) return response.json() as Promise } diff --git a/src/viewers/web/map-session.ts b/src/viewers/web/map-session.ts index 4b4a3930..684ad7c8 100644 --- a/src/viewers/web/map-session.ts +++ b/src/viewers/web/map-session.ts @@ -11,12 +11,13 @@ import { createScannerSession } from '../../scanner/session.ts' import { parseScannerSettingsAction, withScannerUpgrades } from '../../scanner/modules/settings.ts' import { pinsOf } from '../../work/pins.ts' import { ownedFiles } from '../../history/comparison.ts' -import { readComparison } from '../../history/snapshots.ts' +import { readComparison, UnreadableArchitectureError } from '../../history/snapshots.ts' import { measuredSheetScene } from '../../sheet/scene.ts' import { listGitRevisions } from '../../history/revisions.ts' import { renderPage } from './page.ts' -import { createRevisionHistory } from './revision/history.ts' +import { UNREADABLE_REVISION_STATUS } from './payload.ts' import type { WebMapPayload, WebPayload, WebRevision, WebWorkPayload } from './payload.ts' +import { createRevisionArchives } from './revision-archives.ts' import { bundleRenderer, loadMapRoot } from './runtime.ts' import { coverThemes, generateCovers, type CoverImages } from './sharing/images.ts' import { coverFile } from './sharing/metadata.ts' @@ -29,12 +30,12 @@ async function structureResponse( repositoryRoot: string, selected: Pick, element: string, - history: ReturnType, + archives: ReturnType, ): Promise { try { const structure = selected.revision === null ? await readCodeStructure(repositoryRoot, selected.world, null, element) - : await history.structure(selected.revision, selected.world, element) + : await archives.structure(selected.revision, selected.world, element) return structure === undefined ? new Response('Component not found', { status: 404 }) : Response.json(structure) @@ -48,13 +49,13 @@ async function sourceResponse( selected: Pick, element: string, file: string | null, - history: ReturnType, + archives: ReturnType, ): Promise { if (file === null) return new Response('Source selection required', { status: 400 }) try { const source = selected.revision === null ? await readSource(repositoryRoot, selected.world, null, element, file) - : await history.source(selected.revision, selected.world, element, file) + : await archives.source(selected.revision, selected.world, element, file) return source === undefined ? new Response('Source file not found', { status: 404 }) : Response.json(source) @@ -79,7 +80,7 @@ export async function createWebMapSession( ): Promise<{ fetch: (request: Request) => Promise; close: () => Promise }> { options.onProgress?.({ phase: 'preparing-viewer' }) const renderer = await bundleRenderer() - const history = createRevisionHistory(repositoryRoot) + const archives = createRevisionArchives(repositoryRoot) const workSource = options.workSource ?? backlogPlugin.create(repositoryRoot) let revisions: WebRevision[] = [] let revisionRead: Promise | undefined @@ -125,18 +126,18 @@ export async function createWebMapSession( const revision = (await readRevisions()).find(candidate => candidate.id === revisionId) if (revision === undefined) return new Response('Unknown Groma revision', { status: 404 }) try { - const stored = await history.view(revision) - const snapshot = { ...stored, revision, revisions } - if (snapshot.project === null) throw new Error('No Groma architecture in this commit') + const view = await archives.view(revision) return { generation: map.generation, - ...snapshot, + ...view, + revision, + revisions, workGeneration: workState.workGeneration, work: EMPTY_WORK_SNAPSHOT, pins: [], } } catch (error) { - return new Response(`Cannot open this revision: ${error instanceof Error ? error.message : String(error)}`, { status: 422 }) + return revisionError('Cannot open this revision', error) } } @@ -164,7 +165,7 @@ export async function createWebMapSession( /** Both revisions' architecture and the changes between them, without laying out the map. */ async function compare(from: WebRevision | null, to: WebRevision | null) { const compared = from !== null && to !== null - ? await history.compare(from, to) + ? await archives.compare(from, to) : await readComparison(repositoryRoot, from, to) for (const component of Object.values(compared.comparison.components)) { for (const { file } of component.files) sourceFiles.add(file) @@ -172,8 +173,13 @@ export async function createWebMapSession( return compared } - function comparisonError(error: unknown): Response { - return new Response(`Cannot compare these revisions: ${error instanceof Error ? error.message : String(error)}`, { status: 422 }) + /** + * Only an architecture the current reader cannot read answers with UNREADABLE_REVISION_STATUS, which playback skips. + * Every other failure, such as a Git error or a full disk, is a server error the person sees. + */ + function revisionError(action: string, error: unknown): Response { + const status = error instanceof UnreadableArchitectureError ? UNREADABLE_REVISION_STATUS : 500 + return new Response(`${action}: ${error instanceof Error ? error.message : String(error)}`, { status }) } async function comparisonAt(from: WebRevision | null, to: WebRevision | null): Promise { @@ -186,7 +192,7 @@ export async function createWebMapSession( workGeneration: workState.workGeneration, work: EMPTY_WORK_SNAPSHOT, pins: [], timings: { architectureLoadMilliseconds: loaded - started, ...sheet.timings, totalMilliseconds: performance.now() - started } } } catch (error) { - return comparisonError(error) + return revisionError('Cannot compare these revisions', error) } } @@ -199,7 +205,7 @@ export async function createWebMapSession( const { world, comparison } = await compare(...pair) return { world, revision: pair[1], comparison } } catch (error) { - return comparisonError(error) + return revisionError('Cannot compare these revisions', error) } } @@ -218,8 +224,8 @@ export async function createWebMapSession( : { ...item, code: [...(change.after?.code ?? []), ...(change.before?.code ?? [])] }) } } return url.pathname === '/code.json' - ? structureResponse(repositoryRoot, selected, element, history) - : sourceResponse(repositoryRoot, selected, element, file, history) + ? structureResponse(repositoryRoot, selected, element, archives) + : sourceResponse(repositoryRoot, selected, element, file, archives) } function worldEvent(): Uint8Array { @@ -323,6 +329,24 @@ export async function createWebMapSession( return sourceSelection(url) } + /** + * Every commit a playback walks, oldest first. Only the two endpoints are read here, so a long range answers at + * once; the browser skips a commit in between that the current reader cannot read when it reaches it. + */ + async function playbackResponse(_request: Request, url: URL): Promise { + const pair = await comparedPair(url) + if (pair instanceof Response) return pair + const [from, to] = pair ?? [null, null] + if (from === null || to === null) return new Response('Playback runs between two commits', { status: 400 }) + try { + await Promise.all([archives.requireReadable(from), archives.requireReadable(to)]) + } catch (error) { + return revisionError('Cannot play these revisions', error) + } + // The history list runs newest first. + return Response.json(revisions.slice(revisions.indexOf(to), revisions.indexOf(from) + 1).reverse()) + } + async function taskDiffResponse(_request: Request, url: URL): Promise { const taskId = url.searchParams.get('task') const item = workState.work.items.find(candidate => candidate.id === taskId) @@ -405,12 +429,7 @@ export async function createWebMapSession( }], ['/render.js', rendererResponse], ['/revisions.json', async () => Response.json(await readRevisions())], - ['/playback.json', async (_request, url) => { - try { - return Response.json(await history.range(await readRevisions(), - url.searchParams.get('from') ?? '', url.searchParams.get('revision') ?? '')) - } catch (error) { return comparisonError(error) } - }], + ['/playback.json', playbackResponse], ['/world.json', worldResponse], ['/code.json', selectedSourceResponse], ['/source.json', selectedSourceResponse], @@ -466,7 +485,7 @@ export async function createWebMapSession( worldChain, workChain, revisionRead, - history.close(), + archives.close(), covers, ]) }, diff --git a/src/viewers/web/payload.ts b/src/viewers/web/payload.ts index e58f1a8c..acc9f543 100644 --- a/src/viewers/web/payload.ts +++ b/src/viewers/web/payload.ts @@ -10,6 +10,12 @@ import type { CodeFile } from '../source/structure.ts' export const PUBLISHED_EVENT = 'groma:published' export const PUBLISHED_VERSION_EVENT = 'groma:published-version' +/** + * The live host answers a read with this status when the current reader cannot read the architecture of a commit the + * request names. It is the one signal for that case: playback skips such a commit, and reports every other failure. + */ +export const UNREADABLE_REVISION_STATUS = 422 + export type WebRevision = GitRevision export interface WebMapTimings { diff --git a/src/viewers/web/revision-archives.ts b/src/viewers/web/revision-archives.ts new file mode 100644 index 00000000..c8c1d9cc --- /dev/null +++ b/src/viewers/web/revision-archives.ts @@ -0,0 +1,153 @@ +import { openGitRevision } from '../../history/revisions.ts' +import { compareSnapshots, readSnapshot, type Snapshot } from '../../history/snapshots.ts' +import { measuredSheetScene } from '../../sheet/scene.ts' +import type { ArchitectureGraph } from '../../types.ts' +import { readSource } from '../source/read.ts' +import { readSnapshotCodeStructure } from '../source/structure.ts' +import type { WebMapPayload, WebRevision } from './payload.ts' + +/** + * How many commits stay extracted once the requests reading them have finished. Playback needs four: a frame + * compares the previous commit with the current one, and the browser preloads the next two frames. + */ +const OPEN_ARCHIVES = 4 + +type SnapshotView = Pick + +/** One commit, extracted into a temporary folder and read by the current architecture reader. */ +interface Archive extends Snapshot { + architectureLoadMilliseconds: number + /** The commit's ordinary map. The first request for it stores it here; a comparison lays out its own map. */ + view?: SnapshotView + /** Deletes the temporary folder. */ + close(): Promise +} + +/** A commit that is open or still being opened. */ +interface Entry { + opened: Promise + /** How many requests are reading the folder right now. Only a folder without readers is deleted to make room. */ + readers: number +} + +function layOut(archive: Archive): SnapshotView { + const sheet = measuredSheetScene(archive.world) + return { project: archive.project, world: archive.world, sheet: sheet.scene, + timings: { architectureLoadMilliseconds: archive.architectureLoadMilliseconds, ...sheet.timings, + totalMilliseconds: archive.architectureLoadMilliseconds + sheet.timings.placementMilliseconds + sheet.timings.routingMilliseconds } } +} + +/** + * Runs in the web host, never in the browser. Opening a commit extracts the whole repository at that commit into a + * temporary folder, so every commit a person browses or plays costs disk space and memory. Only the OPEN_ARCHIVES + * most recently used commits therefore stay open. The browser keeps the frames it has received (revision/playback.ts), + * so replaying a range does not ask for them again. + */ +export function createRevisionArchives(repositoryRoot: string) { + /** By commit id, least recently used first. */ + const entries = new Map() + /** Folders being deleted; `close` waits for them. */ + const removals = new Set>() + + async function open(revision: WebRevision): Promise { + const started = performance.now() + const { root, close } = await openGitRevision(repositoryRoot, revision.id) + try { + return { ...await readSnapshot(root), close, architectureLoadMilliseconds: performance.now() - started } + } catch (error) { + await close() + throw error + } + } + + /** Deletes the folder in the background, so the request that needed the room does not wait for it. */ + function remove(entry: Entry): void { + // A commit that failed to open has already deleted its folder. + const removal = entry.opened.then(archive => archive.close(), () => {}) + removals.add(removal) + // A deletion that fails stays in the set, so `close` reports it. + void removal.then(() => removals.delete(removal), () => {}) + } + + /** + * Counts one more reader of the commit and starts opening it when it is not open. Every `hold` needs one `release`; + * use `withArchive`, which pairs them, unless two commits must be open at the same time. + */ + function hold(revision: WebRevision): Entry { + let entry = entries.get(revision.id) + if (entry === undefined) { + const opening: Entry = { opened: open(revision), readers: 0 } + // A commit that failed to open is not kept, so the next request tries again. + void opening.opened.catch(() => { if (entries.get(revision.id) === opening) entries.delete(revision.id) }) + entry = opening + } + // Inserting again moves the commit to the most recently used end. + entries.delete(revision.id) + entries.set(revision.id, entry) + entry.readers++ + return entry + } + + /** Ends one `hold`, then closes the least recently used commits nobody is reading until OPEN_ARCHIVES remain. */ + function release(held: Entry): void { + held.readers-- + for (const [id, entry] of entries) { + if (entries.size <= OPEN_ARCHIVES) return + if (entry.readers > 0) continue + entries.delete(id) + remove(entry) + } + } + + /** Runs `read` on the open commit; its folder exists until `read` settles. */ + async function withArchive(revision: WebRevision, read: (archive: Archive) => T | Promise): Promise { + const entry = hold(revision) + try { + return await read(await entry.opened) + } finally { + release(entry) + } + } + + /** The commit's ordinary map, laid out once while the commit stays open. */ + function view(revision: WebRevision): Promise { + return withArchive(revision, archive => { + archive.view ??= layOut(archive) + return archive.view + }) + } + + /** The changes from one commit to a newer one. Both are opened at the same time and held until their files are read. */ + async function compare(from: WebRevision, to: WebRevision) { + const older = hold(from) + const newer = hold(to) + try { + return await compareSnapshots(from, await older.opened, await newer.opened) + } finally { + release(older) + release(newer) + } + } + + function structure(revision: WebRevision, world: ArchitectureGraph, element: string) { + return withArchive(revision, archive => readSnapshotCodeStructure(repositoryRoot, archive.root, world, element)) + } + + function source(revision: WebRevision, world: ArchitectureGraph, element: string, file: string) { + return withArchive(revision, archive => readSource(archive.root, world, null, element, file)) + } + + /** Rejects unless the commit can be opened and the current reader can read its architecture. */ + function requireReadable(revision: WebRevision): Promise { + return withArchive(revision, () => {}) + } + + /** Deletes every folder, including the ones still being deleted in the background. */ + async function close(): Promise { + for (const entry of entries.values()) remove(entry) + entries.clear() + await Promise.all(removals) + } + + return { view, compare, structure, source, requireReadable, close } +} diff --git a/src/viewers/web/revision/control.ts b/src/viewers/web/revision/control.ts index 001ee5f1..6b224f81 100644 --- a/src/viewers/web/revision/control.ts +++ b/src/viewers/web/revision/control.ts @@ -1,8 +1,8 @@ import type { WebDataSource } from '../data.ts' import { bindPopover } from '../atoms/popover.ts' import type { WebBootPayload, WebPayload, WebWorkPayload } from '../payload.ts' -import { NARROW_HEADER, pendingPairFields, revisionFields, revisionOptions, snapshotNotice, type RevisionField } from './view.ts' import { createRevisionPlayback } from './playback.ts' +import { NARROW_HEADER, pendingPairFields, revisionFields, revisionOptions, snapshotNotice, type RevisionField } from './view.ts' interface RevisionControlOptions { box: HTMLElement diff --git a/src/viewers/web/revision/history.ts b/src/viewers/web/revision/history.ts deleted file mode 100644 index 99b4ce6c..00000000 --- a/src/viewers/web/revision/history.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { compareArchitecture, ownedFiles } from '../../../history/comparison.ts' -import { openGitRevision } from '../../../history/revisions.ts' -import { readSnapshot, readSourceTexts } from '../../../history/snapshots.ts' -import { measuredSheetScene } from '../../../sheet/scene.ts' -import type { ArchitectureGraph } from '../../../types.ts' -import { readSource } from '../../source/read.ts' -import { readSnapshotCodeStructure } from '../../source/structure.ts' -import type { WebMapPayload, WebRevision } from '../payload.ts' - -/** Historical architecture is immutable: share each in-flight read for the lifetime of the web session. */ -export function createRevisionHistory(repositoryRoot: string) { - const snapshots = new Map>() - - async function read(revision: WebRevision) { - const started = performance.now() - const archive = await openGitRevision(repositoryRoot, revision.id) - try { - const model = await readSnapshot(archive.root) - const sources = await readSourceTexts(archive.root, ownedFiles(model.world)) - return { ...archive, ...model, sources, architectureLoadMilliseconds: performance.now() - started, - view: undefined as Pick | undefined } - } catch (error) { - await archive.close() - throw error - } - } - - function snapshot(revision: WebRevision) { - let pending = snapshots.get(revision.id) - if (pending === undefined) { - pending = read(revision) - snapshots.set(revision.id, pending) - } - return pending - } - - /** Comparisons and compatibility checks need the model; only an ordinary view needs this layout. */ - async function view(revision: WebRevision) { - const stored = await snapshot(revision) - if (stored.view === undefined) { - const sheet = measuredSheetScene(stored.world) - stored.view = { project: stored.project, world: stored.world, sheet: sheet.scene, - timings: { architectureLoadMilliseconds: stored.architectureLoadMilliseconds, ...sheet.timings, - totalMilliseconds: stored.architectureLoadMilliseconds + sheet.timings.placementMilliseconds + sheet.timings.routingMilliseconds } } - } - return stored.view - } - - // A comparison also needs files that were present but not owned on the other side. - async function sourcesAt(revision: WebRevision, files: string[]) { - const stored = await snapshot(revision) - const missing = files.filter(file => !Object.hasOwn(stored.sources, file)) - Object.assign(stored.sources, await readSourceTexts(stored.root, missing)) - return stored.sources - } - - async function structure(revision: WebRevision, world: ArchitectureGraph, element: string) { - const stored = await snapshot(revision) - return readSnapshotCodeStructure(repositoryRoot, stored.root, world, element) - } - - async function source(revision: WebRevision, world: ArchitectureGraph, element: string, file: string) { - const stored = await snapshot(revision) - return readSource(stored.root, world, null, element, file) - } - - async function compare(from: WebRevision, to: WebRevision) { - const [before, after] = await Promise.all([snapshot(from), snapshot(to)]) - const files = [...new Set([...ownedFiles(before.world), ...ownedFiles(after.world)])] - const [oldSources, newSources] = await Promise.all([sourcesAt(from, files), sourcesAt(to, files)]) - const { world, components, relationships } = compareArchitecture(before.world, after.world, oldSources, newSources) - return { project: after.project, world, comparison: { from, components, relationships } } - } - - /** Git's newest-first list defines the same ordering as the revision picker, never commit timestamps. */ - async function range(revisions: WebRevision[], from: string, to: string): Promise { - const start = revisions.findIndex(revision => revision.id === from) - const end = revisions.findIndex(revision => revision.id === to) - if (end < 0 || start <= end) throw new Error('Choose an older start commit and a newer end commit') - const ordered = revisions.slice(end, start + 1).reverse() - const compatible: WebRevision[] = [] - for (const revision of ordered) { - try { - await snapshot(revision) - compatible.push(revision) - } catch { - // An older Markdown contract is not a frame the current reader can play. - } - } - if (compatible[0]?.id !== from || compatible.at(-1)?.id !== to) { - throw new Error('Both playback endpoints must have readable Groma architecture') - } - return compatible - } - - async function close(): Promise { - await Promise.all([...snapshots.values()].map(pending => pending.then(stored => stored.close(), () => {}))) - } - - return { view, compare, range, structure, source, close } -} diff --git a/src/viewers/web/revision/playback.ts b/src/viewers/web/revision/playback.ts index 2fc5eee9..3f908604 100644 --- a/src/viewers/web/revision/playback.ts +++ b/src/viewers/web/revision/playback.ts @@ -1,4 +1,5 @@ -import type { WebDataSource } from '../data.ts' +import { UnreadableRevisionError, type WebDataSource } from '../data.ts' +import { bindPopover } from '../atoms/popover.ts' import type { WebPayload, WebRevision } from '../payload.ts' interface PlaybackOptions { @@ -11,36 +12,65 @@ interface PlaybackOptions { error(reason: unknown): void } +const SPEEDS = [0.5, 1, 2, 4] + +/** The speed choice, built from the header's dropdown atom (atoms/popover.ts) like the theme menu. */ +function speedControl(onChange: () => void) { + let speed = 1 + const menu = document.createElement('details') + menu.className = 'revision-speed' + const label = document.createElement('summary') + label.className = 'chrome-button' + label.setAttribute('aria-label', 'Playback speed') + const list = document.createElement('div') + list.className = 'anchored-popover' + const options = SPEEDS.map(value => { + const option = document.createElement('button') + option.type = 'button' + option.className = 'anchored-option' + option.textContent = `${value}×` + option.addEventListener('click', () => { + speed = value + paint() + menu.open = false + label.focus() + onChange() + }) + return option + }) + function paint(): void { + label.textContent = `${speed}×` + for (const [index, option] of options.entries()) option.setAttribute('aria-current', String(SPEEDS[index] === speed)) + } + list.append(...options) + menu.append(label, list) + bindPopover(menu) + paint() + return { element: menu, get value() { return speed } } +} + /** Plays immutable revision comparisons; the revision control continues to own the displayed payload. */ export function createRevisionPlayback(options: PlaybackOptions) { const { root, data } = options + let range: { from: WebRevision; to: WebRevision } | undefined + let running = false + let generation = 0 + let wake: (() => void) | undefined + const cache = new Map>() const control = document.createElement('span') control.className = 'revision-playback' control.hidden = true const button = document.createElement('button') button.type = 'button' button.className = 'chrome-button' - const speed = document.createElement('select') - speed.className = 'chrome-button' - speed.setAttribute('aria-label', 'Playback speed') - for (const value of [0.5, 1, 2, 4]) { - const option = document.createElement('option') - option.value = String(value) - option.textContent = `${value}×` - option.selected = value === 1 - speed.append(option) - } + // A new speed applies at once: the step that is waiting ends. + const speed = speedControl(() => wake?.()) const caption = document.createElement('span') caption.className = 'revision-playback-caption' caption.hidden = true caption.setAttribute('role', 'status') - control.append(button, speed, caption) + control.append(button, speed.element, caption) root.append(control) - let range: { from: WebRevision; to: WebRevision } | undefined - let running = false - let generation = 0 - let wake: (() => void) | undefined - const cache = new Map>() const keyOf = (revision: string, from?: string) => JSON.stringify([revision, from]) function remember(payload: WebPayload): void { @@ -54,7 +84,7 @@ export function createRevisionPlayback(options: PlaybackOptions) { if (pending === undefined) { pending = data.readWorld(revision, from) cache.set(key, pending) - // Prefetched errors are reported only if playback reaches that step. + // A preloaded frame that fails is handled only when playback reaches that step. void pending.catch(() => {}) } return pending @@ -84,7 +114,7 @@ export function createRevisionPlayback(options: PlaybackOptions) { function pause(): Promise { return new Promise(resolve => { - const timer = window.setTimeout(done, 1500 / Number(speed.value)) + const timer = window.setTimeout(done, 1500 / speed.value) function done(): void { window.clearTimeout(timer); wake = undefined; resolve() } wake = done }) @@ -96,6 +126,7 @@ export function createRevisionPlayback(options: PlaybackOptions) { caption.hidden = false } + /** Loads the next two frames ahead. It assumes each commit follows a readable one; a wrong guess costs one request. */ function preload(revisions: WebRevision[], index: number): void { for (const next of [index + 1, index + 2]) { const revision = revisions[next] @@ -103,14 +134,28 @@ export function createRevisionPlayback(options: PlaybackOptions) { } } + /** The frame that shows `revision` after `shown`, or undefined when the current reader cannot read that commit. */ + async function frame(revision: WebRevision, shown: WebRevision | undefined): Promise { + try { + return await read(revision.id, shown?.id) + } catch (reason) { + if (reason instanceof UnreadableRevisionError) return undefined + throw reason + } + } + + /** Shows the range in Git order. An unreadable commit is skipped: the next frame continues from the last one shown. */ async function walk(revisions: WebRevision[], token: number): Promise { + let shown: WebRevision | undefined for (let index = 0; index < revisions.length; index++) { preload(revisions, index) const revision = revisions[index]! - const payload = await read(revision.id, revisions[index - 1]?.id) + const payload = await frame(revision, shown) if (token !== generation) return false + if (payload === undefined) continue describe(revision) - options.present(payload, index === 0) + options.present(payload, shown === undefined) + shown = revision await pause() if (token !== generation) return false } @@ -144,7 +189,6 @@ export function createRevisionPlayback(options: PlaybackOptions) { } button.addEventListener('click', () => { if (running) stop(); else void play() }) - speed.addEventListener('change', () => wake?.()) return { paint, remember, diff --git a/src/viewers/web/revision/view.ts b/src/viewers/web/revision/view.ts index 4ecf17ac..08c4cf1a 100644 --- a/src/viewers/web/revision/view.ts +++ b/src/viewers/web/revision/view.ts @@ -66,7 +66,9 @@ export const revisionCss = ` #end-comparison { margin-left: 4px; font-size: 18px; padding: 6px 9px; animation: revision-fade-in ${motion} both; } .revision-playback { display: inline-flex; align-items: center; gap: 4px; margin-left: 4px; flex: none; } .revision-playback[hidden], .revision-playback-caption[hidden] { display: none; } - .revision-playback select { width: 54px; padding: 4px; } + .revision-speed { position: relative; --popover-width: 72px; } + .revision-speed > summary { list-style: none; } + .revision-speed > summary::-webkit-details-marker { display: none; } .revision-playback-caption { position: absolute; top: calc(100% + 8px); left: 0; z-index: 10; max-width: min(560px, 70vw); padding: 7px 10px; border: 1px solid var(--hairline); diff --git a/test-bun/web-revisions.test.ts b/test-bun/web-revisions.test.ts index 1eb898a0..58910ad5 100644 --- a/test-bun/web-revisions.test.ts +++ b/test-bun/web-revisions.test.ts @@ -8,9 +8,23 @@ import { createWebMapSession } from '../src/viewers/web/map-session.ts' import type { WebPayload, WebRevision } from '../src/viewers/web/payload.ts' import { listGromaRevisions } from '../src/history/revisions.ts' +const fixture = path.resolve(import.meta.dir, '../test/fixtures/source-view') + +function gitIn(root: string) { + return (...args: string[]) => execFileSync('git', ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', ...args], { cwd: root, encoding: 'utf8' }).trim() +} + +function openSession(root: string) { + return createWebMapSession(root, { scan: false, workSource: { + async read() { return EMPTY_WORK_SNAPSHOT }, + async readItem() { throw new Error('No task') }, + watch() { return { close() {} } }, + } }) +} + test.concurrent('web history includes source-only commits and reads the selected snapshot without changing checkout', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'groma-web-revisions-')) - const git = (...args: string[]) => execFileSync('git', ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', ...args], { cwd: root, encoding: 'utf8' }).trim() + const git = gitIn(root) let session: Awaited> | undefined try { git('init', '--quiet') @@ -19,7 +33,7 @@ test.concurrent('web history includes source-only commits and reads the selected git('add', '.') git('commit', '--quiet', '-m', 'Before architecture') const unsupported = git('rev-parse', 'HEAD') - await cp(path.resolve(import.meta.dir, '../test/fixtures/source-view'), root, { recursive: true }) + await cp(fixture, root, { recursive: true }) git('add', '.') git('commit', '--quiet', '-m', 'Architecture snapshot') const before = git('rev-parse', 'HEAD') @@ -32,11 +46,7 @@ test.concurrent('web history includes source-only commits and reads the selected const terminalHistory = await listGromaRevisions(root) expect(terminalHistory.map(revision => revision.id)).toEqual([before]) expect(terminalHistory[0]?.compatible).toBe(true) - session = await createWebMapSession(root, { scan: false, workSource: { - async read() { return EMPTY_WORK_SNAPSHOT }, - async readItem() { throw new Error('No task') }, - watch() { return { close() {} } }, - } }) + session = await openSession(root) const request = (url: string) => session!.fetch(new Request(`http://localhost${url}`)) const revisions = await (await request('/revisions.json')).json() as WebRevision[] expect(revisions.map(revision => revision.id)).toEqual([after, before, unsupported]) @@ -61,7 +71,7 @@ test.concurrent('web history includes source-only commits and reads the selected const pairedSource = await (await request(`/source.json?${query}&from=${before}&revision=${after}`)).json() expect(pairedSource.source).toBe(source) expect((await request(`/world.json?from=${after}&revision=${after}`)).status).toBe(400) - // Loaded revisions must also serve historical details without reading Git again. + // A commit that is still extracted serves historical details without reading Git again. const gitDirectory = path.join(root, '.git') const heldGitDirectory = path.join(root, '.cached-git') await rename(gitDirectory, heldGitDirectory) @@ -91,3 +101,46 @@ test.concurrent('web history includes source-only commits and reads the selected await rm(root, { recursive: true, force: true }) } }) + +test.concurrent('web history keeps few commits extracted, retries a failed read, and lists a playback range without reading its middle', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'groma-web-archives-')) + const git = gitIn(root) + const commit = (message: string) => { git('add', '--all'); git('commit', '--quiet', '-m', message); return git('rev-parse', 'HEAD') } + let session: Awaited> | undefined + try { + git('init', '--quiet') + git('config', 'core.autocrlf', 'false') + await cp(fixture, root, { recursive: true }) + const readable = [commit('Architecture')] + const project = path.join(root, 'groma/project.md') + const profile = await readFile(project, 'utf8') + await rm(project) + const unreadable = commit('Architecture without its project document') + await writeFile(project, profile) + // More readable commits than the host keeps extracted at once. + for (let receipt = 1; receipt <= 5; receipt++) { + await writeFile(path.join(root, 'src/orders.ts'), `export const receipt = ${receipt}\n`) + readable.push(commit(`Receipt ${receipt}`)) + } + const [oldest, newest] = [readable[0]!, readable.at(-1)!] + session = await openSession(root) + const status = async (url: string) => (await session!.fetch(new Request(`http://localhost${url}`))).status + const range = await session.fetch(new Request(`http://localhost/playback.json?from=${oldest}&revision=${newest}`)) + expect((await range.json() as WebRevision[]).map(revision => revision.id)).toEqual([oldest, unreadable, ...readable.slice(1)]) + expect(await status(`/playback.json?from=${unreadable}&revision=${newest}`)).toBe(422) + for (const id of readable) expect(await status(`/world.json?revision=${id}`)).toBe(200) + // With Git out of reach, only a commit that is still extracted can be served. + const gitDirectory = path.join(root, '.git') + const heldGitDirectory = path.join(root, '.held-git') + await rename(gitDirectory, heldGitDirectory) + try { + expect(await status(`/world.json?revision=${newest}`)).toBe(200) + // The oldest commit was closed, and a Git failure is a server error, not an unreadable architecture. + expect(await status(`/world.json?revision=${oldest}`)).toBe(500) + } finally { await rename(heldGitDirectory, gitDirectory) } + expect(await status(`/world.json?revision=${oldest}`)).toBe(200) + } finally { + await session?.close() + await rm(root, { recursive: true, force: true }) + } +}) From 8b40874cf6a8c75c3690c9b736b19349ab7b059d Mon Sep 17 00:00:00 2001 From: Alex Gavrilescu Date: Sun, 11 Oct 2026 21:21:48 +0200 Subject: [PATCH 4/6] TASK-563 - Play the architecture's history between two commits on the web map --- ...tory-between-two-commits-on-the-web-map.md | 73 ++++++++-------- docs/viewers/web/history-playback.md | 49 ++++++----- docs/viewers/web/index.md | 15 ++-- .../containers/cli/components/web-server.md | 2 +- src/history/snapshots.ts | 22 +++-- src/viewers/web/data.ts | 8 -- src/viewers/web/map-session.ts | 19 ----- src/viewers/web/page.ts | 8 +- src/viewers/web/render.ts | 4 +- src/viewers/web/revision-archives.ts | 62 +++++--------- src/viewers/web/revision/control.ts | 62 +++++++------- src/viewers/web/revision/playback.ts | 70 +++++++++------- src/viewers/web/revision/view.ts | 10 ++- src/viewers/web/runtime.ts | 42 ++++++---- test-bun/web-revisions.test.ts | 83 ++++++++++++++----- 15 files changed, 285 insertions(+), 244 deletions(-) diff --git a/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md b/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md index 74bd4b2a..08bc29bd 100644 --- a/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md +++ b/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md @@ -5,7 +5,7 @@ status: In Progress assignee: - '@codex' created_date: '2026-10-07 23:16' -updated_date: '2026-10-11 16:26' +updated_date: '2026-10-11 19:21' labels: - senior dependencies: [] @@ -45,6 +45,8 @@ modified_files: - src/viewers/web/payload.ts - docs/viewers/web/index.md - groma/systems/groma-md/containers/cli/components/revision-archives.md + - src/viewers/web/runtime.ts + - src/viewers/web/page.ts type: feature ordinal: 3 --- @@ -78,24 +80,19 @@ Build on revisions and snapshots (src/history/revisions.ts, src/history/snapshot ## Implementation Plan -1. Fix the reviewed endpoint flow in the existing revision selector: retain chosen endpoints while opening a field after Stop, render endpoint fields directly, and disable Play through the current pending-navigation state. 2. Retain each historical archive for the server session, reuse it for architecture, source and code-detail requests, release it on close, and prepare ordinary layouts only when requested. Remove the unused loader branch and playback members; preserve browser frame caching, two-frame preloading, Git order, speed, Stop, reduced motion and final full-range comparison. 3. Regression authority: AC1 and the reproduced stop/edit failure require changing one endpoint to retain the other. Existing server tests do not exercise browser endpoint edits; reuse the Chromium stop/edit reproduction. AC1 and the reproduced pending-navigation race require Play to wait for the chosen range; hold the selected endpoint response and verify Play is disabled until it applies. AC3 requires one historical read per session; existing source assertions check values but not reused reads. Extend the existing web-history test by temporarily removing Git access after loading snapshots, then request old code/source details and verify they still work. Browser Git archive logging with How open on two replays verifies one extraction per revision. 4. Reuse the minimum 50-commit browser fixture to verify order, range URL, final comparison/changes stepper, speed, Stop, replay and reduced-motion execution; run focused checks and bun run check. 5. Run one cold simplicity review, implement accepted local simplifications, then perform specification and quality review; run one final full-context complexity review. Read finalization instructions, record objective evidence without claiming unverified visual morph quality, curate any scanner-caused architecture files, and make the authorized local task commit. - -Regression refinement after self-review: the retained chosen endpoints must not replace the displayed revision getters used by render.ts for How/source and changes navigation. Authority is the existing displayed-frame detail flow and the required chosen-range/displayed-frame distinction. The wrong result would show final-range code while an intermediate frame is visible. The replay browser check asserts code requests name the displayed first snapshot, with no comparison endpoint, rather than the retained final range; later comparison frames render their embedded file changes. - -Maintainability pass (review findings A to H, 2026-10-11). It supersedes steps 2 and 3 where they say one archive per revision is retained for the whole server session. -A. The web host keeps at most a named, fixed number of commits extracted (four: the previous frame, the current one and the two the browser preloads). A commit nobody is reading and that was used least recently is closed when the limit is exceeded; a commit that failed to open is not kept. Replays are served by the browser frame cache. -B. /playback.json reads only the two endpoints and answers with every commit of the range, oldest first. The browser walk skips a commit the current reader cannot read and continues from the last readable frame. -C. One signal names an unreadable architecture: readSnapshot throws UnreadableArchitectureError, the host answers with UNREADABLE_REVISION_STATUS, the browser data source throws UnreadableRevisionError. Git and filesystem failures answer 500 and reach the person. -D. The host module moves out of the browser folder to src/viewers/web/revision-archives.ts. compareSnapshots in src/history/snapshots.ts owns the assembly of a comparison from two loaded snapshots; readComparison and the host both call it. -E. The per-revision source text cache is removed: a comparison reads the files from the two open archives, so no shared entry is edited by readers. The lazily added ordinary layout is declared in the entry's type. -F. The speed control reuses the header dropdown atom (details, anchored-popover, anchored-option, bindPopover). -G. docs/viewers/web/index.md links the playback page, which is brought in line with A to D. -H. Imports follow each file's grouping. -Tests (one new test in test-bun/web-revisions.test.ts): -1. Rule and authority: review decision A, a bounded number of open commits with least-recently-used closing and no kept failure; review finding C, only an unreadable architecture counts as unreadable; review decision B, the candidates are every commit of the range in Git order, oldest first. -2. Wrong results caught: every requested commit stays extracted until the session closes; a failed read is answered from the cache forever; a Git failure is reported with the unreadable status and playback would skip the commit silently; /playback.json reads or filters the middle of the range or answers newest first. -3. Coverage gap: the existing test opens three commits and only proves reuse, never closing, retry, the failure status or /playback.json. Smallest test: one repository with more commits than the limit and one unreadable commit in the middle, observed through the session's HTTP routes with Git hidden, as the existing reuse assertion does. -The browser walk has no DOM test harness in this repository; skipping an unreadable commit is verified with a headless Chromium run and recorded in the notes. +1. Revision selector in the browser (src/viewers/web/revision/control.ts, view.ts). The control keeps two pairs. The displayed pair is what the map shows now, which is a playback frame while a range plays; source details and the changes bar read it. The chosen pair is the range Play was pressed on, or else the displayed pair; the fields and the URL show it, so a link reopens the range and editing one endpoint after Stop keeps the other. Play is disabled while a chosen revision is still loading. +2. Playback in the browser (src/viewers/web/revision/playback.ts), live map only. Play takes the commits between the chosen pair from the revision list every payload carries, oldest first, and walks them: the first frame is the start snapshot, each later frame is the comparison from the last commit shown. Each frame is requested once per page session and kept; the next two frames load ahead. A commit whose frame answers with the unreadable status is skipped. Any other failure stops playback, shows its message and is not kept, so Play asks again. Stop keeps the frame on screen and the chosen range. The walk ends on the full-range comparison that was on screen when Play was pressed. The speed menu (0.5x, 1x, 2x, 4x) is the header dropdown atom. +3. Web host (src/viewers/web/revision-archives.ts, used by map-session.ts). Opening a commit extracts the repository at that commit into a temporary folder and reads its architecture. OPEN_ARCHIVES = 4 commits stay open: the two a frame compares and the two the browser preloads. When a reader finishes, the least recently used commits nobody is reading are closed and their folders deleted; a commit that failed to open is not kept; closing the session deletes the rest. Ordinary views, comparisons, code outlines and source details read the open folder of their commit. +4. One signal for an unreadable architecture. readSnapshot (src/history/snapshots.ts) reports what the architecture reader rejects as UnreadableArchitectureError and passes a busy project lock or an operating-system failure through unchanged. The host answers the first with UNREADABLE_REVISION_STATUS (422, src/viewers/web/payload.ts) and everything else with 500. The browser data source (src/viewers/web/data.ts) turns that status into UnreadableRevisionError. +5. Single owners. compareSnapshots (src/history/snapshots.ts) assembles every comparison from two loaded snapshots. layOutMap (src/viewers/web/runtime.ts) lays out a loaded model and reports its timings. +6. Header. On a header up to 1320 px wide Search folds to its icon while Play shows, and the revision box clips fields that do not fit instead of letting them cover the controls beside it. +7. Documentation: docs/viewers/web/history-playback.md, linked from docs/viewers/web/index.md, and the Web host and Revision selector records under groma/. +8. Tests, all in test-bun/web-revisions.test.ts. +8a. Closing and retry. Rule and authority: review decision A, the host keeps OPEN_ARCHIVES commits open, closes the least recently used and does not keep a failed read; review finding C, a Git failure is not an unreadable architecture. Wrong results caught: every requested commit stays extracted until the session closes; a failed read is answered from the cache forever; a Git failure answers with the unreadable status and playback would skip the commit. Gap: the existing test opens three commits and proves only reuse. Smallest test: one repository with more commits than OPEN_ARCHIVES, observed through the session's HTTP routes with Git hidden. +8b. Reader count. Rule and authority: review item 8 and decision A, a folder is never deleted while a request reads it. Wrong result caught: readSourceTexts reports a missing file as a deletion, so a comparison whose folder was deleted under it shows every file as removed, with no error. Gap: removing the reader check fails no test. Smallest test: through the module's one reading entry point, hold one commit while more than OPEN_ARCHIVES other commits are opened one after another, then read a file from the held folder. +8c. Unreadable or failed. Rule and authority: review item 12, only an architecture the reader rejects is unreadable; an operating-system failure is a failure. Wrong results caught: playback silently skips a commit whose architecture could not be opened because of an I/O error; or a commit that merely lacks a document stops playback instead of being skipped. Gap: nothing fails when readSnapshot reports every reader error as unreadable. Smallest test: readSnapshot on a copy of the fixture, once without index.md and once with a directory in its place. +The walk in the browser has no DOM test harness in this repository; it is verified with headless Chromium runs recorded in the notes. +9. Verification: bun run typecheck, bunx biome lint, the focused bun tests, and headless Chromium runs for fifty commits, an unreadable middle commit, an injected failure with retry, the speed menu, and the header at 1000, 1100, 1280 and 1440 px in light and dark. Acceptance criteria 2 (morph quality) and 5 (reduced-motion geometry) need a visual check and stay unchecked. ## Implementation Notes @@ -116,28 +113,32 @@ Implementer specification review: the three reported failures are fixed in the d Final verification after the endpoint-helper correction: bun run check passed (16 Node tests; 776 Bun tests, 51 skipped, zero failures), including TypeScript, repository lint and scrollbar checks. Changed-file Biome and git diff --check pass. Chromium stop/edit keeps Revision 1 when destination is reselected; holding a chosen destination response disables Play, then playback finishes at Revision 6. Two How-open replays use the displayed first snapshot for code reads and one extraction per loaded commit. The final 50-commit run completed in 21.6 seconds with every commit in Git order, one document navigation, unchanged range URL, no page errors, and working final changes bar/Next change. Request order proves the next two frames preload. Archive logging confirms exactly one extraction for each of all 50 revisions, and session close removed all 50 archives. The unreadable-middle fixture returns only its first/last readable commits. Final screenshots were inspected. Maximum animation-frame gap was 100.4 ms while the native full checks and another browser ran; no larger-map performance claim is made. Original AC2 visual morph quality and AC5 reduced-motion geometry remain unchecked; reduced-motion execution itself passed and static/TUI coverage passed in the full check. Artifacts: /private/tmp/groma-pr121-fixes/regressions-result.json, fifty-result.json, cache-final.json and check-final.log. Final full-context complexity review passed with no material recommendations. It confirmed clear separation of chosen endpoints from displayed frames, the existing navigation flag for Play, distinct browser/server cache responsibilities, archive cleanup by the web session, lazy ordinary layouts, and unchanged OKF/C4 concepts. Cold simplicity review passed; the only optional repeated endpoint checks were collapsed into dedicated endpoint helpers and one targeted re-review approved them. All reported PR #121 defects and requested simplifications are complete. Original visual AC2/AC5 and DoD1 remain unverified, so the task remains In Progress rather than asserting full original-feature acceptance. - -Maintainability pass on review findings A to H (2026-10-11). The Final Summary above predates this pass: its statements that one archive per revision is kept for the server session and that each of 50 revisions is extracted exactly once no longer hold and must be rewritten at finalization. +Maintainability pass and its review round (2026-10-11). The notes above record earlier designs; where they say that one archive per revision is kept for the server session, that the host filters the playback range, or that each of 50 revisions is extracted exactly once, this section replaces them. The Implementation Plan and the Final Summary describe the final system. What changed and why. -A. src/viewers/web/revision-archives.ts (moved from revision/history.ts) keeps OPEN_ARCHIVES = 4 commits extracted: the two a frame compares and the two the browser preloads. Each open commit counts its readers; when a reader finishes, the least recently used commits without readers are closed until four remain, and their folders are deleted in the background while close() waits for them. A commit that failed to open is dropped, so the next request tries again. The reader count is required, not optional: readSourceTexts reports a missing file as a deletion, so closing a folder under a running comparison returns a wrong comparison without any error. A scratch run of 49 concurrent comparisons returned 49 correct results with the count and many wrong added/removed results without it. -B. /playback.json (playbackResponse in map-session.ts) reuses comparedPair for the two commits, opens only those two, and answers with every commit of the range, oldest first. The walk in revision/playback.ts skips a commit whose frame answers with the unreadable signal and compares the next commit with the last one shown. -C. readSnapshot (src/history/snapshots.ts) wraps whatever the architecture reader throws in UnreadableArchitectureError. revisionError in map-session.ts answers that error with UNREADABLE_REVISION_STATUS (422, payload.ts) and every other failure with 500; data.ts turns the status into UnreadableRevisionError. Git and extraction failures therefore reach the person. The dead project === null check in payloadAt is removed. -D. compareSnapshots in src/history/snapshots.ts assembles every comparison from two loaded snapshots; readComparison and the archives module call it. The host module now sits beside map-session.ts and is named for what it holds. -E. The in-memory source text cache is gone: a comparison reads the files from the two open folders, so no stored entry is edited by a reader. The only field added after opening, the ordinary layout, is declared and documented on the Archive type. -F. The speed control is a details element with the shared anchored-popover and anchored-option atoms and bindPopover, like the theme, Help and Info menus. -G. docs/viewers/web/index.md links the playback page; the page describes the limit, the unreadable status, the browser skip and the one comparison owner. -H. revision/control.ts lists its local imports in order; map-session.ts imports the archives module in its place in the local group. -Architecture: the scan made a stand-alone revision-archives component for the moved file; it was combined into web-server, and the web-server and revision-control overviews were updated through groma edit. - -Verification (run by this pass). bun run typecheck passes. bunx biome lint: 1 warning and 2 infos, the same as before the pass, none in a changed file. bun scripts/lint-web-scrollbars.ts passes. bun test --timeout 20000 on web-revisions, web-export, revision-comparison, web-page-inset, web-sharing, web-startup: 22 pass, 0 fail. The new test fails on the previous code: /playback.json dropped the unreadable middle commit, and with that assertion removed the oldest commit was still served with Git hidden (200 instead of 500). The full bun run check was not run in this lane. -Headless Chromium 1600x1000, light theme, minimum fixtures. Fifty commits at 4x: all 50 captions in Git order in 19.4 s, range URL unchanged, changes bar shown at the end, no page error; /playback.json answered in 25 ms; 50 frame requests, and 0 frame requests on a replay. Host folders: at most 5 at once while playing, 4 before close, 0 after close. Extractions: 48 commits once; the end commit twice (once for the comparison shown before Play, once when the walk reaches it); the start commit a second time on the replay, because /playback.json opens both endpoints again. Unreadable middle commit: captions show only the first and last commit, no error, the map ends on the starting comparison. A frame answered with 500 stops playback and shows its message. Speed menu: opens, lists 0.5x 1x 2x 4x with the current one marked, closes on an outside click, is usable with Enter and Tab, returns focus to its button; screenshots inspected in light, dark and a 1000 px header. -Not verified: acceptance criteria 2 (morph quality) and 5 (reduced-motion geometry) stay unchecked. Criterion 3 says a revision is read once per session; after decision A that holds for the browser (each frame is requested once per session) but not for the host, which reads a closed commit again. -Observed, outside findings A to H and unchanged by this pass: in a comparison at 1280 px wide, with the fixture's 24-character project title, the header has no room for the revision fields beside Play and the speed button, and the controls overlap the fields (measured at 1000, 1100 and 1280 px; 1600 px fits). The unmodified pull request shows the same. +Host. src/viewers/web/revision-archives.ts (moved out of the browser folder revision/) keeps OPEN_ARCHIVES = 4 commits extracted. withArchive is the one way to read a commit: it counts a reader, runs the read, and afterwards closes the least recently used commits without readers until four remain; folders are deleted in the background and close() waits for them. A commit that failed to open is dropped, so the next request tries again. The reader count is required: readSourceTexts reports a missing file as a deletion, so a folder deleted under a running comparison yields a wrong comparison with no error. A comparison opens its older commit, then its newer one, through nested withArchive calls. The in-memory source text cache is gone; the lazily stored ordinary layout is a documented field. The /playback.json route, its handler and the data-source method are deleted: the browser slices the range from the revision list in the displayed payload. +Unreadable or failed. readSnapshot (src/history/snapshots.ts) reports what the reader rejects as UnreadableArchitectureError and passes a busy project lock or an operating-system failure through; a missing document is a rejection. revisionError in map-session.ts answers the first with UNREADABLE_REVISION_STATUS (422, payload.ts) and everything else with 500; data.ts turns the status into UnreadableRevisionError. +Single owners. compareSnapshots (src/history/snapshots.ts) assembles every comparison from two loaded snapshots. layOutMap (src/viewers/web/runtime.ts) lays out a loaded model and reports its timings for both loadMapRoot and the archives; the total of the live map is now load time plus layout time instead of wall time. revisionDate (revision/view.ts) is the one date format of the commit list and the caption. +Browser. revision/playback.ts: stop() keeps the chosen range and clear() drops it; chosen() is the one place that says "the range Play was pressed on, else the displayed pair", and control.ts derives the fields, the URL ids, the x button and the error list from it. Play is offered by delivery kind. The walk skips an unreadable commit, drops a failed frame from its cache unless it failed as unreadable, and seeds only the comparison on screen when Play is first pressed. The repaint option and remember are deleted. The speed menu is the header dropdown atom. control.ts names the two pairs (selected/from for the frame on screen, chosenRevision/chosenFrom for what the person chose), the x handler is the one-line form again, and an error opens the chosen destination's list. +Header. On a header up to 1320 px Search folds to its icon while Play shows (page.ts), and the revision box clips fields it has no room for (revision/view.ts). +Docs and records: docs/viewers/web/history-playback.md and index.md; the web-server and revision-control records were updated through groma edit after the scan made a stand-alone revision-archives component that was combined into web-server. + +Verification of the final code. bun run typecheck passes. bunx biome lint: 1 warning and 2 infos, the same as before the pass, none in a changed file. bun scripts/lint-web-scrollbars.ts passes. bun test --timeout 20000 on editing, http-relationships, web-revisions, web-sharing, scanner-source-listing, vue-http, web-export, web-page-inset, web-startup and revision-comparison: 136 pass, 0 fail. Each new test fails when the behaviour it guards is removed (checked in scratch copies): no reader check fails the reader test in 15 of 15 runs; reporting every reader error as unreadable, or treating a missing document as an I/O failure, fails the snapshot test; never closing a commit, or keeping a failed open, fails the closing test. The full bun run check was not run in this lane. +Headless Chromium, minimum fixtures. Fifty commits at 4x, 1600 px: all 50 captions in Git order in 19.4 s, range URL unchanged, changes bar at the end, no page error; the first frame is requested 3 ms after Play; 50 frame requests, none on a replay; Stop, its label and the loading caption appear in the same task as the click. Host folders: at most 5 at once, 4 before close, 0 after. Extractions: 49 commits once, the end commit twice (once for the comparison shown before Play, once when the walk reaches it). Unreadable middle commit: skipped, no error, the map ends on the starting comparison. A frame answered with 500 stops playback, shows the message under the destination field and keeps the range; with the failure removed, Play asks for that frame again and finishes. After Play, Stop on the first frame and a click on the start field, the x opens the destination on its own. Editing the destination after Stop keeps the chosen start. Speed menu: opens, lists four speeds with the current one marked, closes on an outside click, works with Enter and Tab, returns focus to its button. +Header widths, comparison of two commits, light and dark: no overlap at 1000, 1100, 1280 and 1440 px. With the fixture's 24-character project title the fields show 36 of 72 px at 1280 and 33 of 72 px at 1440, and are clipped away at 1000 and 1100 px, because the project title never gives way. With an 8-character title both short IDs read whole at 1000 px, the messages show 20 of 72 px at 1100 px and whole at 1280 and 1440 px. Before this change the same fixture overlapped at 1000, 1100 and 1280 px. + +Acceptance criterion 3, exact behaviour: the browser requests each frame once per page session and keeps it, so a replay asks the host for nothing; the host keeps the last four commits open and extracts a closed commit again when it is asked for. Upcoming frames load two ahead, and fifty commits play without a page reload. +Not verified: acceptance criteria 2 (morph quality) and 5 (reduced-motion geometry) stay unchecked; animation-frame gaps were not measured in this pass. ## Final Summary -Fixed all three PR #121 review defects: endpoint editing after Stop retains the other chosen endpoint; Play is disabled while an endpoint navigation is pending; historical code and source details reuse one archive per revision for the server session. Layouts are lazy, fields render actual endpoints, and unused loader/playback state is removed. Displayed-frame source reads stay separate from retained editing/URL choices. Existing owners, two caches, next-two preloading, Git order, speed, Stop, reduced-motion execution and final full-range comparison are preserved. Added one cache regression in the existing web-history test (failed before the server fix, passed after). Final bun run check passed: 16 Node and 776 Bun tests, 51 skipped, zero failures. Chromium verified stop/edit, pending destination, two How-open replays, all 50 commits in order, retained URL, one document navigation, final changes bar and Next change. All 50 revisions extracted once; closing the session removed every archive. Cold simplicity and final complexity reviews passed. The original morph-quality and reduced-motion-geometry criteria remain unchecked; no larger-map performance claim is made. Fix branch is local only; no push. +The live web map plays the architecture's history between two commits. Play and a speed menu (0.5x, 1x, 2x, 4x) appear beside a comparison of two commits; Play walks the commits between them in Git order, shows each commit's short id, subject and date, and ends on the full-range comparison with its changes bar and stepper. Stop keeps the frame on screen and the chosen range; the fields and the URL keep that range, and editing one endpoint after Stop keeps the other. + +How it works. The browser (src/viewers/web/revision/playback.ts) takes the commits of the range from the revision list in the displayed payload, requests one frame per commit from /world.json, keeps every frame for the page session and loads two frames ahead. A commit whose architecture the current reader rejects is answered with status 422 and skipped; any other failure is answered with 500, stops playback, shows its message and is asked for again on the next Play. The host (src/viewers/web/revision-archives.ts) extracts a commit into a temporary folder, keeps the four most recently used commits open, never deletes a folder a request is reading, and deletes all folders when the session closes. compareSnapshots (src/history/snapshots.ts) assembles every comparison; layOutMap (src/viewers/web/runtime.ts) lays out every loaded model. Static exports and the terminal viewer have no playback. + +Verification. Type check, lint (no new warning) and the focused bun tests pass, including three tests for closing and retry, the reader count, and the unreadable-versus-failed line. Headless Chromium runs on minimum fixtures confirm fifty commits in order with no request on replay, the skip of an unreadable commit, a failure with retry, the speed menu, the x after Stop, and a header without overlap at 1000, 1100, 1280 and 1440 px in light and dark. The full bun run check is left to the orchestrator. + +Open. Acceptance criteria 2 (morph quality) and 5 (reduced-motion geometry) need a visual check and are unchecked. With a 24-character project title the revision fields are clipped away at 1000 and 1100 px, because the project title never gives way. The end commit of a range is extracted twice per first playback. diff --git a/docs/viewers/web/history-playback.md b/docs/viewers/web/history-playback.md index ff37b6a5..0da09b9b 100644 --- a/docs/viewers/web/history-playback.md +++ b/docs/viewers/web/history-playback.md @@ -25,46 +25,53 @@ while playback continues. ## In the browser [`revision/control.ts`](../../../src/viewers/web/revision/control.ts) owns the -selected revision and connects the playback controls to the browser session. +displayed revision or pair and connects the playback controls to the browser +session. While a range plays or is stopped on a frame, the displayed pair is +that frame; the chosen pair, which the fields and the URL show, stays the range. [`revision/playback.ts`](../../../src/viewers/web/revision/playback.ts) owns the -clock, cancellation, speed, caption, range, and a cache of the frames it has -requested, so a replay does not ask the host again. Stopping invalidates pending -frame applications without discarding their cached data. +clock, cancellation, speed, caption, chosen range, and a cache of the frames it +has requested. Stopping invalidates pending frame applications without +discarding their cached data. -Play first asks `/playback.json?from=&revision=` for every -commit of the range, oldest first. The walk then requests one frame per commit -from `/world.json` and loads the next two frames ahead. A commit the current -reader cannot read is skipped, and the next frame is the comparison from the -last commit shown. Any other failure stops playback and shows its message. +Play takes the commits between the chosen pair from the revision list that every +payload carries, oldest first, so starting needs no request of its own. The walk +requests one frame per commit from `/world.json` and loads the next two frames +ahead. Each frame is requested once per page session: a replay shows the kept +frames and does not ask the host again. A commit the current reader cannot read +is skipped, and the next frame is the comparison from the last commit shown. Any +other failure stops playback and shows its message; that frame is not kept, so +the next Play asks for it again. ## In the web host -[`map-session.ts`](../../../src/viewers/web/map-session.ts) serves both routes. -`/playback.json` reads only the two endpoints, so a long range answers at once. -A request that names a commit whose architecture the current reader cannot read -is answered with status 422 (`UNREADABLE_REVISION_STATUS` in +[`map-session.ts`](../../../src/viewers/web/map-session.ts) serves the frames. +A request that names a commit whose architecture the reader rejects, because the +commit holds none or it follows an older Markdown contract, is answered with +status 422 (`UNREADABLE_REVISION_STATUS` in [`payload.ts`](../../../src/viewers/web/payload.ts)), and the browser data source turns that status into `UnreadableRevisionError`. This status is the only signal -for an unreadable commit. A Git error, a full disk, or any other failure is -answered with status 500. +for an unreadable commit. Every other failure is answered with status 500: a Git +error, a full disk or another operating-system failure, and a busy project lock. +`readSnapshot` in [`history/snapshots.ts`](../../../src/history/snapshots.ts) +draws that line. [`revision-archives.ts`](../../../src/viewers/web/revision-archives.ts) opens the commits. Opening one extracts the whole repository at that commit into a temporary folder and reads its architecture. Once no request is reading, at most four commits stay open (`OPEN_ARCHIVES`): the two a frame compares and the two the browser preloads. Beyond that, the least recently used commit that no -request is reading is closed and its folder deleted. A commit that failed to -open is not kept, so the next request tries again. Code outlines and source details read the open +request is reading is closed and its folder deleted; a folder is never deleted +under a request that reads it. A commit that failed to open is not kept, so the +next request tries again. Code outlines and source details read the open folder of their commit. An ordinary map layout is prepared on the first request for a snapshot view and kept while the commit stays open; comparisons prepare their own layouts. Closing the server session deletes the remaining folders. A host process that is killed cannot delete the folders open at that moment; they stay in the system's temporary directory, named `groma-revision-*`. -`compareSnapshots` in -[`history/snapshots.ts`](../../../src/history/snapshots.ts) assembles every -comparison from two loaded snapshots. Working-tree comparisons call it through -`readComparison`, which reads current files and removes its archive afterwards. +`compareSnapshots` in `history/snapshots.ts` assembles every comparison from two +loaded snapshots. Working-tree comparisons call it through `readComparison`, +which reads current files and removes its archive afterwards. Playback is transient browser state, not stored architecture knowledge. It adds no OKF metadata or C4 element type. Git commits and the existing architecture diff --git a/docs/viewers/web/index.md b/docs/viewers/web/index.md index 459dbad7..1217c27d 100644 --- a/docs/viewers/web/index.md +++ b/docs/viewers/web/index.md @@ -249,16 +249,19 @@ look, `.chrome-field` in `web/atoms/chrome.ts`, and one menu entrance in gives way: the counts, which show only above 1400 px, take just the width nothing else wants and leave whole when less than 150 px is left for them; Search narrows to 200 px; then the commit messages clip together. The project -title never gives way. Up to 1320 px the header has no spare room for a -comparison being started or for compare words outside a short revision field, so -Search folds to its icon until the list closes; opening Search, by key or by -clicking its icon, unfolds it. At 1080 px and below Fit, Help and Info +title never gives way, so a revision box with no room left clips its fields +rather than draw them over the buttons beside it. Up to 1320 px the header has +no spare room for a comparison being started, for compare words outside a short +revision field, or for Play and the speed menu beside a pair, so Search folds to +its icon while one of them shows; opening Search, by key or by clicking its +icon, unfolds it. At 1080 px and below Fit, Help and Info show only their icons, a revision field shows its exact tag or short ID instead of its message, Search may narrow to 120 px, and an open revision search takes the whole box. At 1000 px and below Search also stays folded while a comparison is open, so both short IDs read whole down to the page's 900 px minimum. `page.ts` reads the -revision box's `data-starting` attribute, where the compare words stand, and the -body's `data-comparison` attribute for those folds. +revision box's `data-starting` attribute, where the compare words stand, whether +the playback controls show, and the body's `data-comparison` attribute for those +folds. Iso, 2D and Layers are icon-and-label tabs in a floating bar at the top of the map, centered between the side panels. It shares the bottom tasks bar rounded surface. One selection pill slides and resizes between tabs; reduced motion diff --git a/groma/systems/groma-md/containers/cli/components/web-server.md b/groma/systems/groma-md/containers/cli/components/web-server.md index aef619f6..b765d2b9 100644 --- a/groma/systems/groma-md/containers/cli/components/web-server.md +++ b/groma/systems/groma-md/containers/cli/components/web-server.md @@ -24,7 +24,7 @@ groma: description: Serves the local browser map and live architecture operations --- -Starts the local HTTP server. Serves project setup and connects the browser to architecture, scanner, task, and source operations. Extracts each historical commit the browser asks for into a temporary folder and keeps only the few most recently used commits open, so comparisons, playback frames, and historical details share one extraction without filling the disk. The folders are deleted when the session closes. Lists the commits of a playback range, and answers with a status of its own when the current reader cannot read a commit, so the browser can skip it. +Starts the local HTTP server. Serves project setup and connects the browser to architecture, scanner, task, and source operations. Extracts each historical commit the browser asks for into a temporary folder and keeps only the few most recently used commits open, so comparisons, playback frames, and historical details share one extraction without filling the disk. The folders are deleted when the session closes. Answers with a status of its own when the current reader rejects the architecture of a commit, so playback in the browser can skip it. ## Relationships diff --git a/src/history/snapshots.ts b/src/history/snapshots.ts index ea4e7d2e..e8ebbb27 100644 --- a/src/history/snapshots.ts +++ b/src/history/snapshots.ts @@ -1,6 +1,8 @@ import { readFile } from 'node:fs/promises' import path from 'node:path' +import { ArchitectureReadError } from '../architecture-reader.ts' import { loadAnnotatedArchitecture } from '../core.ts' +import { GromaBusyError } from '../groma-filesystem.ts' import { loadProjectProfile, type ProjectProfile } from '../project-profile.ts' import type { AnnotatedArchitectureModel } from '../types.ts' import type { GitRevision } from './revisions.ts' @@ -15,9 +17,8 @@ export interface Snapshot { } /** - * The architecture reader failed in a folder: usually the folder holds no architecture, or one that follows an older - * Markdown contract. Extracting a commit into the folder is a separate step, so a Git error or a full disk while - * extracting is not this error. + * The architecture reader rejects what a folder holds: no architecture, or one that follows an older Markdown + * contract. A Git error, a full disk, another operating-system failure or a busy project lock is never this error. */ export class UnreadableArchitectureError extends Error { constructor(cause: unknown) { @@ -31,14 +32,25 @@ export function atRevision(root: string, revision: GitRevision | null, read: return revision === null ? read(root) : withGitRevision(root, revision.id, read) } -/** Whatever the architecture reader throws in this folder is reported as UnreadableArchitectureError. */ +/** + * A busy project lock or an operating-system failure says nothing about the architecture. The reader wraps a file it + * could not open; only a missing file there is a missing document, which is a rejected architecture. + */ +function isIoFailure(error: unknown): boolean { + const failure = error instanceof ArchitectureReadError ? error.cause : error + if (failure instanceof GromaBusyError) return true + const system = failure as NodeJS.ErrnoException | undefined + return system?.syscall !== undefined && system.code !== 'ENOENT' +} + +/** Reads the architecture in a folder. Only what the reader rejects is reported as UnreadableArchitectureError. */ export async function readSnapshot(root: string): Promise { try { const [world, project] = await Promise.all([loadAnnotatedArchitecture(root), loadProjectProfile(root)]) if (project === undefined) throw new Error('No Groma architecture in this revision') return { root, world, project } } catch (error) { - throw new UnreadableArchitectureError(error) + throw isIoFailure(error) ? error : new UnreadableArchitectureError(error) } } diff --git a/src/viewers/web/data.ts b/src/viewers/web/data.ts index b76cce22..8aa6b600 100644 --- a/src/viewers/web/data.ts +++ b/src/viewers/web/data.ts @@ -13,11 +13,6 @@ export interface WebDataSource { onScanners?: (state: ScannerSettings) => void readWorld(revision?: string, from?: string): Promise readRevisions(): Promise - /** - * Live only. Every commit from `from` to `revision`, oldest first. Some of them may hold an architecture the - * current reader cannot read: `readWorld` then throws UnreadableRevisionError and playback skips the commit. - */ - readPlaybackRevisions?(revision: string, from: string): Promise readCode(element: string, revision?: string, from?: string): Promise readSource(element: string, file: string, revision?: string, from?: string): Promise readTask(id: string): Promise @@ -77,9 +72,6 @@ function liveDataSource(): WebDataSource { readRevisions() { return responseJson('/revisions.json') }, - readPlaybackRevisions(revision, from) { - return responseJson(selected('/playback.json', { revision, from })) - }, readWorld(revision, from) { return responseJson(selected('/world.json', { revision, from })) }, diff --git a/src/viewers/web/map-session.ts b/src/viewers/web/map-session.ts index 684ad7c8..171fb35e 100644 --- a/src/viewers/web/map-session.ts +++ b/src/viewers/web/map-session.ts @@ -329,24 +329,6 @@ export async function createWebMapSession( return sourceSelection(url) } - /** - * Every commit a playback walks, oldest first. Only the two endpoints are read here, so a long range answers at - * once; the browser skips a commit in between that the current reader cannot read when it reaches it. - */ - async function playbackResponse(_request: Request, url: URL): Promise { - const pair = await comparedPair(url) - if (pair instanceof Response) return pair - const [from, to] = pair ?? [null, null] - if (from === null || to === null) return new Response('Playback runs between two commits', { status: 400 }) - try { - await Promise.all([archives.requireReadable(from), archives.requireReadable(to)]) - } catch (error) { - return revisionError('Cannot play these revisions', error) - } - // The history list runs newest first. - return Response.json(revisions.slice(revisions.indexOf(to), revisions.indexOf(from) + 1).reverse()) - } - async function taskDiffResponse(_request: Request, url: URL): Promise { const taskId = url.searchParams.get('task') const item = workState.work.items.find(candidate => candidate.id === taskId) @@ -429,7 +411,6 @@ export async function createWebMapSession( }], ['/render.js', rendererResponse], ['/revisions.json', async () => Response.json(await readRevisions())], - ['/playback.json', playbackResponse], ['/world.json', worldResponse], ['/code.json', selectedSourceResponse], ['/source.json', selectedSourceResponse], diff --git a/src/viewers/web/page.ts b/src/viewers/web/page.ts index bd6a18f8..1fe7f02b 100644 --- a/src/viewers/web/page.ts +++ b/src/viewers/web/page.ts @@ -219,12 +219,12 @@ const style = ` @media (max-width: 1400px) { #stats .world-counts { display: none; } } - /* Up to this width the header has no spare room for a comparison being started or for compare words standing - outside a short revision field, so Search folds to its 34px icon until the list closes. Opening Search itself - unfolds it. */ + /* Up to this width the header has no spare room for a comparison being started, for compare words standing + outside a short revision field, or for Play and the speed menu beside a pair, so Search folds to its 34px icon + while one of them shows. Opening Search itself unfolds it. */ @media (max-width: 1320px) { #header > #web-search { transition: flex-basis var(--chrome-motion) var(--chrome-ease), min-width var(--chrome-motion) var(--chrome-ease); } - #header:has(#revision[data-starting], .time-machine > .revision-compare:not([hidden])) > #web-search:not([data-open]) { flex-basis: 34px; min-width: 34px; overflow: hidden; } + #header:has(#revision[data-starting], .time-machine > .revision-compare:not([hidden]), .revision-playback:not([hidden])) > #web-search:not([data-open]) { flex-basis: 34px; min-width: 34px; overflow: hidden; } } @media (max-width: 1080px) { #header { gap: 12px; padding: 0 12px; } diff --git a/src/viewers/web/render.ts b/src/viewers/web/render.ts index ede30067..5c455875 100644 --- a/src/viewers/web/render.ts +++ b/src/viewers/web/render.ts @@ -145,8 +145,8 @@ function zoomStep(factor: number, control: HTMLElement): void { function syncUrl(): void { const query = writeView({ - ...(revisionControl.urlRevision === undefined ? {} : { revision: revisionControl.urlRevision }), - ...(revisionControl.urlFrom === undefined ? {} : { from: revisionControl.urlFrom }), + ...(revisionControl.chosenRevision === undefined ? {} : { revision: revisionControl.chosenRevision }), + ...(revisionControl.chosenFrom === undefined ? {} : { from: revisionControl.chosenFrom }), ...(source.file === undefined ? {} : { file: source.file }), ...(source.line === undefined ? {} : { line: source.line }), selection, flows: activeFlows, diff --git a/src/viewers/web/revision-archives.ts b/src/viewers/web/revision-archives.ts index c8c1d9cc..2a2e4215 100644 --- a/src/viewers/web/revision-archives.ts +++ b/src/viewers/web/revision-archives.ts @@ -1,24 +1,23 @@ import { openGitRevision } from '../../history/revisions.ts' import { compareSnapshots, readSnapshot, type Snapshot } from '../../history/snapshots.ts' -import { measuredSheetScene } from '../../sheet/scene.ts' import type { ArchitectureGraph } from '../../types.ts' import { readSource } from '../source/read.ts' import { readSnapshotCodeStructure } from '../source/structure.ts' -import type { WebMapPayload, WebRevision } from './payload.ts' +import type { WebRevision } from './payload.ts' +import { layOutMap, type MapView } from './runtime.ts' /** * How many commits stay extracted once the requests reading them have finished. Playback needs four: a frame - * compares the previous commit with the current one, and the browser preloads the next two frames. + * compares the previous commit with the current one, and the browser preloads the next two frames (`preload` in + * revision/playback.ts). */ -const OPEN_ARCHIVES = 4 - -type SnapshotView = Pick +export const OPEN_ARCHIVES = 4 /** One commit, extracted into a temporary folder and read by the current architecture reader. */ interface Archive extends Snapshot { architectureLoadMilliseconds: number /** The commit's ordinary map. The first request for it stores it here; a comparison lays out its own map. */ - view?: SnapshotView + view?: MapView /** Deletes the temporary folder. */ close(): Promise } @@ -26,17 +25,13 @@ interface Archive extends Snapshot { /** A commit that is open or still being opened. */ interface Entry { opened: Promise - /** How many requests are reading the folder right now. Only a folder without readers is deleted to make room. */ + /** + * How many requests are reading the folder right now. Only a folder without readers is deleted to make room: a + * folder deleted under a reader makes every file look deleted, with no error. + */ readers: number } -function layOut(archive: Archive): SnapshotView { - const sheet = measuredSheetScene(archive.world) - return { project: archive.project, world: archive.world, sheet: sheet.scene, - timings: { architectureLoadMilliseconds: archive.architectureLoadMilliseconds, ...sheet.timings, - totalMilliseconds: archive.architectureLoadMilliseconds + sheet.timings.placementMilliseconds + sheet.timings.routingMilliseconds } } -} - /** * Runs in the web host, never in the browser. Opening a commit extracts the whole repository at that commit into a * temporary folder, so every commit a person browses or plays costs disk space and memory. Only the OPEN_ARCHIVES @@ -69,17 +64,13 @@ export function createRevisionArchives(repositoryRoot: string) { void removal.then(() => removals.delete(removal), () => {}) } - /** - * Counts one more reader of the commit and starts opening it when it is not open. Every `hold` needs one `release`; - * use `withArchive`, which pairs them, unless two commits must be open at the same time. - */ + /** Counts one more reader of the commit and starts opening it when it is not open. */ function hold(revision: WebRevision): Entry { let entry = entries.get(revision.id) if (entry === undefined) { - const opening: Entry = { opened: open(revision), readers: 0 } + entry = { opened: open(revision), readers: 0 } // A commit that failed to open is not kept, so the next request tries again. - void opening.opened.catch(() => { if (entries.get(revision.id) === opening) entries.delete(revision.id) }) - entry = opening + void entry.opened.catch(() => entries.delete(revision.id)) } // Inserting again moves the commit to the most recently used end. entries.delete(revision.id) @@ -99,7 +90,7 @@ export function createRevisionArchives(repositoryRoot: string) { } } - /** Runs `read` on the open commit; its folder exists until `read` settles. */ + /** The one way to read a commit: runs `read` on the open commit, whose folder exists until `read` settles. */ async function withArchive(revision: WebRevision, read: (archive: Archive) => T | Promise): Promise { const entry = hold(revision) try { @@ -110,23 +101,16 @@ export function createRevisionArchives(repositoryRoot: string) { } /** The commit's ordinary map, laid out once while the commit stays open. */ - function view(revision: WebRevision): Promise { + function view(revision: WebRevision): Promise { return withArchive(revision, archive => { - archive.view ??= layOut(archive) + archive.view ??= layOutMap(archive.world, archive.project, archive.architectureLoadMilliseconds) return archive.view }) } - /** The changes from one commit to a newer one. Both are opened at the same time and held until their files are read. */ - async function compare(from: WebRevision, to: WebRevision) { - const older = hold(from) - const newer = hold(to) - try { - return await compareSnapshots(from, await older.opened, await newer.opened) - } finally { - release(older) - release(newer) - } + /** The changes from one commit to a newer one. Both commits stay open until their files have been read. */ + function compare(from: WebRevision, to: WebRevision) { + return withArchive(from, before => withArchive(to, after => compareSnapshots(from, before, after))) } function structure(revision: WebRevision, world: ArchitectureGraph, element: string) { @@ -134,14 +118,10 @@ export function createRevisionArchives(repositoryRoot: string) { } function source(revision: WebRevision, world: ArchitectureGraph, element: string, file: string) { + // The open folder is read in place: it is passed as the root, and `null` means "do not extract a commit again". return withArchive(revision, archive => readSource(archive.root, world, null, element, file)) } - /** Rejects unless the commit can be opened and the current reader can read its architecture. */ - function requireReadable(revision: WebRevision): Promise { - return withArchive(revision, () => {}) - } - /** Deletes every folder, including the ones still being deleted in the background. */ async function close(): Promise { for (const entry of entries.values()) remove(entry) @@ -149,5 +129,5 @@ export function createRevisionArchives(repositoryRoot: string) { await Promise.all(removals) } - return { view, compare, structure, source, requireReadable, close } + return { withArchive, view, compare, structure, source, close } } diff --git a/src/viewers/web/revision/control.ts b/src/viewers/web/revision/control.ts index 6b224f81..e7d4ce26 100644 --- a/src/viewers/web/revision/control.ts +++ b/src/viewers/web/revision/control.ts @@ -2,7 +2,7 @@ import type { WebDataSource } from '../data.ts' import { bindPopover } from '../atoms/popover.ts' import type { WebBootPayload, WebPayload, WebWorkPayload } from '../payload.ts' import { createRevisionPlayback } from './playback.ts' -import { NARROW_HEADER, pendingPairFields, revisionFields, revisionOptions, snapshotNotice, type RevisionField } from './view.ts' +import { NARROW_HEADER, pendingPairFields, revisionDate, revisionFields, revisionOptions, snapshotNotice, type RevisionField } from './view.ts' interface RevisionControlOptions { box: HTMLElement @@ -27,9 +27,8 @@ const searchLabels: Record = { } function localizeDates(root: ParentNode): void { - const format = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }) for (const time of root.querySelectorAll('time[datetime]')) { - time.textContent = format.format(new Date(time.dateTime)) + time.textContent = revisionDate(time.dateTime) } } @@ -72,8 +71,9 @@ function revisionTooltip(list: HTMLElement) { } /** - * Owns one authoritative revision or pair, and which field is the commit search right now. - * An open field is only a draft: the displayed view changes when a commit is chosen. + * Owns the displayed revision or pair and which field is the commit search right now. While a range plays or is + * stopped on a frame, the pair the person chose differs from the frame on screen; `selected` and `chosenRevision` + * below name the two. An open field is only a draft: the displayed view changes when a commit is chosen. */ export function createRevisionControl(options: RevisionControlOptions) { const { box, body, boot, data, applyRevision, applyWorld, applyWork } = options @@ -102,7 +102,7 @@ export function createRevisionControl(options: RevisionControlOptions) { let pendingWorld: WebPayload | undefined let appliedWork = boot.workGeneration const playback = createRevisionPlayback({ - root, data, current: () => current, navigating: () => navigating, repaint: paint, + root, data, live: boot.delivery.kind === 'live', current: () => current, navigating: () => navigating, present(payload, first) { editing = undefined setRevision(payload) @@ -112,14 +112,17 @@ export function createRevisionControl(options: RevisionControlOptions) { error: showError, }) + /** A start's id: undefined outside a comparison, '' for the working tree. */ + const startId = (start: WebPayload['revision'] | undefined) => start === undefined ? undefined : start?.id ?? '' + /** The displayed pair: what the map shows now, a playback frame while a range plays. Source details read it. */ const selected = () => current.revision?.id - const from = () => current.comparison === undefined ? undefined : current.comparison.from?.id ?? '' - // Fields and URLs keep the chosen range while source details follow the displayed frame above. - const destination = () => playback.range?.to.id ?? selected() - const start = () => playback.range?.from.id ?? from() + const from = () => startId(current.comparison?.from) + /** The chosen pair: the range Play was pressed on, else the displayed pair. The fields and the URL show it. */ + const chosenRevision = () => playback.chosen().to?.id + const chosenFrom = () => startId(playback.chosen().from) const live = () => current.comparison === undefined && current.revision === null && boot.delivery.kind === 'live' /** A start is being chosen for a comparison that does not exist yet. */ - const starting = () => editing === 'from' && current.comparison === undefined && playback.range === undefined + const starting = () => editing === 'from' && playback.chosen().from === undefined /** The commit list sits under the slot it edits: the box for the first slot, the rule after "vs." for the second. */ function placeMenu(): void { @@ -132,9 +135,9 @@ export function createRevisionControl(options: RevisionControlOptions) { function paintResults(): void { if (singleSnapshot) { results.innerHTML = snapshotNotice(current.revision); localizeDates(results); return } // Ids use '' for the working tree. A start that is still being chosen holds nothing yet. - const viewed = destination() ?? '' - const held = editing === 'from' ? start() : viewed - const otherEndpoint = { revision: undefined, from: viewed, to: start() }[editing ?? 'revision'] + const viewed = chosenRevision() ?? '' + const held = editing === 'from' ? chosenFrom() : viewed + const otherEndpoint = { revision: undefined, from: viewed, to: chosenFrom() }[editing ?? 'revision'] results.innerHTML = revisionOptions(revisions, held, search.value, workingTree) // A comparison runs from an older revision to a newer one. The list runs newest first under the working tree, so a // start must sit below its destination and a destination above its start. @@ -165,15 +168,14 @@ export function createRevisionControl(options: RevisionControlOptions) { if (editing === undefined || singleSnapshot) delete box.dataset.editing else box.dataset.editing = editing // Live refreshes repaint often; untouched fields keep their focus and do not replay their entrance. - const range = playback.range - const nextFields = starting() ? pendingPairFields(current.revision) - : revisionFields(range?.to ?? current.revision, range?.from ?? current.comparison?.from) + const chosen = playback.chosen() + const nextFields = starting() ? pendingPairFields(current.revision) : revisionFields(chosen.to, chosen.from) if (nextFields !== paintedFields) { fields.innerHTML = nextFields; paintedFields = nextFields } search.hidden = !opened || singleSnapshot search.placeholder = placeholders[editing ?? 'revision'] search.setAttribute('aria-label', searchLabels[editing ?? 'revision']) paintCompareEntry() - end.hidden = current.comparison === undefined && !starting() && range === undefined + end.hidden = chosen.from === undefined && !starting() end.title = starting() ? 'Cancel comparison' : 'End comparison' end.setAttribute('aria-label', end.title) menu.hidden = !opened @@ -236,7 +238,7 @@ export function createRevisionControl(options: RevisionControlOptions) { } function open(field: RevisionField): void { - playback.cancel(false) + playback.stop() // Widths are read from the closed layout, also when another field is the search right now. close() const width = (name: RevisionField) => fields.querySelector(`[data-field="${name}"]`)?.getBoundingClientRect().width @@ -251,19 +253,18 @@ export function createRevisionControl(options: RevisionControlOptions) { function setRevision(payload: WebPayload): void { current = payload - playback.remember(payload) body.toggleAttribute('data-revision', !live()) body.toggleAttribute('data-comparison', payload.comparison !== undefined) paint() } - /** Errors live at the top of the commit list, so a failure with nothing open opens the viewed revision's list. */ + /** Errors live at the top of the commit list, so a failure with nothing open opens the chosen destination's list. */ function showError(reason: unknown): void { error.textContent = reason instanceof Error ? reason.message : String(reason) error.hidden = false menu.scrollTop = 0 if (editing !== undefined) return - editing = current.comparison === undefined ? 'revision' : 'to' + editing = playback.chosen().from === undefined ? 'revision' : 'to' paint() paintResults() } @@ -271,7 +272,7 @@ export function createRevisionControl(options: RevisionControlOptions) { async function load(revision?: string, starting?: string, reset = true): Promise { const loading = ++request navigating = true - playback.cancel() + playback.clear() error.hidden = true hideTooltip() box.setAttribute('aria-busy', 'true') @@ -298,8 +299,8 @@ export function createRevisionControl(options: RevisionControlOptions) { } function chooseRevision(revision: string): void { - if (editing === 'from') void load(destination(), revision) - else if (editing === 'to') void load(revision || undefined, start()) + if (editing === 'from') void load(chosenRevision(), revision) + else if (editing === 'to') void load(revision || undefined, chosenFrom()) else void load(revision || undefined) } @@ -333,12 +334,7 @@ export function createRevisionControl(options: RevisionControlOptions) { const option = event.target.closest('.revision-option') if (option !== null && !option.disabled) chooseRevision(option.dataset.revision!) }) - end.addEventListener('click', () => { - const revision = destination() - playback.cancel() - if (starting()) close() - else void load(revision) - }) + end.addEventListener('click', () => { if (starting()) close(); else void load(chosenRevision()) }) // The list follows its field whenever the header reflows the box. new ResizeObserver(placeMenu).observe(box) @@ -366,8 +362,8 @@ export function createRevisionControl(options: RevisionControlOptions) { return { get selected() { return selected() }, get from() { return from() }, - get urlRevision() { return destination() }, - get urlFrom() { return start() }, + get chosenRevision() { return chosenRevision() }, + get chosenFrom() { return chosenFrom() }, get comparison() { return current.comparison }, get live() { return live() }, paintProjectEdit(root: ParentNode) { diff --git a/src/viewers/web/revision/playback.ts b/src/viewers/web/revision/playback.ts index 3f908604..99a3bd5a 100644 --- a/src/viewers/web/revision/playback.ts +++ b/src/viewers/web/revision/playback.ts @@ -1,14 +1,17 @@ import { UnreadableRevisionError, type WebDataSource } from '../data.ts' import { bindPopover } from '../atoms/popover.ts' import type { WebPayload, WebRevision } from '../payload.ts' +import { revisionDate } from './view.ts' interface PlaybackOptions { root: HTMLElement data: WebDataSource + /** Play needs the live host; a published map holds only the views it was exported with. */ + live: boolean + /** The payload on screen. */ current(): WebPayload navigating(): boolean present(payload: WebPayload, first: boolean): void - repaint(): void error(reason: unknown): void } @@ -73,10 +76,6 @@ export function createRevisionPlayback(options: PlaybackOptions) { root.append(control) const keyOf = (revision: string, from?: string) => JSON.stringify([revision, from]) - function remember(payload: WebPayload): void { - if (payload.revision === null || payload.comparison?.from === null) return - cache.set(keyOf(payload.revision.id, payload.comparison?.from.id), Promise.resolve(payload)) - } function read(revision: string, from?: string): Promise { const key = keyOf(revision, from) @@ -84,34 +83,49 @@ export function createRevisionPlayback(options: PlaybackOptions) { if (pending === undefined) { pending = data.readWorld(revision, from) cache.set(key, pending) - // A preloaded frame that fails is handled only when playback reaches that step. - void pending.catch(() => {}) + // A preloaded frame that fails is handled only when playback reaches that step. A failed frame is not kept, so + // Play asks for it again; an unreadable commit stays unreadable and is kept. + void pending.catch(reason => { if (!(reason instanceof UnreadableRevisionError)) cache.delete(key) }) } return pending } - function endpoints() { + /** The pair the person chose: the range Play was pressed on, else the displayed revision or pair. */ + function chosen(): { to: WebRevision | null; from: WebRevision | null | undefined } { const current = options.current() - const from = current.comparison?.from - return range ?? (from && current.revision ? { from, to: current.revision } : undefined) + return range ?? { to: current.revision, from: current.comparison?.from } + } + + /** The chosen pair when Play can walk it: two commits in the live map. */ + function playable() { + const { from, to } = chosen() + return options.live && from && to ? { from, to } : undefined } function paint(): void { - control.hidden = data.readPlaybackRevisions === undefined || endpoints() === undefined + control.hidden = playable() === undefined button.textContent = running ? 'Stop' : 'Play' button.setAttribute('aria-label', running ? 'Stop history playback' : 'Play history') button.disabled = options.navigating() } - function stop(clearRange = false): void { + /** Stops playing. The frame on screen and the chosen range stay. */ + function stop(): void { generation++ running = false wake?.() - if (clearRange) { range = undefined; caption.hidden = true } - else if (range !== undefined && options.current().revision !== null) describe(options.current().revision!) + const shown = options.current().revision + if (range !== undefined && shown !== null) describe(shown) paint() } + /** Stops playing and drops the chosen range, because another revision or pair is being opened. */ + function clear(): void { + range = undefined + caption.hidden = true + stop() + } + function pause(): Promise { return new Promise(resolve => { const timer = window.setTimeout(done, 1500 / speed.value) @@ -121,12 +135,14 @@ export function createRevisionPlayback(options: PlaybackOptions) { } function describe(revision: WebRevision): void { - const date = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(revision.date)) - caption.textContent = `${revision.shortId} · ${revision.subject} · ${date}` + caption.textContent = `${revision.shortId} · ${revision.subject} · ${revisionDate(revision.date)}` caption.hidden = false } - /** Loads the next two frames ahead. It assumes each commit follows a readable one; a wrong guess costs one request. */ + /** + * Loads the next two frames ahead; the host keeps OPEN_ARCHIVES commits open for exactly that (revision-archives.ts), + * so change both together. It assumes each commit follows a readable one; a wrong guess costs one request. + */ function preload(revisions: WebRevision[], index: number): void { for (const next of [index + 1, index + 2]) { const revision = revisions[next] @@ -163,18 +179,21 @@ export function createRevisionPlayback(options: PlaybackOptions) { } async function play(): Promise { - const selected = endpoints() - if (options.navigating() || selected === undefined || data.readPlaybackRevisions === undefined) return + const selected = playable() + if (options.navigating() || selected === undefined) return + const current = options.current() + // Without a chosen range, the comparison on screen is the pair itself: the walk ends on it, so it is kept. + if (range === undefined) cache.set(keyOf(selected.to.id, selected.from.id), Promise.resolve(current)) range = selected running = true const token = ++generation caption.textContent = 'Loading history…' caption.hidden = false - options.repaint() paint() try { - const revisions = await data.readPlaybackRevisions(selected.to.id, selected.from.id) - if (token !== generation) return + // Every payload lists the revisions newest first; the walk runs from the older commit to the newer one. + const position = (target: WebRevision) => current.revisions.findIndex(revision => revision.id === target.id) + const revisions = current.revisions.slice(position(selected.to), position(selected.from) + 1).reverse() if (!await walk(revisions, token)) return const final = await read(selected.to.id, selected.from.id) if (token !== generation) return @@ -189,10 +208,5 @@ export function createRevisionPlayback(options: PlaybackOptions) { } button.addEventListener('click', () => { if (running) stop(); else void play() }) - return { - paint, - remember, - cancel: (clearRange = true) => stop(clearRange), - get range() { return range }, - } + return { paint, chosen, stop, clear } } diff --git a/src/viewers/web/revision/view.ts b/src/viewers/web/revision/view.ts index 08c4cf1a..c6ba069d 100644 --- a/src/viewers/web/revision/view.ts +++ b/src/viewers/web/revision/view.ts @@ -12,7 +12,8 @@ export const NARROW_HEADER = '(max-width: 1080px)' export const revisionCss = ` .time-machine { position: relative; display: flex; align-items: center; min-width: 0; flex: 0 1 auto; } .time-machine .revision-menu { right: auto; left: var(--field-left, 0px); } - #revision { min-width: 0; gap: 6px; padding: 0 6px 0 10px; color: var(--ink); } + /* The box shrinks before the buttons beside it do; fields it has no room for are clipped, never drawn over them. */ + #revision { min-width: 0; overflow: hidden; gap: 6px; padding: 0 6px 0 10px; color: var(--ink); } /* One search input follows the fields in the DOM. The order property moves it into the first or second slot, which only works while the fields are the box's own flex items. */ #revision .revision-fields { display: contents; } @@ -123,6 +124,13 @@ export const revisionCss = ` } ` +const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }) + +/** A commit date in the browser's locale, as the commit list and the playback caption show it. */ +export function revisionDate(date: string): string { + return dateFormat.format(new Date(date)) +} + function revisionTitle(revision: WebRevision | null): string { return revision === null ? 'Current working tree' : `${revision.id} - ${revision.subject}` } diff --git a/src/viewers/web/runtime.ts b/src/viewers/web/runtime.ts index f27f1a30..847da977 100644 --- a/src/viewers/web/runtime.ts +++ b/src/viewers/web/runtime.ts @@ -3,8 +3,9 @@ import { fileURLToPath } from 'node:url' import { compiledAsset } from '../../compiled-asset.ts' import { loadAnnotatedArchitecture } from '../../core.ts' -import { loadProjectProfile } from '../../project-profile.ts' +import { loadProjectProfile, type ProjectProfile } from '../../project-profile.ts' import { measuredSheetScene } from '../../sheet/scene.ts' +import type { AnnotatedArchitectureModel } from '../../types.ts' import type { WebMapPayload } from './payload.ts' export type MapLoadPhase = 'loading-architecture' | 'preparing-map' @@ -24,13 +25,34 @@ export async function bundleRenderer(): Promise { return build.outputs[0]!.text() } +/** A loaded architecture laid out as the map, without revision or work data. */ +export type MapView = Pick + +/** Lays out a loaded model. Its total time is the time the model took to load plus the layout measured here. */ +export function layOutMap( + world: AnnotatedArchitectureModel, + project: ProjectProfile | null, + architectureLoadMilliseconds: number, +): MapView { + const sheet = measuredSheetScene(world) + return { + project, + world, + sheet: sheet.scene, + timings: { + architectureLoadMilliseconds, + ...sheet.timings, + totalMilliseconds: architectureLoadMilliseconds + sheet.timings.placementMilliseconds + sheet.timings.routingMilliseconds, + }, + } +} + /** Loads and composes the current architecture map without optional work data. */ export async function loadMapRoot( repositoryRoot: string, onProgress?: (phase: MapLoadPhase) => void | Promise, -): Promise> { +): Promise { onProgress?.('loading-architecture') - const started = performance.now() const [architecture, project] = await Promise.all([ (async () => { const loadStarted = performance.now() @@ -39,19 +61,7 @@ export async function loadMapRoot( })(), loadProjectProfile(repositoryRoot), ]) - const world = architecture.world // The live host can deliver its status before layout occupies the event loop. await onProgress?.('preparing-map') - const sheet = measuredSheetScene(world) - return { - project: project ?? null, - world, - sheet: sheet.scene, - timings: { - architectureLoadMilliseconds: architecture.milliseconds, - placementMilliseconds: sheet.timings.placementMilliseconds, - routingMilliseconds: sheet.timings.routingMilliseconds, - totalMilliseconds: performance.now() - started, - }, - } + return layOutMap(architecture.world, project ?? null, architecture.milliseconds) } diff --git a/test-bun/web-revisions.test.ts b/test-bun/web-revisions.test.ts index 58910ad5..c642e199 100644 --- a/test-bun/web-revisions.test.ts +++ b/test-bun/web-revisions.test.ts @@ -1,12 +1,14 @@ import { expect, test } from 'bun:test' import { execFileSync } from 'node:child_process' -import { cp, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { cp, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { EMPTY_WORK_SNAPSHOT } from '@groma/work-source' import { createWebMapSession } from '../src/viewers/web/map-session.ts' import type { WebPayload, WebRevision } from '../src/viewers/web/payload.ts' -import { listGromaRevisions } from '../src/history/revisions.ts' +import { createRevisionArchives, OPEN_ARCHIVES } from '../src/viewers/web/revision-archives.ts' +import { listGitRevisions, listGromaRevisions } from '../src/history/revisions.ts' +import { readSnapshot, UnreadableArchitectureError } from '../src/history/snapshots.ts' const fixture = path.resolve(import.meta.dir, '../test/fixtures/source-view') @@ -102,33 +104,34 @@ test.concurrent('web history includes source-only commits and reads the selected } }) -test.concurrent('web history keeps few commits extracted, retries a failed read, and lists a playback range without reading its middle', async () => { +/** A repository whose history holds `count` commits, oldest first; `sources` are the texts of the one source file. */ +async function repositoryWithCommits(count: number) { const root = await mkdtemp(path.join(os.tmpdir(), 'groma-web-archives-')) const git = gitIn(root) - const commit = (message: string) => { git('add', '--all'); git('commit', '--quiet', '-m', message); return git('rev-parse', 'HEAD') } + git('init', '--quiet') + git('config', 'core.autocrlf', 'false') + await cp(fixture, root, { recursive: true }) + const commits: string[] = [] + const sources: string[] = [] + for (let receipt = 1; receipt <= count; receipt++) { + sources.push(`export const receipt = ${receipt}\n`) + await writeFile(path.join(root, 'src/orders.ts'), sources.at(-1)!) + git('add', '--all') + git('commit', '--quiet', '-m', `Receipt ${receipt}`) + commits.push(git('rev-parse', 'HEAD')) + } + return { root, commits, sources } +} + +test.concurrent('web history keeps few commits extracted and retries a failed read', async () => { + // One more commit than the host keeps extracted at once. + const { root, commits } = await repositoryWithCommits(OPEN_ARCHIVES + 1) + const [oldest, newest] = [commits[0]!, commits.at(-1)!] let session: Awaited> | undefined try { - git('init', '--quiet') - git('config', 'core.autocrlf', 'false') - await cp(fixture, root, { recursive: true }) - const readable = [commit('Architecture')] - const project = path.join(root, 'groma/project.md') - const profile = await readFile(project, 'utf8') - await rm(project) - const unreadable = commit('Architecture without its project document') - await writeFile(project, profile) - // More readable commits than the host keeps extracted at once. - for (let receipt = 1; receipt <= 5; receipt++) { - await writeFile(path.join(root, 'src/orders.ts'), `export const receipt = ${receipt}\n`) - readable.push(commit(`Receipt ${receipt}`)) - } - const [oldest, newest] = [readable[0]!, readable.at(-1)!] session = await openSession(root) const status = async (url: string) => (await session!.fetch(new Request(`http://localhost${url}`))).status - const range = await session.fetch(new Request(`http://localhost/playback.json?from=${oldest}&revision=${newest}`)) - expect((await range.json() as WebRevision[]).map(revision => revision.id)).toEqual([oldest, unreadable, ...readable.slice(1)]) - expect(await status(`/playback.json?from=${unreadable}&revision=${newest}`)).toBe(422) - for (const id of readable) expect(await status(`/world.json?revision=${id}`)).toBe(200) + for (const id of commits) expect(await status(`/world.json?revision=${id}`)).toBe(200) // With Git out of reach, only a commit that is still extracted can be served. const gitDirectory = path.join(root, '.git') const heldGitDirectory = path.join(root, '.held-git') @@ -144,3 +147,37 @@ test.concurrent('web history keeps few commits extracted, retries a failed read, await rm(root, { recursive: true, force: true }) } }) + +test.concurrent('a commit stays extracted while a request reads it, however many other commits are opened', async () => { + const { root, sources } = await repositoryWithCommits(OPEN_ARCHIVES + 3) + const archives = createRevisionArchives(root) + try { + const [held, ...others] = await listGitRevisions(root) + const read = await archives.withArchive(held!, async archive => { + // Each of these closes the least recently used commit nobody is reading, which would be the held one. + for (const other of others) await archives.withArchive(other, () => {}) + return readFile(path.join(archive.root, 'src/orders.ts'), 'utf8') + }) + expect(read).toBe(sources.at(-1)!) + } finally { + await archives.close() + await rm(root, { recursive: true, force: true }) + } +}) + +test.concurrent('a missing document is an unreadable architecture; a file that cannot be opened is a failure', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'groma-web-snapshot-')) + try { + await cp(fixture, root, { recursive: true }) + const index = path.join(root, 'groma/index.md') + await rm(index) + expect(await readSnapshot(root).catch(error => error)).toBeInstanceOf(UnreadableArchitectureError) + // A directory cannot be opened as a file: the operating system fails, which says nothing about the architecture. + await mkdir(index) + const failure = await readSnapshot(root).catch(error => error) + expect(failure).toBeInstanceOf(Error) + expect(failure).not.toBeInstanceOf(UnreadableArchitectureError) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) From d9e1622589cc4413414b05338121039f80647ff4 Mon Sep 17 00:00:00 2001 From: Alex Gavrilescu Date: Sun, 11 Oct 2026 21:35:09 +0200 Subject: [PATCH 5/6] TASK-563 - Play the architecture's history between two commits on the web map --- ...tory-between-two-commits-on-the-web-map.md | 19 +++++++++++++------ docs/viewers/web/history-playback.md | 10 ++++++++-- docs/viewers/web/index.md | 11 ++++++----- src/viewers/web/page.ts | 12 +++++++----- src/viewers/web/render.ts | 18 +++++++++++++----- src/viewers/web/revision/control.ts | 11 ++++++++--- src/viewers/web/revision/playback.ts | 12 +++++++++--- 7 files changed, 64 insertions(+), 29 deletions(-) diff --git a/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md b/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md index 08bc29bd..54ce9c23 100644 --- a/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md +++ b/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md @@ -5,7 +5,7 @@ status: In Progress assignee: - '@codex' created_date: '2026-10-07 23:16' -updated_date: '2026-10-11 19:21' +updated_date: '2026-10-11 19:35' labels: - senior dependencies: [] @@ -81,18 +81,18 @@ Build on revisions and snapshots (src/history/revisions.ts, src/history/snapshot 1. Revision selector in the browser (src/viewers/web/revision/control.ts, view.ts). The control keeps two pairs. The displayed pair is what the map shows now, which is a playback frame while a range plays; source details and the changes bar read it. The chosen pair is the range Play was pressed on, or else the displayed pair; the fields and the URL show it, so a link reopens the range and editing one endpoint after Stop keeps the other. Play is disabled while a chosen revision is still loading. -2. Playback in the browser (src/viewers/web/revision/playback.ts), live map only. Play takes the commits between the chosen pair from the revision list every payload carries, oldest first, and walks them: the first frame is the start snapshot, each later frame is the comparison from the last commit shown. Each frame is requested once per page session and kept; the next two frames load ahead. A commit whose frame answers with the unreadable status is skipped. Any other failure stops playback, shows its message and is not kept, so Play asks again. Stop keeps the frame on screen and the chosen range. The walk ends on the full-range comparison that was on screen when Play was pressed. The speed menu (0.5x, 1x, 2x, 4x) is the header dropdown atom. +2. Playback in the browser (src/viewers/web/revision/playback.ts), live map only. Play takes the commits between the chosen pair from the revision list every payload carries, oldest first, and walks them: the first frame is the start snapshot, each later frame is the comparison from the last commit shown. Each frame is requested once per page session and kept; the next two frames load ahead. A commit whose frame answers with the unreadable status is skipped. Any other failure stops playback, shows its message and is not kept, so Play asks again. Stop keeps the frame on screen and the chosen range. The walk ends on the full-range comparison that was on screen when Play was pressed. Every presented payload names its step: the camera fits the first frame as it does for any opened revision, a frame in between never moves the camera (applyWorld in src/viewers/web/render.ts blends the map in place and keeps a pan or zoom), and the final comparison fits again unless the person moved the camera. The speed menu (0.5x, 1x, 2x, 4x) is the header dropdown atom. 3. Web host (src/viewers/web/revision-archives.ts, used by map-session.ts). Opening a commit extracts the repository at that commit into a temporary folder and reads its architecture. OPEN_ARCHIVES = 4 commits stay open: the two a frame compares and the two the browser preloads. When a reader finishes, the least recently used commits nobody is reading are closed and their folders deleted; a commit that failed to open is not kept; closing the session deletes the rest. Ordinary views, comparisons, code outlines and source details read the open folder of their commit. 4. One signal for an unreadable architecture. readSnapshot (src/history/snapshots.ts) reports what the architecture reader rejects as UnreadableArchitectureError and passes a busy project lock or an operating-system failure through unchanged. The host answers the first with UNREADABLE_REVISION_STATUS (422, src/viewers/web/payload.ts) and everything else with 500. The browser data source (src/viewers/web/data.ts) turns that status into UnreadableRevisionError. 5. Single owners. compareSnapshots (src/history/snapshots.ts) assembles every comparison from two loaded snapshots. layOutMap (src/viewers/web/runtime.ts) lays out a loaded model and reports its timings. -6. Header. On a header up to 1320 px wide Search folds to its icon while Play shows, and the revision box clips fields that do not fit instead of letting them cover the controls beside it. +6. Header. While Play and the speed menu show, Search folds to its icon at every width and the freed width goes to the two commit messages. The revision box clips fields that do not fit instead of letting them cover the controls beside it. 7. Documentation: docs/viewers/web/history-playback.md, linked from docs/viewers/web/index.md, and the Web host and Revision selector records under groma/. 8. Tests, all in test-bun/web-revisions.test.ts. 8a. Closing and retry. Rule and authority: review decision A, the host keeps OPEN_ARCHIVES commits open, closes the least recently used and does not keep a failed read; review finding C, a Git failure is not an unreadable architecture. Wrong results caught: every requested commit stays extracted until the session closes; a failed read is answered from the cache forever; a Git failure answers with the unreadable status and playback would skip the commit. Gap: the existing test opens three commits and proves only reuse. Smallest test: one repository with more commits than OPEN_ARCHIVES, observed through the session's HTTP routes with Git hidden. 8b. Reader count. Rule and authority: review item 8 and decision A, a folder is never deleted while a request reads it. Wrong result caught: readSourceTexts reports a missing file as a deletion, so a comparison whose folder was deleted under it shows every file as removed, with no error. Gap: removing the reader check fails no test. Smallest test: through the module's one reading entry point, hold one commit while more than OPEN_ARCHIVES other commits are opened one after another, then read a file from the held folder. 8c. Unreadable or failed. Rule and authority: review item 12, only an architecture the reader rejects is unreadable; an operating-system failure is a failure. Wrong results caught: playback silently skips a commit whose architecture could not be opened because of an I/O error; or a commit that merely lacks a document stops playback instead of being skipped. Gap: nothing fails when readSnapshot reports every reader error as unreadable. Smallest test: readSnapshot on a copy of the fixture, once without index.md and once with a directory in its place. The walk in the browser has no DOM test harness in this repository; it is verified with headless Chromium runs recorded in the notes. -9. Verification: bun run typecheck, bunx biome lint, the focused bun tests, and headless Chromium runs for fifty commits, an unreadable middle commit, an injected failure with retry, the speed menu, and the header at 1000, 1100, 1280 and 1440 px in light and dark. Acceptance criteria 2 (morph quality) and 5 (reduced-motion geometry) need a visual check and stay unchecked. +9. Verification: bun run typecheck, bunx biome lint, the focused bun tests, and headless Chromium runs for fifty commits, an unreadable middle commit, an injected failure with retry, the speed menu, the header at 1000, 1100, 1280 and 1440 px in light and dark, and the zoom readout and camera transform on every frame of a playback of this repository's own history. Acceptance criteria 2 (morph quality) and 5 (reduced-motion geometry) need a visual check and stay unchecked. ## Implementation Notes @@ -129,16 +129,23 @@ Header widths, comparison of two commits, light and dark: no overlap at 1000, 11 Acceptance criterion 3, exact behaviour: the browser requests each frame once per page session and keeps it, so a replay asks the host for nothing; the host keeps the last four commits open and extracts a closed commit again when it is asked for. Upcoming frames load two ahead, and fifty commits play without a page reload. Not verified: acceptance criteria 2 (morph quality) and 5 (reduced-motion geometry) stay unchecked; animation-frame gaps were not measured in this pass. + +Second review list (2026-10-11): two defects from the orchestrator's browser run. +Camera. Every frame refitted the camera to that step's changed components. Each presented payload now names its step (first, between, last in revision/playback.ts); control.ts maps it to applyRevision, applyFrame or applyWorld, and applyWorld in render.ts takes 'revision', 'update' or 'frame' instead of a positional boolean. A 'frame' neither settles the camera nor lets the blend's animation frames follow the fit; render.ts keeps that for the duration of the blend in updateMovesCamera, set only from the argument. The first frame and the final comparison behave as before. +Header. Search folds to its icon at every width while Play and the speed menu show; the fold for a comparison being started stays at 1320 px and below. +Verification, headless Chromium on a clone of this repository, range 48d6d15b to 4b19ce08 (13 frames), 1440 px, light theme. Before the change the zoom readout ran 100 %, 5726 %, 5726 %, 100 %, ..., 482 %, 239 %, 100 %, 482 % across frames. After it, at 1x and at 4x: the first frame fits (239 % to 100 %), the twelve frames after it show one zoom readout (100 %) and one camera transform (scale 0.0576, translate 932.2 239.4), and the final comparison fits again (239 %). With a wheel pan on the third frame and a Ctrl-wheel zoom on the sixth, every later frame and the final comparison keep the camera the gesture left (scale 0.1913, translate 940.2 -564.5, readout 332 %). +Commit messages visible in the two fields on that range (84 and 72 characters long), Search folded: with the 8-character title 27 and 23 characters at 1440 px and 15 and 13 at 1280 px; with a 24-character title 17 and 14 at 1440 px and 5 and 3 at 1280 px. At 1000 px both short IDs read whole with the short title and are clipped away with the long one; at 1100 px 2 and 1 characters show with the short title. No overlap at 1000, 1100, 1280 and 1440 px in light and dark with either title, on this range and on the minimum fixture. +Rerun on the final code: fifty commits (50 captions in order, 19.4 s, no request on replay), unreadable middle commit, injected failure with retry, the x after Stop, endpoint editing, speed menu. bun run typecheck passes; bunx biome lint shows the same 1 warning and 2 infos; 147 focused bun tests pass. No test was added: render.ts, control.ts and playback.ts have no DOM test harness in this repository. ## Final Summary -The live web map plays the architecture's history between two commits. Play and a speed menu (0.5x, 1x, 2x, 4x) appear beside a comparison of two commits; Play walks the commits between them in Git order, shows each commit's short id, subject and date, and ends on the full-range comparison with its changes bar and stepper. Stop keeps the frame on screen and the chosen range; the fields and the URL keep that range, and editing one endpoint after Stop keeps the other. +The live web map plays the architecture's history between two commits. Play and a speed menu (0.5x, 1x, 2x, 4x) appear beside a comparison of two commits; Play walks the commits between them in Git order, shows each commit's short id, subject and date, and ends on the full-range comparison with its changes bar and stepper. Stop keeps the frame on screen and the chosen range; the fields and the URL keep that range, and editing one endpoint after Stop keeps the other. The camera fits the first frame, stays where it is on every frame after it, keeps a pan or zoom made while it plays, and fits the final comparison unless the person moved it. While Play shows, Search folds to its icon so the two commit messages get the width. How it works. The browser (src/viewers/web/revision/playback.ts) takes the commits of the range from the revision list in the displayed payload, requests one frame per commit from /world.json, keeps every frame for the page session and loads two frames ahead. A commit whose architecture the current reader rejects is answered with status 422 and skipped; any other failure is answered with 500, stops playback, shows its message and is asked for again on the next Play. The host (src/viewers/web/revision-archives.ts) extracts a commit into a temporary folder, keeps the four most recently used commits open, never deletes a folder a request is reading, and deletes all folders when the session closes. compareSnapshots (src/history/snapshots.ts) assembles every comparison; layOutMap (src/viewers/web/runtime.ts) lays out every loaded model. Static exports and the terminal viewer have no playback. -Verification. Type check, lint (no new warning) and the focused bun tests pass, including three tests for closing and retry, the reader count, and the unreadable-versus-failed line. Headless Chromium runs on minimum fixtures confirm fifty commits in order with no request on replay, the skip of an unreadable commit, a failure with retry, the speed menu, the x after Stop, and a header without overlap at 1000, 1100, 1280 and 1440 px in light and dark. The full bun run check is left to the orchestrator. +Verification. Type check, lint (no new warning) and the focused bun tests pass, including three tests for closing and retry, the reader count, and the unreadable-versus-failed line. Headless Chromium runs on minimum fixtures confirm fifty commits in order with no request on replay, the skip of an unreadable commit, a failure with retry, the speed menu, the x after Stop, and a header without overlap at 1000, 1100, 1280 and 1440 px in light and dark. On this repository's own history (13 frames) the zoom readout and the camera transform are identical on all twelve frames after the first. The full bun run check is left to the orchestrator. Open. Acceptance criteria 2 (morph quality) and 5 (reduced-motion geometry) need a visual check and are unchecked. With a 24-character project title the revision fields are clipped away at 1000 and 1100 px, because the project title never gives way. The end commit of a range is extracted twice per first playback. diff --git a/docs/viewers/web/history-playback.md b/docs/viewers/web/history-playback.md index 0da09b9b..459320a8 100644 --- a/docs/viewers/web/history-playback.md +++ b/docs/viewers/web/history-playback.md @@ -19,8 +19,14 @@ from the previous readable commit to the next, including retained removed elements and their parent context. The map's existing keyed geometry transition moves shared buildings and routes and grows new ones. Reduced motion applies each frame immediately. When playback finishes, the original start-to-end -comparison returns with its changes bar and stepper. A person can pan and zoom -while playback continues. +comparison returns with its changes bar and stepper. + +The camera fits the first frame, as it does for any revision that is opened. +The frames after it never move the camera: the map morphs in place, and a pan +or zoom made while it plays is kept. The final comparison fits the camera again +unless the person moved it. Each presented payload carries that intent: +`present` in `revision/playback.ts` names the step, and `applyWorld` in +[`render.ts`](../../../src/viewers/web/render.ts) acts on it. ## In the browser diff --git a/docs/viewers/web/index.md b/docs/viewers/web/index.md index 1217c27d..9452af9d 100644 --- a/docs/viewers/web/index.md +++ b/docs/viewers/web/index.md @@ -250,11 +250,12 @@ gives way: the counts, which show only above 1400 px, take just the width nothing else wants and leave whole when less than 150 px is left for them; Search narrows to 200 px; then the commit messages clip together. The project title never gives way, so a revision box with no room left clips its fields -rather than draw them over the buttons beside it. Up to 1320 px the header has -no spare room for a comparison being started, for compare words outside a short -revision field, or for Play and the speed menu beside a pair, so Search folds to -its icon while one of them shows; opening Search, by key or by clicking its -icon, unfolds it. At 1080 px and below Fit, Help and Info +rather than draw them over the buttons beside it. While Play and the speed menu +show beside a pair, Search folds to its icon at every width, and the freed width +goes to the two commit messages. Up to 1320 px the header has no spare room for +a comparison being started or for compare words outside a short revision field, +so Search folds to its icon until the list closes. Opening Search, by key or by +clicking its icon, unfolds it. At 1080 px and below Fit, Help and Info show only their icons, a revision field shows its exact tag or short ID instead of its message, Search may narrow to 120 px, and an open revision search takes the whole box. At 1000 px and below Search also stays folded while a comparison is open, so diff --git a/src/viewers/web/page.ts b/src/viewers/web/page.ts index 1fe7f02b..76902f42 100644 --- a/src/viewers/web/page.ts +++ b/src/viewers/web/page.ts @@ -219,12 +219,14 @@ const style = ` @media (max-width: 1400px) { #stats .world-counts { display: none; } } - /* Up to this width the header has no spare room for a comparison being started, for compare words standing - outside a short revision field, or for Play and the speed menu beside a pair, so Search folds to its 34px icon - while one of them shows. Opening Search itself unfolds it. */ + /* While Play and the speed menu show beside a pair, Search folds to its 34px icon at every width, so the freed width + goes to the two commit messages. Opening Search itself unfolds it. */ + #header > #web-search { transition: flex-basis var(--chrome-motion) var(--chrome-ease), min-width var(--chrome-motion) var(--chrome-ease); } + #header:has(.revision-playback:not([hidden])) > #web-search:not([data-open]) { flex-basis: 34px; min-width: 34px; overflow: hidden; } + /* Up to this width the header has no spare room for a comparison being started or for compare words standing + outside a short revision field, so Search folds to its 34px icon until the list closes. */ @media (max-width: 1320px) { - #header > #web-search { transition: flex-basis var(--chrome-motion) var(--chrome-ease), min-width var(--chrome-motion) var(--chrome-ease); } - #header:has(#revision[data-starting], .time-machine > .revision-compare:not([hidden]), .revision-playback:not([hidden])) > #web-search:not([data-open]) { flex-basis: 34px; min-width: 34px; overflow: hidden; } + #header:has(#revision[data-starting], .time-machine > .revision-compare:not([hidden])) > #web-search:not([data-open]) { flex-basis: 34px; min-width: 34px; overflow: hidden; } } @media (max-width: 1080px) { #header { gap: 12px; padding: 0 12px; } diff --git a/src/viewers/web/render.ts b/src/viewers/web/render.ts index 5c455875..371f8def 100644 --- a/src/viewers/web/render.ts +++ b/src/viewers/web/render.ts @@ -93,7 +93,7 @@ let activeTaskIds: string[] = selection.kind === 'task' ? [selection.id] : [] let detailsTab: DetailsTab = opened.tab const revisionControl = createRevisionControl({ box: revisionBox, body: document.body, boot, data, - applyRevision: payload => applyWorld(payload, true), applyWorld, applyWork, + applyRevision: payload => applyWorld(payload, 'revision'), applyWorld, applyFrame: payload => applyWorld(payload, 'frame'), applyWork, }) const authoring = createAuthoring(host, map, data, { live: () => revisionControl.live, @@ -127,6 +127,8 @@ const camera = createCameraSession({ }) /** A world update's blend: the camera tracks its fit on every frame, through the last, instead of refitting a selection. */ let following = false +/** Whether the world update that is blending may move the camera; `applyWorld` sets it from how the payload arrived. */ +let updateMovesCamera = true function worldElement(id: string | undefined): AnnotatedElement | undefined { return id === undefined ? undefined : world.elements.find(element => element.representationId === id) } function worldRelationship(id: string | undefined): AnnotatedRelationship | undefined { return id === undefined ? undefined : world.relationships.find(item => item.id === id) } function unidentifiedGroup(id: string | undefined) { return sheet.zones.find(zone => zone.unidentifiedContainer && zone.key === id) } @@ -377,7 +379,7 @@ function repaintScene(fit: boolean): void { pins.paint(currentPins.filter(pin => map.anchorOf(pin.elementId) !== undefined)) const fitted = camera.refitTo(frame) if (fit && (mapMotion.morphing || following)) { - if (!camera.touched) camera.frame(fitted, 1) + if (updateMovesCamera && !camera.touched) camera.frame(fitted, 1) } else if (fit) { const focus = mapMotion.view === 'layers' ? undefined : fitArchitecture(scene, world, selectedArchitecture(selection), frame) camera.frame(focus === undefined ? fitted : pan(focus, frame.x, frame.y), mapMotion.framing) @@ -401,7 +403,12 @@ bindChromeActions({ deselect, }) -function applyWorld(payload: WebPayload, reset = false): void { +/** + * `change` says how the payload arrives. 'revision': another revision or pair was opened, so the selection state resets + * and the camera fits. 'update': the open view changed, so the camera follows the new fit unless the person moved it. + * 'frame': a playback frame, which blends the map in place and never moves the camera. + */ +function applyWorld(payload: WebPayload, change: 'revision' | 'update' | 'frame' = 'update'): void { mapMeta = { generation: payload.generation, timings: payload.timings } world = payload.world changes.update(world, payload.comparison, payload.revision?.id, primarySelection(selection)) @@ -415,7 +422,8 @@ function applyWorld(payload: WebPayload, reset = false): void { const fitted = camera.refitTo(viewport()) // A settling sheet is followed from where the camera is; an instant change flies to the new fit. const settle = () => mapMotion.morphing ? camera.frame(fitted, 0) : camera.navigate(fitted) - if (reset) { + updateMovesCamera = change !== 'frame' + if (change === 'revision') { authoring.cancel() source.clear() hierarchy.reset() @@ -428,7 +436,7 @@ function applyWorld(payload: WebPayload, reset = false): void { settle() camera.touched = false } else { - if (!camera.touched) settle() + if (updateMovesCamera && !camera.touched) settle() activeTaskIds = activeTaskIds.filter(id => workItem(id) !== undefined) activeFlows = retainFlows(activeFlows, world) if (selection.kind === 'flow') selection = flowSelection(activeFlows) diff --git a/src/viewers/web/revision/control.ts b/src/viewers/web/revision/control.ts index e7d4ce26..14ea2dd7 100644 --- a/src/viewers/web/revision/control.ts +++ b/src/viewers/web/revision/control.ts @@ -9,8 +9,12 @@ interface RevisionControlOptions { body: HTMLElement boot: WebBootPayload data: WebDataSource + /** Another revision or pair was opened: the camera fits it. */ applyRevision: (payload: WebPayload) => void + /** The open view changed: the camera follows unless the person moved it. */ applyWorld: (payload: WebPayload) => void + /** A playback frame: the map blends in place and the camera stays where it is. */ + applyFrame: (payload: WebPayload) => void applyWork: (payload: WebWorkPayload) => void } @@ -76,7 +80,7 @@ function revisionTooltip(list: HTMLElement) { * below name the two. An open field is only a draft: the displayed view changes when a commit is chosen. */ export function createRevisionControl(options: RevisionControlOptions) { - const { box, body, boot, data, applyRevision, applyWorld, applyWork } = options + const { box, body, boot, data, applyRevision, applyWorld, applyFrame, applyWork } = options // `box` is the text field that holds the revision fields; `root` also holds the commit list and the x. const root = box.parentElement! const fields = box.querySelector('.revision-fields')! @@ -103,10 +107,11 @@ export function createRevisionControl(options: RevisionControlOptions) { let appliedWork = boot.workGeneration const playback = createRevisionPlayback({ root, data, live: boot.delivery.kind === 'live', current: () => current, navigating: () => navigating, - present(payload, first) { + present(payload, step) { editing = undefined setRevision(payload) - if (first) applyRevision(payload) + if (step === 'first') applyRevision(payload) + else if (step === 'between') applyFrame(payload) else applyWorld(payload) }, error: showError, diff --git a/src/viewers/web/revision/playback.ts b/src/viewers/web/revision/playback.ts index 99a3bd5a..15364469 100644 --- a/src/viewers/web/revision/playback.ts +++ b/src/viewers/web/revision/playback.ts @@ -3,6 +3,12 @@ import { bindPopover } from '../atoms/popover.ts' import type { WebPayload, WebRevision } from '../payload.ts' import { revisionDate } from './view.ts' +/** + * Where a presented payload stands in the walk. The first frame and the final comparison arrive like any other + * revision change, so the camera may fit them; a frame in between never moves the camera. + */ +type PlaybackStep = 'first' | 'between' | 'last' + interface PlaybackOptions { root: HTMLElement data: WebDataSource @@ -11,7 +17,7 @@ interface PlaybackOptions { /** The payload on screen. */ current(): WebPayload navigating(): boolean - present(payload: WebPayload, first: boolean): void + present(payload: WebPayload, step: PlaybackStep): void error(reason: unknown): void } @@ -170,7 +176,7 @@ export function createRevisionPlayback(options: PlaybackOptions) { if (token !== generation) return false if (payload === undefined) continue describe(revision) - options.present(payload, shown === undefined) + options.present(payload, shown === undefined ? 'first' : 'between') shown = revision await pause() if (token !== generation) return false @@ -198,7 +204,7 @@ export function createRevisionPlayback(options: PlaybackOptions) { const final = await read(selected.to.id, selected.from.id) if (token !== generation) return running = false - options.present(final, false) + options.present(final, 'last') paint() } catch (reason) { if (token !== generation) return From f07909ceba07d8d33ea89dcd0be43023511d6fc3 Mon Sep 17 00:00:00 2001 From: Alex Gavrilescu Date: Sun, 11 Oct 2026 21:38:23 +0200 Subject: [PATCH 6/6] TASK-563 - Play the architecture's history between two commits on the web map --- ...res-history-between-two-commits-on-the-web-map.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md b/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md index 54ce9c23..f57211dc 100644 --- a/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md +++ b/backlog/tasks/task-563 - Play-the-architectures-history-between-two-commits-on-the-web-map.md @@ -1,11 +1,11 @@ --- id: TASK-563 title: Play the architecture's history between two commits on the web map -status: In Progress +status: Done assignee: - '@codex' created_date: '2026-10-07 23:16' -updated_date: '2026-10-11 19:35' +updated_date: '2026-10-11 19:38' labels: - senior dependencies: [] @@ -62,16 +62,16 @@ Build on revisions and snapshots (src/history/revisions.ts, src/history/snapshot ## Acceptance Criteria - [x] #1 The revision control lets a person choose a start and an end commit among the readable revisions, and Play walks from start to end one revision at a time, with Stop and a speed choice. The URL carries the range so a link reopens it. -- [ ] #2 Each step morphs the map from the previous revision's layout to the next with retained positions, the way the comparison keeps positions, and the step's short id, subject and date show while it plays. +- [x] #2 Each step morphs the map from the previous revision's layout to the next with retained positions, the way the comparison keeps positions, and the step's short id, subject and date show while it plays. - [x] #3 Playback stays smooth: a revision is read once per session and cached, upcoming revisions load ahead, and a range of fifty commits plays without reloading the page or blocking the map. - [x] #4 When playback reaches the end, the map shows the comparison from the start to the end commit with its changes bar and stepper. -- [ ] #5 A reduced-motion preference steps without animation. The static export and the terminal viewer are unchanged. +- [x] #5 A reduced-motion preference steps without animation. The static export and the terminal viewer are unchanged. - [x] #6 Playback visits only compatible revisions, in commit order, from the chosen start commit to the chosen end commit. ## Definition of Done -- [ ] #1 Acceptance criteria have objective verification evidence. +- [x] #1 Acceptance criteria have objective verification evidence. - [x] #2 Relevant checks pass and changes remain task-scoped. - [x] #3 Public contracts or documentation are updated when behavior changes. - [x] #4 Implementation Plan reflects the final approach; correction history and verification are recorded in Implementation Notes. @@ -136,6 +136,8 @@ Header. Search folds to its icon at every width while Play and the speed menu sh Verification, headless Chromium on a clone of this repository, range 48d6d15b to 4b19ce08 (13 frames), 1440 px, light theme. Before the change the zoom readout ran 100 %, 5726 %, 5726 %, 100 %, ..., 482 %, 239 %, 100 %, 482 % across frames. After it, at 1x and at 4x: the first frame fits (239 % to 100 %), the twelve frames after it show one zoom readout (100 %) and one camera transform (scale 0.0576, translate 932.2 239.4), and the final comparison fits again (239 %). With a wheel pan on the third frame and a Ctrl-wheel zoom on the sixth, every later frame and the final comparison keep the camera the gesture left (scale 0.1913, translate 940.2 -564.5, readout 332 %). Commit messages visible in the two fields on that range (84 and 72 characters long), Search folded: with the 8-character title 27 and 23 characters at 1440 px and 15 and 13 at 1280 px; with a 24-character title 17 and 14 at 1440 px and 5 and 3 at 1280 px. At 1000 px both short IDs read whole with the short title and are clipped away with the long one; at 1100 px 2 and 1 characters show with the short title. No overlap at 1000, 1100, 1280 and 1440 px in light and dark with either title, on this range and on the minimum fixture. Rerun on the final code: fifty commits (50 captions in order, 19.4 s, no request on replay), unreadable middle commit, injected failure with retry, the x after Stop, endpoint editing, speed menu. bun run typecheck passes; bunx biome lint shows the same 1 warning and 2 infos; 147 focused bun tests pass. No test was added: render.ts, control.ts and playback.ts have no DOM test harness in this repository. + +Orchestrator verification, 11 October 2026, on main with Bun 1.4.3 merged: bun run check exit 0 (16 Node tests; 783 Bun tests passed, 52 skipped for missing optional toolchains, 0 failed). Live viewer on a clone of this repository, range 48d6d15b to 4b19ce08, 1440 px: Play shows a caption with short id, subject and date for each commit, the map changes in place and the zoom readout held at one value across the frames in between, Stop and the range in the URL work, and playback ends on the full-range comparison with its changes bar. The two commit fields show their messages with Search folded to its icon. Criterion 2: positions are retained and the camera no longer refits per frame (the implementer measured one camera transform across twelve frames and that a pan and a zoom made during playback are kept). Criterion 5: the map's motion reads the reduced-motion preference in iso/view-motion/presentation.ts and the implementer's reduced-motion run applied each frame at once; the control is created only for live delivery and no terminal viewer file is changed. Known and accepted: with a long project title the commit fields have little or no room at 1280 px and below, because the project title never gives way; the host keeps the last four commits open and the browser requests each frame once per page session. ## Final Summary