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..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,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-11 19:38' labels: - senior dependencies: [] @@ -18,6 +19,34 @@ references: - src-core - web-server - web-page + - src/viewers/web/revision/control.ts + - src/viewers/web/revision/playback.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 + - 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 + - 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 + - src/viewers/web/runtime.ts + - src/viewers/web/page.ts type: feature ordinal: 3 --- @@ -32,18 +61,93 @@ 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. -- [ ] #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. -- [ ] #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] #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] #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. +- [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. -- [ ] #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] #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. + +## Implementation Plan + + +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. 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. 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, 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 + + +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. +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. +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. + +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. + +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 + + +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. 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 new file mode 100644 index 00000000..459320a8 --- /dev/null +++ b/docs/viewers/web/history-playback.md @@ -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. diff --git a/docs/viewers/web/index.md b/docs/viewers/web/index.md index a65a9623..9452af9d 100644 --- a/docs/viewers/web/index.md +++ b/docs/viewers/web/index.md @@ -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 @@ -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 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..b765d2b9 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-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 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..5e5a8983 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, 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 f40a2e6b..19c830ab 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() } +} + +/** 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. */ diff --git a/src/history/snapshots.ts b/src/history/snapshots.ts index 6535d2c2..e8ebbb27 100644 --- a/src/history/snapshots.ts +++ b/src/history/snapshots.ts @@ -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(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 } +/** + * 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 isIoFailure(error) ? error : new UnreadableArchitectureError(error) + } } /** Missing files are meaningful deletions; other read failures remain visible. */ @@ -28,13 +65,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 560dc48d..8aa6b600 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' @@ -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(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 044f96ab..171fb35e 100644 --- a/src/viewers/web/map-session.ts +++ b/src/viewers/web/map-session.ts @@ -11,11 +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, withGitRevision } from '../../history/revisions.ts' +import { listGitRevisions } from '../../history/revisions.ts' import { renderPage } from './page.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' @@ -28,9 +30,12 @@ async function structureResponse( repositoryRoot: string, selected: Pick, element: string, + archives: 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 archives.structure(selected.revision, selected.world, element) return structure === undefined ? new Response('Component not found', { status: 404 }) : Response.json(structure) @@ -44,10 +49,13 @@ async function sourceResponse( selected: Pick, element: string, file: string | null, + archives: 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 archives.source(selected.revision, selected.world, element, file) return source === undefined ? new Response('Source file not found', { status: 404 }) : Response.json(source) @@ -59,13 +67,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. */ @@ -75,6 +80,7 @@ export async function createWebMapSession( ): Promise<{ fetch: (request: Request) => Promise; close: () => Promise }> { options.onProgress?.({ phase: 'preparing-viewer' }) const renderer = await bundleRenderer() + const archives = createRevisionArchives(repositoryRoot) const workSource = options.workSource ?? backlogPlugin.create(repositoryRoot) let revisions: WebRevision[] = [] let revisionRead: Promise | undefined @@ -120,17 +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 snapshot = await loadMap(repositoryRoot, revisions, revision) - 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) } } @@ -157,15 +164,22 @@ 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 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) } 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 { @@ -178,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) } } @@ -191,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) } } @@ -210,8 +224,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, archives) + : sourceResponse(repositoryRoot, selected, element, file, archives) } function worldEvent(): Uint8Array { @@ -235,7 +249,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, @@ -275,7 +289,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) }) @@ -452,6 +466,7 @@ export async function createWebMapSession( worldChain, workChain, revisionRead, + archives.close(), covers, ]) }, diff --git a/src/viewers/web/page.ts b/src/viewers/web/page.ts index bd6a18f8..76902f42 100644 --- a/src/viewers/web/page.ts +++ b/src/viewers/web/page.ts @@ -219,11 +219,13 @@ const style = ` @media (max-width: 1400px) { #stats .world-counts { display: none; } } + /* 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. Opening Search itself - unfolds it. */ + 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])) > #web-search:not([data-open]) { flex-basis: 34px; min-width: 34px; overflow: hidden; } } @media (max-width: 1080px) { 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/render.ts b/src/viewers/web/render.ts index 79849cff..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) } @@ -145,8 +147,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.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, @@ -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-archives.ts b/src/viewers/web/revision-archives.ts new file mode 100644 index 00000000..2a2e4215 --- /dev/null +++ b/src/viewers/web/revision-archives.ts @@ -0,0 +1,133 @@ +import { openGitRevision } from '../../history/revisions.ts' +import { compareSnapshots, readSnapshot, type Snapshot } from '../../history/snapshots.ts' +import type { ArchitectureGraph } from '../../types.ts' +import { readSource } from '../source/read.ts' +import { readSnapshotCodeStructure } from '../source/structure.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 (`preload` in + * revision/playback.ts). + */ +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?: MapView + /** 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: a + * folder deleted under a reader makes every file look deleted, with no error. + */ + readers: number +} + +/** + * 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. */ + function hold(revision: WebRevision): Entry { + let entry = entries.get(revision.id) + if (entry === undefined) { + entry = { opened: open(revision), readers: 0 } + // A commit that failed to open is not kept, so the next request tries again. + void entry.opened.catch(() => entries.delete(revision.id)) + } + // 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) + } + } + + /** 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 { + 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 ??= layOutMap(archive.world, archive.project, archive.architectureLoadMilliseconds) + return archive.view + }) + } + + /** 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) { + return withArchive(revision, archive => readSnapshotCodeStructure(repositoryRoot, archive.root, world, element)) + } + + 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)) + } + + /** 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 { withArchive, view, compare, structure, source, close } +} diff --git a/src/viewers/web/revision/control.ts b/src/viewers/web/revision/control.ts index a6847722..14ea2dd7 100644 --- a/src/viewers/web/revision/control.ts +++ b/src/viewers/web/revision/control.ts @@ -1,15 +1,20 @@ 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, revisionDate, revisionFields, revisionOptions, snapshotNotice, type RevisionField } from './view.ts' interface RevisionControlOptions { box: HTMLElement 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 } @@ -26,9 +31,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) } } @@ -71,11 +75,12 @@ 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 + 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')! @@ -100,12 +105,29 @@ export function createRevisionControl(options: RevisionControlOptions) { let navigating = false let pendingWorld: WebPayload | undefined let appliedWork = boot.workGeneration + const playback = createRevisionPlayback({ + root, data, live: boot.delivery.kind === 'live', current: () => current, navigating: () => navigating, + present(payload, step) { + editing = undefined + setRevision(payload) + if (step === 'first') applyRevision(payload) + else if (step === 'between') applyFrame(payload) + else applyWorld(payload) + }, + 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 ?? '' + 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 + 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 { @@ -118,9 +140,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 = 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. @@ -151,17 +173,19 @@ 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 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() + end.hidden = chosen.from === undefined && !starting() end.title = starting() ? 'Cancel comparison' : 'End comparison' end.setAttribute('aria-label', end.title) menu.hidden = !opened placeMenu() + playback.paint() } function close(): void { @@ -219,6 +243,7 @@ export function createRevisionControl(options: RevisionControlOptions) { } function open(field: RevisionField): void { + 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 @@ -238,13 +263,13 @@ export function createRevisionControl(options: RevisionControlOptions) { 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() } @@ -252,6 +277,7 @@ export function createRevisionControl(options: RevisionControlOptions) { async function load(revision?: string, starting?: string, reset = true): Promise { const loading = ++request navigating = true + playback.clear() error.hidden = true hideTooltip() box.setAttribute('aria-busy', 'true') @@ -268,6 +294,7 @@ export function createRevisionControl(options: RevisionControlOptions) { } finally { if (loading === request) { navigating = false + playback.paint() box.removeAttribute('aria-busy') const pending = pendingWorld pendingWorld = undefined @@ -277,8 +304,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(chosenRevision(), revision) + else if (editing === 'to') void load(revision || undefined, chosenFrom()) else void load(revision || undefined) } @@ -312,7 +339,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', () => { if (starting()) close(); else void load(selected()) }) + 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) @@ -340,6 +367,8 @@ export function createRevisionControl(options: RevisionControlOptions) { return { get selected() { return selected() }, get from() { return from() }, + 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 new file mode 100644 index 00000000..15364469 --- /dev/null +++ b/src/viewers/web/revision/playback.ts @@ -0,0 +1,218 @@ +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' + +/** + * 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 + /** 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, step: PlaybackStep): void + 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' + // 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.element, caption) + root.append(control) + + const keyOf = (revision: string, from?: string) => JSON.stringify([revision, from]) + + 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) + // 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 + } + + /** 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() + 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 = playable() === undefined + button.textContent = running ? 'Stop' : 'Play' + button.setAttribute('aria-label', running ? 'Stop history playback' : 'Play history') + button.disabled = options.navigating() + } + + /** Stops playing. The frame on screen and the chosen range stay. */ + function stop(): void { + generation++ + running = false + wake?.() + 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) + function done(): void { window.clearTimeout(timer); wake = undefined; resolve() } + wake = done + }) + } + + function describe(revision: WebRevision): void { + caption.textContent = `${revision.shortId} · ${revision.subject} · ${revisionDate(revision.date)}` + caption.hidden = false + } + + /** + * 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] + if (revision !== undefined) void read(revision.id, revisions[next - 1]!.id) + } + } + + /** 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 frame(revision, shown) + if (token !== generation) return false + if (payload === undefined) continue + describe(revision) + options.present(payload, shown === undefined ? 'first' : 'between') + shown = revision + await pause() + if (token !== generation) return false + } + return true + } + + async function play(): Promise { + 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 + paint() + try { + // 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 + running = false + options.present(final, 'last') + paint() + } catch (reason) { + if (token !== generation) return + stop() + options.error(reason) + } + } + + button.addEventListener('click', () => { if (running) stop(); else void play() }) + return { paint, chosen, stop, clear } +} diff --git a/src/viewers/web/revision/view.ts b/src/viewers/web/revision/view.ts index da83825c..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; } @@ -64,6 +65,17 @@ 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-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); + 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); } } @@ -112,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}` } @@ -140,9 +159,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. */ @@ -157,5 +176,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/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 be791a99..c642e199 100644 --- a/test-bun/web-revisions.test.ts +++ b/test-bun/web-revisions.test.ts @@ -1,16 +1,32 @@ import { expect, test } from 'bun:test' import { execFileSync } from 'node:child_process' -import { cp, mkdtemp, readFile, 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') + +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 +35,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 +48,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,6 +73,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) + // 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) + 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() @@ -81,3 +103,81 @@ test.concurrent('web history includes source-only commits and reads the selected await rm(root, { recursive: true, force: true }) } }) + +/** 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) + 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 { + session = await openSession(root) + const status = async (url: string) => (await session!.fetch(new Request(`http://localhost${url}`))).status + 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') + 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 }) + } +}) + +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 }) + } +})