diff --git a/Cargo.lock b/Cargo.lock index f447f1cc..0a4f6c46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,9 +61,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -537,9 +537,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -652,9 +652,9 @@ checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "colored" @@ -4296,9 +4296,9 @@ dependencies = [ [[package]] name = "sdd" -version = "4.7.4" +version = "4.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29768e2b8c67de4b243612538a62e021aab09a461ef11f9c6c07a05887a4c80a" +checksum = "e6ca0e33fc1ae39e36b2d1fdfc8ee470b26397b642ff87572a59a36ff4f2340b" [[package]] name = "sec1" @@ -4827,9 +4827,9 @@ dependencies = [ [[package]] name = "sys_traits" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5410f31d223892c1ce7a098da845c99d023b4c7f18632bc8f09e60dfae3cbb75" +checksum = "24954f769b36305ab4dc502d095da5cfd9c697c73a8f2c0636a7fca76ff1f24b" dependencies = [ "sys_traits_macros", ] @@ -5013,9 +5013,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] diff --git a/claude-notes/plans/2026-03-15-replay-widget.md b/claude-notes/plans/2026-03-15-replay-widget.md new file mode 100644 index 00000000..274a48b4 --- /dev/null +++ b/claude-notes/plans/2026-03-15-replay-widget.md @@ -0,0 +1,237 @@ +# Replay Widget Implementation Plan + +## Overview + +Add a document history replay feature to hub-client. The replay widget lives in a bottom drawer that expands to reveal a media-player-style timeline. When activated, the user enters **replay mode**: a read-only state where they can scrub through the entire Automerge change history. An **Apply** button exits replay mode and applies the viewed historical state as a new Automerge change. Collapsing the drawer exits replay mode without changes. + +## Architecture + +### Key Automerge APIs (verified against installed `@automerge/automerge-repo@2.5.1`) + +| API | Source | Purpose | +|-----|--------|---------| +| `handle.history()` | `DocHandle.d.ts:103` | Returns `UrlHeads[] | undefined` — topologically sorted array of every change's heads (undefined if doc not ready) | +| `handle.view(heads)` | `DocHandle.d.ts:117` | Returns a read-only `DocHandle` at the given heads | +| `handle.doc()` | `DocHandle.d.ts:79` | Returns the current `Doc` | +| `handle.metadata(change?)` | `DocHandle.d.ts:143` | Returns `DecodedChange \| undefined` with timestamp, message, actor (**@hidden API — may be unstable**). Takes a single change hash `string`, NOT `UrlHeads`. | +| `handle.change(fn)` | `DocHandle.d.ts:184` | Apply a new change to the document | +| `handle.diff(first, second?)` | `DocHandle.d.ts:131` | Returns `Patch[]` between two heads | +| `updateText(doc, ['text'], content)` | `@automerge/automerge-repo` | Incremental text update | + +### Document Schema + +File documents use the `TextDocumentContent` interface from `@quarto/quarto-automerge-schema`: +```typescript +interface TextDocumentContent { + text: string; // Automerge Text type serializes to string +} +``` + +To extract text content from a viewed handle: +```typescript +const viewedHandle = handle.view(history[index]); +const doc = viewedHandle.doc(); +if (doc && isTextDocument(doc)) { + const text = doc.text || ''; +} +``` + +The `isTextDocument()` type guard (from `@quarto/quarto-automerge-schema`) checks for the `text` field. This is the same pattern used throughout `client.ts` (lines 157, 186, 211, 364). + +### Sync Strategy: UI-Level Guard (NOT Network Pause) + +> **IMPORTANT LESSON LEARNED**: The original plan proposed using `repo.networkSubsystem.disconnect()` / `reconnect()` to pause sync during replay. This was **wrong**. Investigation revealed that `NetworkSubsystem.disconnect()` calls `adapter.disconnect()` on each network adapter, which in `BrowserWebSocketClientAdapter` **closes the WebSocket** (`socket.close()`) and emits `peer-disconnected` events. This is destructive — it kills the connection to the server, can trigger the app's "Connection lost" error handler in `App.tsx`, and makes the app unresponsive. +> +> The correct approach is to **leave the network alone** and guard at the UI level instead: +> - The `useReplayMode` hook does NOT call `pauseSync()` or `resumeSync()` +> - The Automerge content sync effect in `Editor.tsx` is guarded with `if (replayState.isActive) return;` to skip updates during replay +> - Sync continues in the background; incoming changes are absorbed by Automerge but not pushed to Monaco +> - On exit, the guard lifts and the sync effect naturally re-syncs Monaco with the current Automerge state +> +> The `pauseSync()` / `resumeSync()` functions were still added to `client.ts` and `automergeSync.ts` (they are valid lower-level operations), but they are **not used by the replay feature**. + +The existing `getFileHandle(path)` already returns `DocHandle | null`, which (when non-null) has `history()` and `view()` methods directly on it. The replay hook uses this existing path. **The `null` return must be guarded** — `enter()` is a no-op if the handle is not available. + +### State Management + +Follow existing pattern: a `useReplayMode` hook (like `usePresence`, `usePreference`) that encapsulates all replay logic. No new context provider needed — the hook returns everything the UI components need. + +### UI Location + +A **bottom drawer** that slides up from the bottom of `.editor-container`. This is separate from the sidebar (which is for navigation/panels) and is architecturally more appropriate for a transient media-player control. The drawer has two states: +- **Collapsed**: A thin bar at the bottom with a clock/history icon and "History" label. Clicking expands. +- **Expanded**: A ~80px tall panel with timeline scrubber, play/pause, step buttons, timestamp display, and Apply button. + +## Design Decisions + +1. **Per-file replay**: History is per Automerge document (per file), not project-wide. The replay widget operates on the currently selected file's `DocHandle`. This matches Automerge's data model where each file is a separate document. + +2. **No animation/playback timer library**: Use `setInterval` for play mode (auto-advance through history). The native `` serves as the timeline scrubber — no need for a slider library. + +3. **Apply = overwrite current text**: The Apply button reads the text content at the viewed historical heads and calls `updateFileContent(path, historicText)`, which uses `updateText` under the hood. This creates a new Automerge change that makes the document match the historical state, preserving the full change graph (no history rewriting). + +4. **Monaco read-only**: Set `readOnly: true` and `domReadOnly: true` on the editor options when replay mode is active. Since Monaco is keyed by `currentFile?.path`, and we don't want to force a remount, we'll use `editorRef.current.updateOptions()` to toggle read-only dynamically. + +5. **Preview continues working**: The `content` state that drives PreviewRouter will be updated when scrubbing, so the preview pane shows the historical state in real-time. + +6. **File switching disabled**: While in replay mode, file selection in the sidebar is blocked to avoid complexity with multi-file history state. + +7. **No network pause**: Replay mode does NOT touch the network connection. The Automerge content sync effect in `Editor.tsx` is guarded at the UI level to be a no-op during replay, keeping the underlying sync healthy. + +8. **Defensive error handling**: `enter()` is wrapped in try-catch so that failures in `history()` or `view()` are logged and the hook stays inactive rather than crashing the app. + +## Work Items + +### Phase 1: Sync service layer + +- [x] Add `pauseSync()` and `resumeSync()` to `ts-packages/quarto-sync-client/src/client.ts` inside `createSyncClient()` (general-purpose API, not used by replay) +- [x] Add `pauseSync()` and `resumeSync()` wrapper exports to `hub-client/src/services/automergeSync.ts` +- [x] Write vitest tests for `pauseSync()`/`resumeSync()` in `hub-client/src/services/automergeSync.test.ts` +- [x] Verify `getFileHandle(path)` returns a handle with `history()` and `view()` methods — no new function needed +- [x] Run tests, verify they pass + +### Phase 2: `useReplayMode` hook + +- [x] Write vitest tests for `useReplayMode` in `hub-client/src/hooks/useReplayMode.test.ts` (20 tests): + - Test `enter()` loads history and activates replay + - Test `enter()` is a no-op when `getFileHandle()` returns `null` + - Test `enter()` is a no-op when `handle.history()` returns `undefined` + - Test `seekTo(index)` updates `currentContent` with correct text + - Test `seekTo()` with out-of-bounds index clamps to valid range + - Test `play()`/`pause()` starts/stops auto-advance interval + - Test `exit()` resets state + - Test `apply()` calls `updateFileContent(path, content)` and resets + - Test `stepForward()`/`stepBackward()` at boundaries (first/last change) +- [x] Create `hub-client/src/hooks/useReplayMode.ts` +- [x] Hook interface: + ```typescript + interface ReplayState { + isActive: boolean; + historyLength: number; // total number of changes + currentIndex: number; // 0-based index into history + isPlaying: boolean; // auto-advancing + currentContent: string; // text at current index + timestamp: number | null; // unix timestamp of current change + } + + interface ReplayControls { + enter: () => void; // enter replay mode + exit: () => void; // exit without applying + apply: () => void; // exit and apply historical state + seekTo: (index: number) => void; + play: () => void; + pause: () => void; + stepForward: () => void; + stepBackward: () => void; + } + + function useReplayMode( + filePath: string | null, + ): { state: ReplayState; controls: ReplayControls } + ``` +- [x] On `enter()`: guard `getFileHandle(path)` for `null` return (no-op if unavailable), load `handle.history()` (guard against `undefined` return), set index to last (current state). Wrapped in try-catch. +- [x] On `seekTo(index)`: extract text via `handle.view(history[index])` then `viewedHandle.doc()`, read `doc.text ?? ''` +- [x] On `play()`: start `setInterval` that increments index (default ~200ms per step) +- [x] On `exit()`: clear replay state (no network operations) +- [x] On `apply()`: read content at current index, call `updateFileContent(path, content)`, reset state +- [x] `metadata()` receives `history[index][0]` (single change hash string), NOT the full `UrlHeads` array +- [x] Memoize history array (only computed on enter, not on every render) +- [x] Handle edge cases: file with no history, `history()` returning `undefined`, `getFileHandle()` returning `null`, binary files +- [x] Run tests, verify they pass + +### Phase 3: ReplayDrawer component + +- [x] Write vitest tests for `ReplayDrawer` in `hub-client/src/components/ReplayDrawer.test.tsx` (13 tests): + - Test collapsed state renders clock icon + "History" label + - Test clicking collapsed bar calls `controls.enter()` and expands + - Test expanded state renders scrubber, transport controls, Apply/Close buttons + - Test Apply button calls `controls.apply()` + - Test Close button calls `controls.exit()` + - Test keyboard shortcuts (Space, Left, Right, Escape) + - Test scrubber `onChange` calls `controls.seekTo()` +- [x] Create `hub-client/src/components/ReplayDrawer.tsx` +- [x] Create `hub-client/src/components/ReplayDrawer.css` +- [x] Collapsed state: thin bar (32px) with clock icon + "History" label +- [x] Expanded state (~80px): + - Timeline scrubber: `` + - Transport controls: |◀ (step back), ▶/⏸ (play/pause), ▶| (step forward) + - Timestamp display: formatted date/time of current change + - Position indicator: "Change 42 of 100" + - **Apply** button (accent green, `#4ade80` to match existing palette) + - **Close** button (exits replay, equivalent to collapsing) +- [x] Expanding the drawer calls `controls.enter()` (enters replay mode) +- [x] Collapsing the drawer calls `controls.exit()` (exits replay mode) +- [x] Keyboard shortcuts: Space = play/pause, Left/Right = step, Escape = exit +- [x] Style: dark theme matching existing UI (`#1a1a2e` background, `#1f3460` borders) +- [x] Run tests, verify they pass + +### Phase 4: Editor integration + +- [x] Add `useReplayMode` hook to `Editor.tsx` +- [x] **Guard Automerge content sync effect**: Add `if (replayState.isActive) return;` at the top of the effect that syncs `fileContents` to Monaco, so incoming Automerge changes don't overwrite replay content +- [x] Pass replay state to Monaco: when `isActive`, call `editorRef.current.updateOptions({ readOnly: true, domReadOnly: true })`; when inactive, restore +- [x] Override `content` state: when replay is active, use `replayState.currentContent` for both Monaco and preview +- [x] Suppress `handleEditorChange`: early-return when replay is active +- [x] Block file switching: when replay is active, `handleSelectFile` is a no-op +- [x] Presence broadcasting: N/A — editor is read-only during replay, cursor won't change +- [x] Add visual indicator: "REPLAY MODE" banner in header area +- [x] Render `` at the bottom of `.editor-container`, below `
` +- [x] Run all tests (345 tests, 17 files), verify they pass + +### Phase 5: Apply logic + +- [x] `apply()` in the hook: capture `currentContent`, call `updateFileContent(path, content)`, reset state +- [x] This creates a new Automerge change that sets the document text to the historical value +- [x] Verified via test: `updateFileContent` is called with the correct content +- [x] On reset, the Automerge sync effect resumes naturally and syncs Monaco + +### Phase 6: Polish and edge cases + +- [x] Handle the case where the document is modified by a peer while in replay mode — sync continues in background, CRDT merge handles reconciliation when replay exits +- [ ] Performance: for very long histories (10,000+ changes), consider lazy loading or sampling the timeline +- [x] Throttle `seekTo` calls during rapid scrubbing — not needed, `handle.view()` is in-memory, React batches renders +- [x] Add transition animation for drawer expand/collapse (CSS `transition: height 0.2s ease`) +- [x] Binary files: `enter()` is a no-op when handle returns null or doc has no text field +- [ ] Test replay mode exit on disconnect/navigation + +## File Changes Summary + +| File | Change | +|------|--------| +| `ts-packages/quarto-sync-client/src/client.ts` | Add `pauseSync()`, `resumeSync()` to internal API and return object (general-purpose, not used by replay) | +| `hub-client/src/services/automergeSync.ts` | Add `pauseSync()`, `resumeSync()` wrapper exports | +| `hub-client/src/hooks/useReplayMode.ts` | **New** — core replay logic hook (no network operations) | +| `hub-client/src/hooks/useReplayMode.test.ts` | **New** — 20 tests for hook | +| `hub-client/src/components/ReplayDrawer.tsx` | **New** — drawer UI component | +| `hub-client/src/components/ReplayDrawer.css` | **New** — drawer styles | +| `hub-client/src/components/ReplayDrawer.test.tsx` | **New** — 13 tests for component | +| `hub-client/src/components/Editor.tsx` | Integrate replay mode, read-only toggle, content override, **guard Automerge sync effect** | +| `hub-client/src/components/Editor.css` | Replay mode banner style | +| `hub-client/src/test-utils/mockSyncClient.ts` | Add `pauseSync()`, `resumeSync()` to mock | +| `hub-client/src/services/automergeSync.test.ts` | Add pause/resume tests | + +## Key Lesson: NetworkSubsystem.disconnect() Is Destructive + +The original plan assumed `repo.networkSubsystem.disconnect()` was a lightweight "pause sync" operation. In reality, it: + +1. Calls `adapter.disconnect()` on each network adapter +2. `BrowserWebSocketClientAdapter.disconnect()` **closes the WebSocket** (`socket.close()`) +3. Emits `peer-disconnected` events +4. The closed WebSocket can trigger the app's `onConnectionChange(false)` → `setConnectionError('Connection lost')` path in `App.tsx` + +The fix: replay mode operates purely at the UI level. The Automerge sync effect in `Editor.tsx` is guarded with `if (replayState.isActive) return;`, and the hook never touches the network. This keeps the connection healthy and avoids all the cascading side effects. + +## Testing Framework + +All tests use **vitest** (v4.0.17) with `@testing-library/react` for hook/component tests. Follow the existing patterns: +- `@vitest-environment jsdom` directive at top of test files +- `vi.mock()` for service dependencies +- `renderHook()` + `act()` for hook tests (see `hub-client/src/hooks/useAuth.test.ts` for reference) +- Test files co-located with source: `useReplayMode.test.ts` next to `useReplayMode.ts` + +## Non-Goals + +- **Project-wide history**: We don't replay all files simultaneously. This would require coordinating across multiple Automerge documents and is significantly more complex. +- **Diffing UI**: No side-by-side diff view or highlighted changes. The user simply sees the document as it was at that point in time. +- **Branching/forking**: Apply overwrites the current state; it doesn't create a branch. +- **Persistent replay sessions**: Replay state is ephemeral — closing the drawer or refreshing the page exits replay mode. diff --git a/hub-client/src/components/Editor.css b/hub-client/src/components/Editor.css index 813d1107..6266791d 100644 --- a/hub-client/src/components/Editor.css +++ b/hub-client/src/components/Editor.css @@ -343,6 +343,18 @@ z-index: 0; } +/* Replay mode banner */ +.replay-mode-banner { + background: #0a4f0a; + color: #4ade80; + text-align: center; + padding: 4px 0; + font-size: 11px; + font-weight: 700; + letter-spacing: 2px; + border-bottom: 1px solid #166616; +} + /* Monaco editor overrides */ .monaco-editor { padding-top: 0 !important; diff --git a/hub-client/src/components/Editor.tsx b/hub-client/src/components/Editor.tsx index 57eeb42d..9f78eaa6 100644 --- a/hub-client/src/components/Editor.tsx +++ b/hub-client/src/components/Editor.tsx @@ -12,6 +12,7 @@ import { renameFile, exportProjectAsZip, } from '../services/automergeSync'; +import { vfsAddFile, isWasmReady } from '../services/wasmRenderer'; import type { Diagnostic } from '../types/diagnostic'; import { registerIntelligenceProviders, disposeIntelligenceProviders } from '../services/monacoProviders'; import { processFileForUpload } from '../services/resourceService'; @@ -20,6 +21,7 @@ import { usePreference } from '../hooks/usePreference'; import { useIntelligence } from '../hooks/useIntelligence'; import { useSlideThumbnails } from '../hooks/useSlideThumbnails'; import { useCursorToSlide } from '../hooks/useCursorToSlide'; +import { useReplayMode } from '../hooks/useReplayMode'; import { diffToMonacoEdits } from '../utils/diffToMonacoEdits'; import { diagnosticsToMarkers } from '../utils/diagnosticToMonaco'; import FileSidebar from './FileSidebar'; @@ -35,6 +37,7 @@ import AboutTab from './tabs/AboutTab'; import ViewToggleControl from './ViewToggleControl'; import { useViewMode } from './ViewModeContext'; import MarkdownSummary from './MarkdownSummary'; +import ReplayDrawer from './ReplayDrawer'; import './Editor.css'; import PreviewRouter from './PreviewRouter'; @@ -120,6 +123,11 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC enableSymbols: true, }); + // Replay mode for document history. + // isActiveRef is updated synchronously in enter()/exit() — before React + // re-renders — so it can guard handleEditorChange against stale closures. + const { state: replayState, controls: replayControls, isActiveRef: replayActiveRef } = useReplayMode(currentFile?.path ?? null); + // Get content from fileContents map, or use default for new files const getContent = useCallback((file: FileEntry | null): string => { if (!file) return ''; @@ -268,6 +276,52 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC } }, [currentFile, project.description]); + // Toggle Monaco read-only mode during replay + useEffect(() => { + if (!editorRef.current) return; + const readOnly = replayState.isActive; + editorRef.current.updateOptions({ readOnly, domReadOnly: readOnly }); + }, [replayState.isActive]); + + // Content for preview and MarkdownSummary: show replay content when active, + // otherwise the normal Automerge-synced content. We keep `content` state + // untouched during replay so that when replay exits, the Automerge sync + // effect's setContent(automergeContent) always produces a state change. + const displayContent = replayState.isActive ? replayState.currentContent : content; + + // When replay content changes, update Monaco and VFS for display. + // Writing to VFS ensures the preview renderer sees historical content. + useEffect(() => { + if (!replayState.isActive) return; + + const model = editorRef.current?.getModel(); + if (model && editorRef.current) { + applyingRemoteRef.current = true; + model.setValue(replayState.currentContent); + applyingRemoteRef.current = false; + } + + // Update VFS so the WASM renderer sees the historical content + if (currentFile && isWasmReady()) { + vfsAddFile(currentFile.path, replayState.currentContent); + } + }, [replayState.isActive, replayState.currentContent, currentFile]); + + // Restore VFS when replay exits — the Automerge sync effect restores Monaco + // and React state, but doesn't re-write VFS unless fileContents changed. + const prevReplayActiveRef = useRef(false); + useEffect(() => { + const wasActive = prevReplayActiveRef.current; + prevReplayActiveRef.current = replayState.isActive; + + if (wasActive && !replayState.isActive && currentFile && isWasmReady()) { + const liveContent = fileContents.get(currentFile.path); + if (liveContent !== undefined) { + vfsAddFile(currentFile.path, liveContent); + } + } + }, [replayState.isActive, currentFile, fileContents]); + // Refresh intelligence (outline) when content changes // VFS is updated via Automerge callbacks, so we trigger refresh after content changes useEffect(() => { @@ -307,6 +361,9 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC useEffect(() => { if (!currentFile) return; + // During replay mode, the replay hook controls content — skip Automerge sync + if (replayState.isActive) return; + const automergeContent = fileContents.get(currentFile.path); if (automergeContent === undefined) return; @@ -335,7 +392,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC // Always update React state to keep preview in sync. // Since Monaco is uncontrolled, this won't affect editor content or cursor. setContent(automergeContent); - }, [currentFile, fileContents]); + }, [currentFile, fileContents, replayState.isActive]); // Update currentFile when files list changes (e.g., on initial load) // Note: setState in effect is intentional - syncing with external file list @@ -373,6 +430,9 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC }, [route, files, fileContents, currentFile]); const handleEditorChange = (value: string | undefined) => { + // Skip changes during replay mode. Use the ref (always current) rather than + // the closure value (can be stale between setState and re-render). + if (replayActiveRef.current) return; // Skip echo when applying remote changes if (applyingRemoteRef.current) return; @@ -437,6 +497,8 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC // Handle file selection from sidebar (uses replaceState - no history entry) const handleSelectFile = useCallback((file: FileEntry) => { + // Block file switching during replay mode + if (replayState.isActive) return; // Don't switch to binary files in the editor if (isBinaryExtension(file.path)) { // For now, just ignore binary file selection @@ -452,7 +514,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC setUnlocatedErrors([]); // Update URL without adding history entry (sidebar navigation) onNavigateToFile(file.path, { replace: true }); - }, [fileContents, onNavigateToFile]); + }, [fileContents, onNavigateToFile, replayState.isActive]); // Handle opening a file in a new browser tab const handleOpenInNewTab = useCallback((file: FileEntry) => { @@ -726,6 +788,10 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC /> )} + {!isFullscreenPreview && replayState.isActive && ( +
REPLAY MODE
+ )} + {!isFullscreenPreview && unlocatedErrors.length > 0 && (
{unlocatedErrors.map((diag, i) => ( @@ -740,7 +806,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC
{!isFullscreenPreview && ( - + {(activeTab) => { switch (activeTab) { case 'files': @@ -805,7 +871,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC {viewMode === 'preview' && (
{ if (previewScrollToLineRef.current) { previewScrollToLineRef.current(lineNumber); @@ -876,7 +942,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC )}
+ {/* Replay mode drawer */} + {!isFullscreenPreview && ( + + )} + {/* New file dialog */} = {}): ReplayState { + return { + isActive: false, + historyLength: 0, + currentIndex: 0, + isPlaying: false, + playbackSpeed: 1, + currentContent: '', + timestamp: null, + actor: null, + chunkActors: [], + ...overrides, + }; +} + +function makeControls(overrides: Partial = {}): ReplayControls { + return { + enter: vi.fn(), + exit: vi.fn(), + apply: vi.fn(), + seekTo: vi.fn(), + seekToStart: vi.fn(), + seekToEnd: vi.fn(), + play: vi.fn(), + pause: vi.fn(), + stepForward: vi.fn(), + stepBackward: vi.fn(), + cycleSpeed: vi.fn(), + getTimestampAtIndex: vi.fn().mockReturnValue(null), + ...overrides, + }; +} + +describe('ReplayDrawer', () => { + let controls: ReplayControls; + + beforeEach(() => { + controls = makeControls(); + }); + + afterEach(() => { + cleanup(); + }); + + describe('collapsed state', () => { + it('renders chevron and "History" label', () => { + render(); + expect(screen.getByText('Replay')).toBeDefined(); + }); + + it('clicking the bar calls controls.enter()', () => { + render(); + fireEvent.click(screen.getByText('Replay')); + expect(controls.enter).toHaveBeenCalled(); + }); + }); + + describe('expanded state', () => { + const activeState = makeState({ + isActive: true, + historyLength: 100, + currentIndex: 42, + currentContent: 'hello', + timestamp: 1710000000, + actor: 'abcdef0123456789abcdef0123456789', + chunkActors: Array.from({ length: 10 }, () => [{ actor: 'abcdef0123456789abcdef0123456789', fraction: 1 }]), + }); + + it('renders transport controls when active', () => { + render(); + expect(screen.getByLabelText('Skip to start')).toBeDefined(); + expect(screen.getByLabelText('Step backward')).toBeDefined(); + expect(screen.getByLabelText('Play')).toBeDefined(); + expect(screen.getByLabelText('Step forward')).toBeDefined(); + expect(screen.getByLabelText('Skip to end')).toBeDefined(); + }); + + it('Skip to start button calls controls.seekToStart()', () => { + render(); + fireEvent.click(screen.getByLabelText('Skip to start')); + expect(controls.seekToStart).toHaveBeenCalled(); + }); + + it('Skip to end button calls controls.seekToEnd()', () => { + render(); + fireEvent.click(screen.getByLabelText('Skip to end')); + expect(controls.seekToEnd).toHaveBeenCalled(); + }); + + it('renders Apply button and collapse toggle in header', () => { + render(); + expect(screen.getByText('Restore')).toBeDefined(); + expect(screen.getByLabelText('Collapse history')).toBeDefined(); + }); + + it('renders position indicator', () => { + render(); + expect(screen.getByText(/43 of 100/)).toBeDefined(); + }); + + it('renders actor short hash', () => { + render(); + expect(screen.getByText('abcdef01')).toBeDefined(); + }); + + it('Apply button calls controls.apply()', () => { + render(); + fireEvent.click(screen.getByText('Restore')); + expect(controls.apply).toHaveBeenCalled(); + }); + + it('header toggle calls controls.exit()', () => { + render(); + fireEvent.click(screen.getByLabelText('Collapse history')); + expect(controls.exit).toHaveBeenCalled(); + }); + + it('shows Pause button when playing', () => { + const playingState = makeState({ + ...activeState, + isPlaying: true, + }); + render(); + expect(screen.getByLabelText('Pause')).toBeDefined(); + }); + + it('scrubber onChange calls controls.seekTo()', () => { + render(); + const scrubber = screen.getByRole('slider'); + fireEvent.change(scrubber, { target: { value: '10' } }); + expect(controls.seekTo).toHaveBeenCalledWith(10); + }); + + it('speed button shows current speed and calls cycleSpeed', () => { + render(); + const speedBtn = screen.getByLabelText('Playback speed'); + expect(speedBtn.textContent).toBe('1x'); + fireEvent.click(speedBtn); + expect(controls.cycleSpeed).toHaveBeenCalled(); + }); + + it('speed button reflects 4x speed', () => { + const fastState = makeState({ ...activeState, playbackSpeed: 4 }); + render(); + expect(screen.getByLabelText('Playback speed').textContent).toBe('4x'); + }); + }); + + describe('keyboard shortcuts', () => { + const activeState = makeState({ + isActive: true, + historyLength: 100, + currentIndex: 50, + currentContent: 'test', + chunkActors: Array.from({ length: 10 }, () => [{ actor: 'actor1', fraction: 1 }]), + }); + + it('Space toggles play/pause', () => { + const { container } = render(); + fireEvent.keyDown(container.firstChild!, { key: ' ' }); + expect(controls.play).toHaveBeenCalled(); + }); + + it('ArrowLeft calls stepBackward', () => { + const { container } = render(); + fireEvent.keyDown(container.firstChild!, { key: 'ArrowLeft' }); + expect(controls.stepBackward).toHaveBeenCalled(); + }); + + it('ArrowRight calls stepForward', () => { + const { container } = render(); + fireEvent.keyDown(container.firstChild!, { key: 'ArrowRight' }); + expect(controls.stepForward).toHaveBeenCalled(); + }); + + it('Home calls seekToStart', () => { + const { container } = render(); + fireEvent.keyDown(container.firstChild!, { key: 'Home' }); + expect(controls.seekToStart).toHaveBeenCalled(); + }); + + it('End calls seekToEnd', () => { + const { container } = render(); + fireEvent.keyDown(container.firstChild!, { key: 'End' }); + expect(controls.seekToEnd).toHaveBeenCalled(); + }); + + it('Escape calls exit', () => { + const { container } = render(); + fireEvent.keyDown(container.firstChild!, { key: 'Escape' }); + expect(controls.exit).toHaveBeenCalled(); + }); + }); +}); diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx new file mode 100644 index 00000000..3d2c52b2 --- /dev/null +++ b/hub-client/src/components/ReplayDrawer.tsx @@ -0,0 +1,283 @@ +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import type { ReplayState, ReplayControls } from '../hooks/useReplayMode'; +import { actorColor } from '../hooks/useReplayMode'; +import './ReplayDrawer.css'; + +interface Props { + state: ReplayState; + controls: ReplayControls; + disabled?: boolean; +} + +function formatRelativeTime(ts: number): string { + const now = Date.now(); + const diffMs = now - ts * 1000; + const diffSec = Math.floor(diffMs / 1000); + if (diffSec < 60) return 'just now'; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDays = Math.floor(diffHr / 24); + if (diffDays < 30) return `${diffDays}d ago`; + // Beyond 30 days, show short date + const date = new Date(ts * 1000); + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +function formatTimestamp(ts: number | null): string { + if (ts === null) return ''; + return formatRelativeTime(ts); +} + +function formatFullTimestamp(ts: number | null): string { + if (ts === null) return ''; + const date = new Date(ts * 1000); + return date.toLocaleString(); +} + +export default function ReplayDrawer({ state, controls, disabled }: Props) { + const drawerRef = useRef(null); + + // Auto-focus the drawer when replay mode activates so keyboard shortcuts work immediately + useEffect(() => { + if (state.isActive) { + drawerRef.current?.focus(); + } + }, [state.isActive]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!state.isActive) return; + + switch (e.key) { + case ' ': + e.preventDefault(); + if (state.isPlaying) { + controls.pause(); + } else { + controls.play(); + } + break; + case 'ArrowLeft': + e.preventDefault(); + controls.stepBackward(); + break; + case 'ArrowRight': + e.preventDefault(); + controls.stepForward(); + break; + case 'Home': + e.preventDefault(); + controls.seekToStart(); + break; + case 'End': + e.preventDefault(); + controls.seekToEnd(); + break; + case 'Escape': + e.preventDefault(); + controls.exit(); + break; + } + }, [state.isActive, state.isPlaying, controls]); + + const handleScrubberChange = useCallback((e: React.ChangeEvent) => { + controls.seekTo(parseInt(e.target.value, 10)); + }, [controls]); + + // Tooltip state for scrubber hover + const [scrubberTooltip, setScrubberTooltip] = useState<{ left: number; text: string } | null>(null); + const scrubberRef = useRef(null); + + const handleScrubberMouseMove = useCallback((e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const index = Math.round(fraction * (state.historyLength - 1)); + const ts = controls.getTimestampAtIndex(index); + const text = ts !== null ? formatFullTimestamp(ts) : `Change ${index + 1}`; + // Position relative to the scrubber container + const left = e.clientX - (scrubberRef.current?.getBoundingClientRect().left ?? rect.left); + setScrubberTooltip({ left, text }); + }, [state.historyLength, controls]); + + const handleScrubberMouseLeave = useCallback(() => { + setScrubberTooltip(null); + }, []); + + // Build per-chunk stacked rects: each chunk is a vertical column, split by actor fractions. + const chunkRects = useMemo(() => { + const chunks = state.chunkActors; + const n = chunks.length || 1; + const chunkWidth = 100 / n; + const rects: { x: number; y: number; width: number; height: number; color: string }[] = []; + for (let i = 0; i < chunks.length; i++) { + const x = i * chunkWidth; + let y = 0; + for (const { actor, fraction } of chunks[i]) { + rects.push({ x, y, width: chunkWidth, height: fraction, color: actorColor(actor) }); + y += fraction; + } + } + return rects; + }, [state.chunkActors]); + + if (!state.isActive) { + return ( +
+ +
+ ); + } + + const progressPercent = state.historyLength > 1 + ? (state.currentIndex / (state.historyLength - 1)) * 100 + : 0; + + return ( +
+
+ + + Replay + + +
+ + {state.currentIndex + 1} of {state.historyLength} + + {state.actor && ( + + {state.actor.slice(0, 8)} + + )} + {state.timestamp && ( + + {formatFullTimestamp(state.timestamp)} + {formatTimestamp(state.timestamp)} + + )} +
+ + +
+ +
+
+ + + {state.isPlaying ? ( + + ) : ( + + )} + + + +
+ +
+ + {/* Background */} + + {/* Actor-colored chunk rects */} + {chunkRects.map((r, i) => ( + + ))} + {/* Dim the portion past the playhead */} + + {/* Playhead */} + + + + {scrubberTooltip && ( +
+ {scrubberTooltip.text} +
+ )} +
+
+
+ ); +} diff --git a/hub-client/src/components/SidebarTabs.css b/hub-client/src/components/SidebarTabs.css index be22a69a..947e6c4e 100644 --- a/hub-client/src/components/SidebarTabs.css +++ b/hub-client/src/components/SidebarTabs.css @@ -61,6 +61,12 @@ background: #16213e; } +/* Dimmed state during replay mode */ +.sidebar-sections--disabled { + opacity: 0.4; + pointer-events: none; +} + /* When FILES section is expanded, let it take available space */ .sidebar-section.expanded:first-child .section-content { flex: 1; diff --git a/hub-client/src/components/SidebarTabs.tsx b/hub-client/src/components/SidebarTabs.tsx index 01686f46..50f27de7 100644 --- a/hub-client/src/components/SidebarTabs.tsx +++ b/hub-client/src/components/SidebarTabs.tsx @@ -27,9 +27,10 @@ const SECTIONS: Section[] = [ interface SidebarTabsProps { children: (sectionId: SectionId) => ReactNode; + disabled?: boolean; } -export default function SidebarTabs({ children }: SidebarTabsProps) { +export default function SidebarTabs({ children, disabled }: SidebarTabsProps) { const [expandedSections, setExpandedSections] = useState>(() => { const initial = new Set(); for (const section of SECTIONS) { @@ -53,7 +54,7 @@ export default function SidebarTabs({ children }: SidebarTabsProps) { }; return ( -
+
{SECTIONS.map((section) => { const isExpanded = expandedSections.has(section.id); return ( diff --git a/hub-client/src/hooks/useReplayMode.test.ts b/hub-client/src/hooks/useReplayMode.test.ts new file mode 100644 index 00000000..5e9a99a8 --- /dev/null +++ b/hub-client/src/hooks/useReplayMode.test.ts @@ -0,0 +1,472 @@ +/** + * Tests for useReplayMode hook + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +vi.mock('../services/automergeSync', () => ({ + getFileHandle: vi.fn(), + updateFileContent: vi.fn(), + freeDoc: vi.fn(), + cloneHandleDoc: vi.fn(), + viewText: vi.fn(), +})); + +import { useReplayMode } from './useReplayMode'; +import { + getFileHandle, + updateFileContent, + cloneHandleDoc, + viewText, +} from '../services/automergeSync'; + +const mockGetFileHandle = vi.mocked(getFileHandle); +const mockUpdateFileContent = vi.mocked(updateFileContent); +const mockCloneHandleDoc = vi.mocked(cloneHandleDoc); +const mockViewText = vi.mocked(viewText); + +// Helper to create a mock handle with history support. +// history() returns UrlHeads[] where each UrlHeads is string[]. +// metadata() receives a single change hash string (first element of UrlHeads). +// Also configures mockCloneHandleDoc and mockViewText for the given texts. +function createMockHandle(texts: string[], timestamps?: number[], actors?: string[]) { + const historyHeads = texts.map((_, i) => [`head-${i}`]); + + const handle = { + history: vi.fn(() => historyHeads), + metadata: vi.fn((changeHash?: string) => { + if (!changeHash) return undefined; + const index = historyHeads.findIndex(h => h[0] === changeHash); + if (index < 0) return undefined; + const ts = timestamps?.[index] ?? 1000000 + index * 1000; + const actor = actors?.[index] ?? `actor${index}abcdef0123456789`; + return { time: ts, actor }; + }), + doc: vi.fn(() => ({ text: texts[texts.length - 1] })), + }; + + // cloneHandleDoc returns a sentinel object representing the clone + const cloneObj = { __clone: true }; + mockCloneHandleDoc.mockReturnValue(cloneObj); + + // viewText extracts text from the clone given heads + mockViewText.mockImplementation((_clone: unknown, heads: unknown) => { + const headArr = heads as string[]; + const index = historyHeads.findIndex(h => h[0] === headArr[0]); + return texts[index] ?? ''; + }); + + return handle; +} + +describe('useReplayMode', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts in inactive state', () => { + const { result } = renderHook(() => useReplayMode('index.qmd')); + expect(result.current.state.isActive).toBe(false); + expect(result.current.state.historyLength).toBe(0); + expect(result.current.state.currentIndex).toBe(0); + expect(result.current.state.isPlaying).toBe(false); + expect(result.current.state.currentContent).toBe(''); + }); + + describe('enter()', () => { + it('loads history and activates replay', () => { + const handle = createMockHandle(['a', 'ab', 'abc']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + + expect(handle.history).toHaveBeenCalled(); + expect(result.current.state.isActive).toBe(true); + expect(result.current.state.historyLength).toBe(3); + // Starts at last index (current state) + expect(result.current.state.currentIndex).toBe(2); + expect(result.current.state.currentContent).toBe('abc'); + // chunkActors should have entries with fractions summing to 1 + expect(result.current.state.chunkActors.length).toBeGreaterThan(0); + }); + + it('is a no-op when getFileHandle returns null', () => { + mockGetFileHandle.mockReturnValue(null as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + + expect(result.current.state.isActive).toBe(false); + }); + + it('is a no-op when handle.history() returns undefined', () => { + const handle = { + history: vi.fn(() => undefined), + metadata: vi.fn(), + doc: vi.fn(), + }; + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + + expect(result.current.state.isActive).toBe(false); + }); + + it('is a no-op when history is empty', () => { + const handle = createMockHandle([]); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + + expect(result.current.state.isActive).toBe(false); + }); + + it('is a no-op when filePath is null', () => { + const { result } = renderHook(() => useReplayMode(null)); + act(() => { result.current.controls.enter(); }); + + expect(mockGetFileHandle).not.toHaveBeenCalled(); + expect(result.current.state.isActive).toBe(false); + }); + }); + + describe('seekTo()', () => { + it('updates currentContent with correct text', () => { + const handle = createMockHandle(['first', 'second', 'third']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + + expect(result.current.state.currentIndex).toBe(0); + expect(result.current.state.currentContent).toBe('first'); + }); + + it('clamps out-of-bounds index to valid range (too high)', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(100); }); + + expect(result.current.state.currentIndex).toBe(2); + }); + + it('clamps out-of-bounds index to valid range (negative)', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(-5); }); + + expect(result.current.state.currentIndex).toBe(0); + }); + }); + + describe('play() / pause()', () => { + it('starts auto-advance interval on play', () => { + const handle = createMockHandle(['a', 'b', 'c', 'd']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + + expect(result.current.state.isPlaying).toBe(false); + + act(() => { result.current.controls.play(); }); + expect(result.current.state.isPlaying).toBe(true); + + // Advance one tick (base interval 200ms at 1x speed) + act(() => { vi.advanceTimersByTime(200); }); + expect(result.current.state.currentIndex).toBe(1); + + // Advance another tick + act(() => { vi.advanceTimersByTime(200); }); + expect(result.current.state.currentIndex).toBe(2); + }); + + it('stops auto-advance on pause', () => { + const handle = createMockHandle(['a', 'b', 'c', 'd']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + act(() => { result.current.controls.play(); }); + act(() => { vi.advanceTimersByTime(200); }); + expect(result.current.state.currentIndex).toBe(1); + + act(() => { result.current.controls.pause(); }); + expect(result.current.state.isPlaying).toBe(false); + + act(() => { vi.advanceTimersByTime(200); }); + // Should not advance further + expect(result.current.state.currentIndex).toBe(1); + }); + + it('stops playing when reaching the end of history', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + act(() => { result.current.controls.play(); }); + + // Advance past all items + act(() => { vi.advanceTimersByTime(200); }); // index 1 + act(() => { vi.advanceTimersByTime(200); }); // index 2 + act(() => { vi.advanceTimersByTime(200); }); // at end, should stop + + expect(result.current.state.currentIndex).toBe(2); + expect(result.current.state.isPlaying).toBe(false); + }); + }); + + describe('cycleSpeed()', () => { + it('cycles through 1x, 2x, 4x speeds', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + + expect(result.current.state.playbackSpeed).toBe(1); + + act(() => { result.current.controls.cycleSpeed(); }); + expect(result.current.state.playbackSpeed).toBe(2); + + act(() => { result.current.controls.cycleSpeed(); }); + expect(result.current.state.playbackSpeed).toBe(4); + + act(() => { result.current.controls.cycleSpeed(); }); + expect(result.current.state.playbackSpeed).toBe(1); + }); + + it('advances faster at 2x speed', () => { + const handle = createMockHandle(['a', 'b', 'c', 'd', 'e']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + act(() => { result.current.controls.cycleSpeed(); }); // 2x + act(() => { result.current.controls.play(); }); + + // At 2x, interval is 100ms + act(() => { vi.advanceTimersByTime(100); }); + expect(result.current.state.currentIndex).toBe(1); + + act(() => { vi.advanceTimersByTime(100); }); + expect(result.current.state.currentIndex).toBe(2); + }); + + it('restarts interval at new speed when changed during playback', () => { + const handle = createMockHandle(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + act(() => { result.current.controls.play(); }); + + // Advance one step at 1x (200ms) + act(() => { vi.advanceTimersByTime(200); }); + expect(result.current.state.currentIndex).toBe(1); + + // Switch to 4x while playing + act(() => { result.current.controls.cycleSpeed(); }); // 2x + act(() => { result.current.controls.cycleSpeed(); }); // 4x + + // At 4x, interval is 50ms + act(() => { vi.advanceTimersByTime(50); }); + expect(result.current.state.currentIndex).toBe(2); + }); + + it('resets speed on exit', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.cycleSpeed(); }); + expect(result.current.state.playbackSpeed).toBe(2); + + act(() => { result.current.controls.exit(); }); + expect(result.current.state.playbackSpeed).toBe(1); + }); + }); + + describe('stepForward() / stepBackward()', () => { + it('steps forward by one', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + act(() => { result.current.controls.stepForward(); }); + + expect(result.current.state.currentIndex).toBe(1); + }); + + it('does not step forward past the end', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + // Already at index 2 (last) + act(() => { result.current.controls.stepForward(); }); + + expect(result.current.state.currentIndex).toBe(2); + }); + + it('steps backward by one', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.stepBackward(); }); + + expect(result.current.state.currentIndex).toBe(1); + }); + + it('does not step backward past the beginning', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + act(() => { result.current.controls.stepBackward(); }); + + expect(result.current.state.currentIndex).toBe(0); + }); + }); + + describe('seekToStart() / seekToEnd()', () => { + it('seekToStart jumps to index 0', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + // Starts at index 2 (last) + expect(result.current.state.currentIndex).toBe(2); + + act(() => { result.current.controls.seekToStart(); }); + expect(result.current.state.currentIndex).toBe(0); + expect(result.current.state.currentContent).toBe('a'); + }); + + it('seekToEnd jumps to last index', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + expect(result.current.state.currentIndex).toBe(0); + + act(() => { result.current.controls.seekToEnd(); }); + expect(result.current.state.currentIndex).toBe(2); + expect(result.current.state.currentContent).toBe('c'); + }); + }); + + describe('exit()', () => { + it('resets state', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + expect(result.current.state.isActive).toBe(true); + + act(() => { result.current.controls.exit(); }); + + expect(result.current.state.isActive).toBe(false); + expect(result.current.state.historyLength).toBe(0); + expect(result.current.state.currentContent).toBe(''); + }); + + it('stops playback on exit', () => { + const handle = createMockHandle(['a', 'b', 'c', 'd']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + act(() => { result.current.controls.play(); }); + expect(result.current.state.isPlaying).toBe(true); + + act(() => { result.current.controls.exit(); }); + expect(result.current.state.isPlaying).toBe(false); + + // Ensure interval is cleared + act(() => { vi.advanceTimersByTime(1000); }); + expect(result.current.state.isActive).toBe(false); + }); + }); + + describe('apply()', () => { + it('calls updateFileContent with historical content and resets', () => { + const handle = createMockHandle(['first', 'second', 'third']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(1); }); + + expect(result.current.state.currentContent).toBe('second'); + + act(() => { result.current.controls.apply(); }); + + expect(mockUpdateFileContent).toHaveBeenCalledWith('index.qmd', 'second'); + expect(result.current.state.isActive).toBe(false); + }); + }); + + describe('timestamp and actor', () => { + it('provides timestamp for current change', () => { + const timestamps = [1000000, 1001000, 1002000]; + const handle = createMockHandle(['a', 'b', 'c'], timestamps); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(1); }); + + expect(result.current.state.timestamp).toBe(1001000); + }); + + it('provides actor hash for current change', () => { + const actors = ['aaa111', 'bbb222', 'ccc333']; + const handle = createMockHandle(['a', 'b', 'c'], undefined, actors); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(1); }); + + expect(result.current.state.actor).toBe('bbb222'); + }); + }); +}); diff --git a/hub-client/src/hooks/useReplayMode.ts b/hub-client/src/hooks/useReplayMode.ts new file mode 100644 index 00000000..70663fa9 --- /dev/null +++ b/hub-client/src/hooks/useReplayMode.ts @@ -0,0 +1,375 @@ +import { useState, useCallback, useRef } from 'react'; +import { + getFileHandle, + updateFileContent, + freeDoc, + cloneHandleDoc, + viewText, +} from '../services/automergeSync'; + +export const PLAYBACK_SPEEDS = [1, 2, 4] as const; +export type PlaybackSpeed = (typeof PLAYBACK_SPEEDS)[number]; + +export interface ChunkActorShare { + actor: string; + fraction: number; +} + +export interface ReplayState { + isActive: boolean; + historyLength: number; + currentIndex: number; + isPlaying: boolean; + playbackSpeed: PlaybackSpeed; + currentContent: string; + timestamp: number | null; + actor: string | null; // short hash of the actor who made the change + chunkActors: ChunkActorShare[][]; // per-chunk actor fractions for the waveform +} + +/** Deterministic color from an actor hash string. */ +export function actorColor(actor: string): string { + const hue = parseInt(actor.slice(0, 6), 16) % 360; + return `hsl(${hue}, 60%, 55%)`; +} + +export interface ReplayControls { + enter: () => void; + exit: () => void; + apply: () => void; + seekTo: (index: number) => void; + seekToStart: () => void; + seekToEnd: () => void; + play: () => void; + pause: () => void; + stepForward: () => void; + stepBackward: () => void; + cycleSpeed: () => void; + getTimestampAtIndex: (index: number) => number | null; +} + +const INITIAL_STATE: ReplayState = { + isActive: false, + historyLength: 0, + currentIndex: 0, + isPlaying: false, + playbackSpeed: 1, + currentContent: '', + timestamp: null, + actor: null, + chunkActors: [], +}; + +// Base interval at 1x speed; divided by playback speed multiplier +const PLAY_BASE_INTERVAL_MS = 200; + +// Type helpers for DocHandle methods we use (avoids importing Automerge types) +interface ViewableHandle { + history(): unknown[] | undefined; + metadata(change?: string): { time?: number; actor?: string } | undefined; +} + +function asViewable(handle: unknown): ViewableHandle { + return handle as ViewableHandle; +} + +export function useReplayMode( + filePath: string | null, +): { state: ReplayState; controls: ReplayControls; isActiveRef: React.RefObject } { + const [state, setState] = useState(INITIAL_STATE); + + // Store history array and handle in refs (stable across renders, not reactive) + const historyRef = useRef([]); + const handleRef = useRef(null); + // Independent clone of the doc used for all view() operations during replay. + // Views of this clone borrow from the clone's WASM state — not the original + // handle's — so handle.history() on re-entry is never blocked. + const cloneRef = useRef(null); + // Cache of extracted text content keyed by history index. + // Avoids repeated WASM view() calls for the same index. + const textCacheRef = useRef>(new Map()); + const intervalRef = useRef | null>(null); + // Keep current index and speed in refs for the interval callback + const indexRef = useRef(0); + const speedRef = useRef(1); + // Synchronous replay-active flag: updated immediately in enter()/reset(), + // before React re-renders. Consumers can read this ref to guard against + // stale closures that still see isActive === false. + const isActiveRef = useRef(false); + + const clearPlayInterval = useCallback(() => { + if (intervalRef.current !== null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + const getContentAtIndex = useCallback((index: number): string => { + const clone = cloneRef.current; + const history = historyRef.current; + if (!clone || index < 0 || index >= history.length) return ''; + + const cached = textCacheRef.current.get(index); + if (cached !== undefined) return cached; + + const text = viewText(clone, history[index]); + textCacheRef.current.set(index, text); + return text; + }, []); + + const getMetadataAtIndex = useCallback((index: number): { timestamp: number | null; actor: string | null } => { + const handle = handleRef.current; + const history = historyRef.current; + if (!handle || index < 0 || index >= history.length) return { timestamp: null, actor: null }; + try { + // metadata() expects a single change hash string. + // history entries are UrlHeads (string[]), so extract the first element. + const heads = history[index]; + const changeHash = Array.isArray(heads) ? heads[0] : heads; + if (typeof changeHash !== 'string') return { timestamp: null, actor: null }; + const meta = asViewable(handle).metadata(changeHash); + return { timestamp: meta?.time ?? null, actor: meta?.actor ?? null }; + } catch { + return { timestamp: null, actor: null }; + } + }, []); + + const getTimestampAtIndex = useCallback((index: number): number | null => { + return getMetadataAtIndex(index).timestamp; + }, [getMetadataAtIndex]); + + const enter = useCallback(() => { + if (!filePath) return; + + let handle, history: unknown[], clone; + try { + handle = getFileHandle(filePath); + if (!handle) return; + + history = asViewable(handle).history() ?? []; + if (history.length === 0) return; + + clone = cloneHandleDoc(handle); + } catch (e) { + console.error('[useReplayMode] Failed to enter replay mode:', e); + return; + } + + handleRef.current = handle; + historyRef.current = history; + cloneRef.current = clone; + textCacheRef.current = new Map(); + isActiveRef.current = true; + + const lastIndex = history.length - 1; + indexRef.current = lastIndex; + + // Split history into ≤100 equal chunks for the actor-colored waveform. + const MAX_CHUNKS = 100; + const chunkCount = Math.min(history.length, MAX_CHUNKS); + const chunkSize = history.length / chunkCount; + + // Collect actor frequencies per chunk — ≤500 metadata() calls (100 chunks × 5 samples). + const SAMPLES_PER_CHUNK = 5; + const viewable = asViewable(handle); + const chunkActors: ChunkActorShare[][] = new Array(chunkCount); + + for (let i = 0; i < chunkCount; i++) { + const startIdx = Math.round(i * chunkSize); + const endIdx = Math.min(Math.round((i + 1) * chunkSize), history.length); + const span = endIdx - startIdx; + const step = Math.max(1, Math.floor(span / SAMPLES_PER_CHUNK)); + const counts = new Map(); + let totalSamples = 0; + for (let j = startIdx; j < endIdx; j += step) { + const heads = history[j]; + const changeHash = Array.isArray(heads) ? heads[0] : heads; + if (typeof changeHash === 'string') { + const meta = viewable.metadata(changeHash); + if (meta?.actor) { + counts.set(meta.actor, (counts.get(meta.actor) ?? 0) + 1); + totalSamples++; + } + } + } + if (totalSamples === 0) { + chunkActors[i] = []; + } else { + chunkActors[i] = Array.from(counts.entries()).map(([actor, count]) => ({ + actor, + fraction: count / totalSamples, + })); + } + } + + const lastMeta = getMetadataAtIndex(lastIndex); + setState({ + isActive: true, + historyLength: history.length, + currentIndex: lastIndex, + isPlaying: false, + playbackSpeed: 1, + currentContent: getContentAtIndex(lastIndex), + timestamp: lastMeta.timestamp, + actor: lastMeta.actor, + chunkActors, + }); + }, [filePath, getContentAtIndex, getMetadataAtIndex]); + + const seekTo = useCallback((index: number) => { + const history = historyRef.current; + if (history.length === 0) return; + + const clamped = Math.max(0, Math.min(index, history.length - 1)); + indexRef.current = clamped; + const content = getContentAtIndex(clamped); + const meta = getMetadataAtIndex(clamped); + + setState(prev => ({ + ...prev, + currentIndex: clamped, + currentContent: content, + timestamp: meta.timestamp, + actor: meta.actor, + })); + }, [getContentAtIndex, getMetadataAtIndex]); + + const stopPlaying = useCallback(() => { + clearPlayInterval(); + setState(prev => ({ ...prev, isPlaying: false })); + }, [clearPlayInterval]); + + const startPlayInterval = useCallback(() => { + clearPlayInterval(); + const history = historyRef.current; + const interval = Math.round(PLAY_BASE_INTERVAL_MS / speedRef.current); + + intervalRef.current = setInterval(() => { + try { + const nextIndex = indexRef.current + 1; + if (nextIndex >= history.length) { + clearPlayInterval(); + setState(prev => ({ ...prev, isPlaying: false })); + return; + } + indexRef.current = nextIndex; + const content = getContentAtIndex(nextIndex); + const meta = getMetadataAtIndex(nextIndex); + setState(prev => ({ + ...prev, + currentIndex: nextIndex, + currentContent: content, + timestamp: meta.timestamp, + actor: meta.actor, + })); + } catch (e) { + console.error('[useReplayMode] Playback error, stopping:', e); + clearPlayInterval(); + setState(prev => ({ ...prev, isPlaying: false })); + } + }, interval); + }, [clearPlayInterval, getContentAtIndex, getMetadataAtIndex]); + + const play = useCallback(() => { + const history = historyRef.current; + if (history.length === 0) return; + + // If at the end, restart from the beginning + if (indexRef.current >= history.length - 1) { + seekTo(0); + } + + setState(prev => ({ ...prev, isPlaying: true })); + startPlayInterval(); + }, [seekTo, startPlayInterval]); + + const pause = useCallback(() => { + stopPlaying(); + }, [stopPlaying]); + + const stepForward = useCallback(() => { + const history = historyRef.current; + const next = indexRef.current + 1; + if (next < history.length) { + seekTo(next); + } + }, [seekTo]); + + const stepBackward = useCallback(() => { + const prev = indexRef.current - 1; + if (prev >= 0) { + seekTo(prev); + } + }, [seekTo]); + + const cycleSpeed = useCallback(() => { + const currentIdx = PLAYBACK_SPEEDS.indexOf(speedRef.current); + const nextSpeed = PLAYBACK_SPEEDS[(currentIdx + 1) % PLAYBACK_SPEEDS.length]; + speedRef.current = nextSpeed; + setState(prev => ({ ...prev, playbackSpeed: nextSpeed })); + // If currently playing, restart the interval at the new speed + if (intervalRef.current !== null) { + startPlayInterval(); + } + }, [startPlayInterval]); + + const seekToStart = useCallback(() => { + if (historyRef.current.length > 0) { + seekTo(0); + } + }, [seekTo]); + + const seekToEnd = useCallback(() => { + const history = historyRef.current; + if (history.length > 0) { + seekTo(history.length - 1); + } + }, [seekTo]); + + const reset = useCallback(() => { + clearPlayInterval(); + isActiveRef.current = false; + // Free the clone's WASM state immediately so borrows don't linger. + if (cloneRef.current) { + freeDoc(cloneRef.current); + cloneRef.current = null; + } + handleRef.current = null; + historyRef.current = []; + textCacheRef.current = new Map(); + indexRef.current = 0; + speedRef.current = 1; + setState(INITIAL_STATE); + }, [clearPlayInterval]); + + const exit = useCallback(() => { + reset(); + }, [reset]); + + const apply = useCallback(() => { + const content = getContentAtIndex(indexRef.current); + if (filePath) { + updateFileContent(filePath, content); + } + reset(); + }, [filePath, getContentAtIndex, reset]); + + return { + state, + controls: { + enter, + exit, + apply, + seekTo, + seekToStart, + seekToEnd, + play, + pause, + stepForward, + stepBackward, + cycleSpeed, + getTimestampAtIndex, + }, + isActiveRef, + }; +} diff --git a/hub-client/src/services/automergeSync.test.ts b/hub-client/src/services/automergeSync.test.ts index 4b0c3a57..a6cef4de 100644 --- a/hub-client/src/services/automergeSync.test.ts +++ b/hub-client/src/services/automergeSync.test.ts @@ -13,6 +13,8 @@ import { isConnected, getFileContent, isFileBinary, + pauseSync, + resumeSync, _resetForTesting, _setClientForTesting, } from './automergeSync'; @@ -214,6 +216,51 @@ describe('automergeSync', () => { }); }); + describe('pauseSync / resumeSync', () => { + beforeEach(async () => { + mockClient = createMockSyncClient( + { + onFileAdded: vi.fn(), + onFileChanged: vi.fn(), + onBinaryChanged: vi.fn(), + onFileRemoved: vi.fn(), + onConnectionChange: vi.fn(), + }, + { + initialFiles: new Map([ + ['index.qmd', { type: 'text' as const, text: '# Hello' }], + ]), + }, + ); + _setClientForTesting(mockClient); + await mockClient.connect('ws://test', 'automerge:test'); + }); + + it('should call pauseSync without triggering onConnectionChange', () => { + const spy = vi.spyOn(mockClient, 'pauseSync'); + pauseSync(); + expect(spy).toHaveBeenCalled(); + // onConnectionChange should NOT have been called after the initial connect + expect(onConnectionChange).toHaveBeenCalledTimes(0); + }); + + it('should call resumeSync without triggering onConnectionChange', () => { + const spy = vi.spyOn(mockClient, 'resumeSync'); + pauseSync(); + resumeSync(); + expect(spy).toHaveBeenCalled(); + expect(onConnectionChange).toHaveBeenCalledTimes(0); + }); + + it('should preserve document state across pause/resume cycle', () => { + expect(getFileContent('index.qmd')).toBe('# Hello'); + pauseSync(); + expect(getFileContent('index.qmd')).toBe('# Hello'); + resumeSync(); + expect(getFileContent('index.qmd')).toBe('# Hello'); + }); + }); + describe('test isolation', () => { it('should have clean state after reset', () => { _resetForTesting(); diff --git a/hub-client/src/services/automergeSync.ts b/hub-client/src/services/automergeSync.ts index 09331f90..e30b91f9 100644 --- a/hub-client/src/services/automergeSync.ts +++ b/hub-client/src/services/automergeSync.ts @@ -18,6 +18,13 @@ import { type FilePayload, } from '@quarto/quarto-sync-client'; +import { decodeHeads } from '@automerge/automerge-repo'; + +import { + free as automergeFreeFn, + clone as automergeCloneFn, + view as automergeViewFn, +} from '@automerge/automerge'; import { vfsAddFile, vfsAddBinaryFile, vfsRemoveFile, vfsClear, initWasm } from './wasmRenderer'; // Re-export types for use in other components @@ -210,6 +217,57 @@ export function getFileHandle(path: string) { return ensureClient().getFileHandle(path); } +/** + * Free an Automerge Doc's WASM resources immediately, rather than waiting + * for JS garbage collection. + */ +export function freeDoc(doc: unknown): void { + try { + automergeFreeFn(doc as Parameters[0]); + } catch { + // doc may already be freed or not a WASM-backed object + } +} + +/** + * Clone the current document from a DocHandle. + * Returns an independent Automerge doc with its own WASM state, so views + * created from this clone do NOT hold borrows on the original handle's doc. + */ +export function cloneHandleDoc(handle: unknown): unknown { + const doc = (handle as { doc(): unknown }).doc(); + return automergeCloneFn(doc as Parameters[0]); +} + +/** + * Create a read-only view of a cloned doc at given heads and extract the + * text field. Heads are UrlHeads (base58check-encoded string[]) as returned + * by handle.history(). + */ +export function viewText(clonedDoc: unknown, heads: unknown): string { + const decoded = decodeHeads(heads as Parameters[0]); + const viewed = automergeViewFn( + clonedDoc as Parameters[0], + decoded as unknown as Parameters[1], + ); + return (viewed as { text?: string })?.text ?? ''; +} + +/** + * Pause all network sync without destroying the connection. + * Document handles and local state are preserved. + */ +export function pauseSync(): void { + ensureClient().pauseSync(); +} + +/** + * Resume network sync after a pause. + */ +export function resumeSync(): void { + ensureClient().resumeSync(); +} + /** * Get all current file paths that have handles. */ diff --git a/hub-client/src/test-utils/mockSyncClient.ts b/hub-client/src/test-utils/mockSyncClient.ts index b81f0237..423fd78a 100644 --- a/hub-client/src/test-utils/mockSyncClient.ts +++ b/hub-client/src/test-utils/mockSyncClient.ts @@ -53,6 +53,8 @@ export interface MockSyncClient { renameFile(oldPath: string, newPath: string): void; getFileHandle(path: string): { documentId: string } | null; getFilePaths(): string[]; + pauseSync(): void; + resumeSync(): void; createNewProject(options: CreateProjectOptions): Promise; // Test helpers @@ -219,6 +221,14 @@ export function createMockSyncClient( return Array.from(files.keys()); }, + pauseSync(): void { + // No-op in mock — sync is simulated + }, + + resumeSync(): void { + // No-op in mock — sync is simulated + }, + async createNewProject(options: CreateProjectOptions): Promise { // Clear existing state files.clear(); diff --git a/ts-packages/quarto-sync-client/src/client.ts b/ts-packages/quarto-sync-client/src/client.ts index 64c05c58..22a3e0bb 100644 --- a/ts-packages/quarto-sync-client/src/client.ts +++ b/ts-packages/quarto-sync-client/src/client.ts @@ -562,6 +562,21 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS return Array.from(state.fileHandles.keys()); } + /** + * Pause all network sync without destroying the connection. + * Document handles and local state are preserved. + */ + function pauseSync(): void { + state.repo?.networkSubsystem.disconnect(); + } + + /** + * Resume network sync after a pause. + */ + function resumeSync(): void { + state.repo?.networkSubsystem.reconnect(); + } + /** * Create a new project with the given files. */ @@ -697,6 +712,8 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS isConnected, getFileHandle, getFilePaths, + pauseSync, + resumeSync, createNewProject, }; }