Skip to content

Large diffs are not rendered by default.

85 changes: 85 additions & 0 deletions docs/viewers/web/history-playback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# 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
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
`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.

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

[`revision/control.ts`](../../../src/viewers/web/revision/control.ts) owns the
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, chosen range, and a cache of the frames it
has requested. Stopping invalidates pending frame applications without
discarding their cached data.

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 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. 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 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` 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
documents remain the portable history; the browser revision selector and web
host own its playback interpretation.
17 changes: 11 additions & 6 deletions docs/viewers/web/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,16 +249,20 @@ 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
title never gives way, so a revision box with no room left clips its fields
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
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
Expand Down Expand Up @@ -335,7 +339,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@ groma:
symbol: renderSetupPage
- scanner: typescript
file: src/viewers/web/startup/scanners.ts
- scanner: typescript
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.
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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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

Expand Down
27 changes: 20 additions & 7 deletions src/history/revisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
repositoryRoot: string,
revisionId: string,
paths: string[],
load: (snapshotRoot: string) => Promise<T>,
): Promise<T> {
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() }
}

/** 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, [])
}

/** Loads through a complete, temporary repository snapshot and always removes it afterwards. */
Expand Down
65 changes: 55 additions & 10 deletions src/history/snapshots.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,57 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { ArchitectureReadError } from '../architecture-reader.ts'
import { loadAnnotatedArchitecture } from '../core.ts'
import { loadProjectProfile } from '../project-profile.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'
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 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) {
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<T>(root: string, revision: GitRevision | null, read: (root: string) => Promise<T>): Promise<T> {
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 }
/**
* 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<Snapshot> {
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 isIoFailure(error) ? error : new UnreadableArchitectureError(error)
}
}

/** Missing files are meaningful deletions; other read failures remain visible. */
Expand All @@ -28,13 +65,21 @@ export async function readSourceTexts(root: string, files: string[]): Promise<So
})))
}

/** Read both complete roots while their archives exist, including files no longer owned on one side. */
/**
* Assembles the comparison of two loaded snapshots; `from` is the revision `before` was read from. Both folders must
* still exist: each side also reads the files only the other side owns.
*/
export async function compareSnapshots(from: GitRevision | null, before: Snapshot, after: Snapshot) {
const files = [...new Set([...ownedFiles(before.world), ...ownedFiles(after.world)])]
const [oldSources, newSources] = await Promise.all([readSourceTexts(before.root, files), readSourceTexts(after.root, files)])
const { world, components, relationships } = compareArchitecture(before.world, after.world, oldSources, newSources)
return { project: after.project, world, comparison: { from, components, relationships } }
}

/** Compares two revisions that are read for this call only; each commit's archive is removed afterwards. */
export function readComparison(root: string, from: GitRevision | null, to: GitRevision | null) {
return atRevision(root, from, beforeRoot => 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)
}))
}
6 changes: 5 additions & 1 deletion src/viewers/web/data.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -29,8 +29,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<T>(path: string): Promise<T> {
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<T>
}
Expand Down
Loading
Loading