From 2652d30cc5e25d656681dbea94ca8a50af7f127f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 4 Sep 2026 15:52:40 -0700 Subject: [PATCH 01/31] Notepad: shared model, archive logic, and adapter port The plan with its resolved open questions, the note/archive types shared by the webview and the VS Code extension host, the idempotent archive mutation with strict validation of stored data, and the optional PlatformAdapter port every notepad-capable host implements. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HqBmeMGWo1P98V3mFpsc1Y --- PLAN.md | 197 ++++++++++++++++++++++ lib/src/lib/notepad/archive-model.ts | 239 +++++++++++++++++++++++++++ lib/src/lib/notepad/types.ts | 122 ++++++++++++++ lib/src/lib/platform/types.ts | 18 ++ 4 files changed, 576 insertions(+) create mode 100644 PLAN.md create mode 100644 lib/src/lib/notepad/archive-model.ts create mode 100644 lib/src/lib/notepad/types.ts diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000..48906e766 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,197 @@ +# Surface Notepad and Archive + +## Summary + +Add an ephemeral per-Surface notepad to Standalone, VS Code, and the desktop website demo. Pocket remains out of scope. + +A notepad can contain manually entered plain text and rich terminal selections preserving bold, italic, foreground color, and background color. Rich notes use an application-owned run model rendered as safe DOM—not another xterm instance—so they can be edited, archived, and copied as both `text/plain` and `text/html`. + +Normal-buffer captures may retain runtime-only xterm markers that link back to their scrollback source. This works while the source remains in the live buffer; markers are never archived. + +When a Surface closes, its notes are persisted as one archive batch containing the Surface title, kind, latest CWD, closure time, and notes. Standalone and VS Code use entirely separate machine-local archives. The desktop demo uses memory only. + +The design is supported by xterm’s public [cell-style API](https://xtermjs.org/docs/api/terminal/interfaces/ibuffercell/) and [buffer markers](https://xtermjs.org/docs/api/terminal/interfaces/imarker/). VS Code’s archive belongs in extension-global storage, which is workspace-independent; do not opt its key into Settings Sync. See [VS Code data storage](https://code.visualstudio.com/api/extension-capabilities/common-capabilities#data-storage). + +## Data Model and Platform Interfaces + +Introduce a shared notepad model: + +```ts +interface RichTextRun { + text: string; + bold?: true; + italic?: true; + foreground?: string; // normalized #RRGGBB + background?: string; // normalized #RRGGBB +} + +type NoteContent = + | { kind: 'plain'; text: string } + | { kind: 'terminal'; runs: RichTextRun[] }; + +interface RuntimeTerminalSource { + terminalId: string; + startMarker: IMarker; + endMarker: IMarker; + startColumn: number; + endColumn: number; + expectedRawText: string; +} + +interface LiveNote { + id: string; + createdAt: number; + content: NoteContent; + source?: RuntimeTerminalSource; +} + +type ArchivedNote = Omit; + +interface ArchiveBatch { + id: string; + closedAt: number; + surfaceTitle: string; + surfaceKind: SurfaceKind; + cwd: CwdState | null; + notes: ArchivedNote[]; +} + +interface NotepadArchiveV1 { + version: 1; + batches: ArchiveBatch[]; +} +``` + +`ArchiveBatch.cwd` is required but nullable. For terminal-backed Surfaces, snapshot the complete canonical `CwdState` immediately before teardown, preserving the path, URI, host and remote identity, path kind, source, and observation time. Browser Surfaces and terminals without a known CWD store `null`. The Archive UI renders the full path and remote host through the existing CWD display utilities rather than persisting a preformatted label. + +Add a host-backed archive port to `PlatformAdapter`: + +```ts +interface NotepadArchiveMutation { + append?: ArchiveBatch[]; + deleteBatchIds?: string[]; + deleteNotes?: Array<{ batchId: string; noteId: string }>; +} + +interface NotepadArchivePort { + load(): Promise; + mutate(change: NotepadArchiveMutation): Promise; + syncVolatile?(snapshot: VolatileNotepadSnapshot): void; +} +``` + +The shared layer validates `load()` as `NotepadArchiveV1`. A malformed archive is reported as unavailable and must never be silently replaced: the Archive view shows it as unreadable and offers one user-initiated recovery, which moves the unreadable data aside (Standalone renames the file to `notepad-archive-v1.unreadable-.json`; VS Code copies the value to a sibling `globalState` key) and starts an empty archive. Until then every append fails and closures take the failure path in Archive and Lifecycle. Mutations operate against the latest stored version and are idempotent by batch and note ID, preventing duplicated batches or lost concurrent appends. + +`syncVolatile` supports VS Code’s unavoidable editor-disposal lifecycle. It mirrors live note content, Surface metadata including `cwd`, and staged archive deletions into extension-host memory. It excludes terminal markers, is never written to disk, and is cleared on extension restart. It hydrates exactly one path, a live resume: a webview re-resolved over PTYs the extension host still owns (the bottom-panel `WebviewView` is disposed by a move between containers, and its `onDidDispose` leaves PTYs alive) receives the mirrored notes for the Surface ids in its live PTY list, riding the boot payload beside the recovery commands. It never hydrates a cold restore. + +Do not add live notes to session snapshots, Lath persistence, `localStorage`, VS Code webview state, workspace state, or any other restoration path. The volatile mirror above is the one exception, and only for live resume. + +Platform archive implementations are: + +- Standalone: a versioned, owner-only `notepad-archive-v1.json` under Tauri application data, separate from session files. Serialize mutations, lock against concurrent writers, and use atomic temporary-file, sync, and rename replacement. +- VS Code: a versioned entry in `ExtensionContext.globalState`, updated through a serialized extension-host queue. Never register the archive key for Settings Sync. +- Website desktop demo: an in-memory implementation cleared by page reload. +- Pocket: no notepad or archive implementation. + +## Notepad UI, Capture, and Source Links + +Add `` to every desktop Surface header. It appears after the cursor/selection icon when that icon is present and before the split controls. It is present at the full and compact header tiers; at the minimal tier it appears only while the Surface has notes, so notes are never invisible but an empty notepad yields its 20px to the title. It uses: + +```tsx + 0 ? 'fill' : 'regular'} /> +``` + +For an attached Surface, clicking it opens a panel aligned to the top-right of the Surface body with 75% of the Surface width and height. Only one Surface notepad is open per Wall. The panel traps its editing interactions from terminal shortcuts and closes on its close control, Escape, or an outside click. + +For a minimized Surface containing notes, place a distinct filled Notepad button in its Door. Restructure the current single-button Door into a wrapper carrying `data-door-id` (what the selection ring and baseboard fitting measure) with two buttons: the title button keeps click-to-reattach and the drag press, the Notepad button does neither. It opens a compact, edge-clamped popover above the Door, capped at approximately 30rem wide and 75% of the Wall height. Opening it does not reattach the Surface. Minimized Surfaces with no notes do not need a Notepad button. + +Notes remain in creation order from top to bottom. Each live item offers Copy and Delete, plus a source-link pin when available. Add New creates an empty plain-text note at the bottom and focuses it; an untouched empty note disappears on blur or panel close. + +Existing notes are directly focusable and editable at the clicked text position. Focusing or moving the caret through a rich note does not change its model. The first content mutation—typing, deletion, cut, or paste—atomically converts the entire note to plain text and then applies the edit. Pasted content is inserted as plain text. Pins do not affect ordering and are not user-controlled favorites. + +Render rich runs as escaped DOM spans in a whitespace-preserving container. Copy writes: + +- `text/plain` for every destination. +- Sanitized `text/html` generated only from escaped text and the four supported attributes. +- Plain-text fallback when rich clipboard writing is unavailable. + +Extend the finalized Dormouse native-selection popup with “Add to notepad,” after Copy Raw and Copy Rewrapped. Show `Cmd+N` on macOS and `Ctrl+N` elsewhere. The shortcut is intercepted only while that terminal has a finalized Dormouse selection; otherwise the key continues to the terminal unchanged. Intercept with a capture-phase window listener that calls `preventDefault` and `stopPropagation`, as the popup's Escape handler does; that also keeps the chord from VS Code's keydown forwarding to the workbench, which the implementation verifies in the extension build. The Standalone menu claims no N chord. The website demo shows no chord and binds none, because browsers reserve Cmd/Ctrl+N; an adapter flag the website's adapter sets, in the style of `hostOwnsTheme`, gates it. Successful capture briefly shows an Added state and dismisses the selection popup without opening the notepad. + +Capture text by joining soft-wrapped rows, detected by xterm's `isWrapped` on the following line, and keeping every hard line break; a block selection keeps every row. This is not Copy Rewrapped: no paragraph joining and no box-drawing stripping. Separately retain the raw selected text (`extractSelectionText`) for source validation. Colors record what xterm renders, so bold with palette 0-7 resolves to the bright entry while `drawBoldTextInBrightColors` is on. Walk xterm buffer cells over the normalized selection, skip width-zero continuation cells, include wide characters once, resolve explicit palette/RGB and inverse colors to normalized RGB, and merge adjacent runs with identical supported styling. Ignore underline, strike-through, dim, blink, hyperlinks, and other terminal attributes. + +For normal-buffer captures, register start and end xterm markers and store the normalized endpoint columns and raw selected text. Alternate-buffer captures receive no source link. + +Clicking a source pin: + +1. Closes the notepad and reattaches the Surface if necessary. +2. Resolves both live markers and reconstructs the original range using their current lines and stored columns. +3. Reads the candidate range and compares its raw text exactly with `expectedRawText`. +4. On success, scrolls it into view and restores the xterm selection plus Dormouse outline and finalized-selection popup. +5. On disposed markers, missing rows, or a text mismatch, removes the pin, keeps or reopens the notepad, and reports that the source is no longer available. + +Column restoration after terminal resizing is explicitly best effort; the raw-text equality check prevents navigation to incorrect output. Scrollback trimming is discovered only when a pin is used. Disposing or replacing the terminal instance removes its pins immediately while retaining the notes. + +## Archive and Lifecycle + +Add a Notepad Archive entry to Settings. It opens a dedicated, roomy Archive view with a Back to Settings action. + +Show newest batches first while preserving note order within each batch. Each batch header displays its Surface title, kind, closure time, and CWD when present. Archived notes support Copy and Delete only—no editing or source pin. Copy uses the same plain/HTML clipboard exporter as live notes. + +Archive deletions are staged in UI state while the Archive view remains open: + +- Delete immediately hides the selected note without confirmation. +- Empty batches are hidden automatically. +- After the first deletion, show: “Deletion is irreversible once this window closes. Undo”. +- Undo restores every deletion staged since the Archive view was opened. +- Back, Escape, and the dialog close control commit the remaining deletion set in one mutation before leaving. +- If the mutation fails, keep the Archive view open, retain its staged state, and show the error. + +Archived entries remain until explicitly deleted. Do not impose an age limit or count limit. + +Route every user-visible permanent Surface closure through an asynchronous close coordinator. If notes exist, construct stable-ID batches and append them before teardown. This includes titlebar actions, keyboard kills (confirmed and the untouched fast path), `dor kill`, the door-restore kill path, and controlled application quit. Multi-Surface closure appends all batches in one mutation. Workspace/Window closure has no live code path (`closeWorkspace` has only test callers and the workspaces flag is dormant), so the spec states it as `Reserved:` against the workspaces-rollout scope and nothing is wired. + +If archiving fails during a blockable closure: + +- Keep the affected Surface open and show a pane-anchored error offering Keep open (default) and Close anyway, which discards that Surface's notes; without the escape an unwritable archive makes every Surface unclosable. +- Return an error from `dor kill`; the Surface stays. +- In Standalone the archive is a gate step before teardown: after the running-work confirmation (or immediately on an all-idle quit) and before `quit_progress`, bounded at 3 s. Failure or timeout calls `quit_cancel`, and the quit dialog shows the error with Cancel (default) and Quit anyway, discarding notes. Teardown keeps its existing rule that no failing step prevents exit. + +Keep an internal immediate-teardown primitive only for rollback and throwaway Surfaces that cannot contain user notes. + +Renderer changes, browser/terminal mode changes, and shell replacement performed in place retain the live notepad instead of archiving it. Every such replacement mints a new Surface id (`replaceSurface`, the untouched-shell replace branch, the door `replace-terminal` restore), so the notepad store migrates the notes to the new id wherever `transferSurfaceRef` runs. Any terminal source pins are discarded if their terminal instance is disposed. + +For Standalone, intercept controlled window/application closure, archive every live batch in one transaction, then continue shutdown. Forced process termination or crashes may lose live notes. + +For VS Code, continuously refresh the extension host’s volatile in-memory mirror. On editor-panel disposal (`killOnDispose: true`) or extension deactivation, append mirrored notes and commit mirrored staged deletions on a best-effort basis. The bottom-panel `WebviewView`'s disposal is not a closure: its PTYs stay alive, its notes stay in the mirror, and they hydrate the next resolve. External VS Code tab/window destruction cannot reliably be blocked; a final storage failure or forced termination may therefore lose those notes. Do not add a persistent draft mirror to address this limitation. + +Standalone and VS Code archives must never discover, import, synchronize, or share each other’s data. + +## Tests and Documentation + +Add unit coverage for: + +- Rich extraction across bold, italic, palette/RGB foreground and background, inverse colors, default colors, wide characters, reversed selections, hard breaks, and soft wraps. +- Run merging, HTML escaping, clipboard MIME output, and plain-text fallback. +- Rich-to-plain conversion only on actual content mutation. +- Archive schema validation, including required local, remote, and `null` CWD snapshots. +- Idempotent append/delete mutations, malformed archives, concurrent appends, and atomic-write failures. +- Marker restoration, resized buffers, trimmed markers, alternate buffers, text mismatch, and terminal disposal. +- VS Code volatile snapshots excluding markers, hydrating a live resume, and never hydrating a cold restore. + +Add component and integration coverage for: + +- Header icon ordering, filled state, and all density tiers. +- Attached 75% panel behavior and compact Door popover behavior. +- Door Notepad clicks not reattaching the Surface. +- Note creation, editing, copying, deletion, ordering, and keyboard isolation. +- Selection-popup action and platform shortcut behavior. +- Archive batch CWD display, staged deletion, Undo, commit-on-close, failed commit, and the unreadable-archive recovery. +- Explicit close, `dor kill`, replacement, controlled Standalone quit, VS Code panel disposal and view re-resolve, and their failure paths including Close anyway and Quit anyway. +- Strict separation of Standalone, VS Code, and demo archive stores. +- Confirmation that live notes are absent from every existing session-persistence format. + +Add focused Storybook scenarios for empty/filled headers, rich and plain notes, unavailable pins, minimized Door popovers, local/remote CWD batch headers, and staged archive deletion. + +Create `docs/specs/notepad.md` as the owning specification and add it to the AGENTS.md spec index and word-budget registry. Update the owning sections of the layout, mouse-and-clipboard, shortcuts, transport, Standalone, VS Code, local-security, and user-security specs. The security contract must disclose that explicitly captured terminal excerpts, style data, Surface metadata, and CWD can persist in the archive while ordinary scrollback remains unpersisted. + +Before implementation, read the glossary, the touched specifications and rationales, `PRODUCT.md`, `DESIGN.md`, and the shared design-token source. Finish by running focused library/host tests, spec lint, and the root test/build suites. diff --git a/lib/src/lib/notepad/archive-model.ts b/lib/src/lib/notepad/archive-model.ts new file mode 100644 index 000000000..fa264995f --- /dev/null +++ b/lib/src/lib/notepad/archive-model.ts @@ -0,0 +1,239 @@ +// Pure archive logic shared by the webview and the VS Code extension host: +// validation of whatever a host hands back, the idempotent mutation, and the +// projection from live notes to an archive batch. No DOM, no xterm. +import { SURFACE_KINDS, type SurfaceKind } from 'dor/commands/types'; +import type { CwdState, CwdSource, PathKind } from '../terminal-state'; +import type { + ArchiveBatch, + ArchivedNote, + LiveNote, + NoteContent, + NotepadArchiveMutation, + NotepadArchiveV1, + RichTextRun, + VolatileSurfaceNotes, +} from './types'; + +export const EMPTY_ARCHIVE: NotepadArchiveV1 = Object.freeze({ version: 1, batches: [] }) as NotepadArchiveV1; + +const HEX_COLOR = /^#[0-9a-f]{6}$/; +const CWD_SOURCES: readonly CwdSource[] = ['osc7', 'osc9_9', 'osc633', 'osc1337', 'process', 'manual']; +const PATH_KINDS: readonly PathKind[] = ['posix', 'windows', 'unknown']; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function readRun(value: unknown): RichTextRun | null { + if (!isRecord(value) || typeof value.text !== 'string') return null; + const run: RichTextRun = { text: value.text }; + if (value.bold !== undefined) { + if (value.bold !== true) return null; + run.bold = true; + } + if (value.italic !== undefined) { + if (value.italic !== true) return null; + run.italic = true; + } + for (const key of ['foreground', 'background'] as const) { + const color = value[key]; + if (color === undefined) continue; + if (typeof color !== 'string' || !HEX_COLOR.test(color)) return null; + run[key] = color; + } + return run; +} + +function readContent(value: unknown): NoteContent | null { + if (!isRecord(value)) return null; + if (value.kind === 'plain') { + return typeof value.text === 'string' ? { kind: 'plain', text: value.text } : null; + } + if (value.kind === 'terminal') { + if (!Array.isArray(value.runs)) return null; + const runs: RichTextRun[] = []; + for (const raw of value.runs) { + const run = readRun(raw); + if (!run) return null; + runs.push(run); + } + return { kind: 'terminal', runs }; + } + return null; +} + +function readNote(value: unknown): ArchivedNote | null { + if (!isRecord(value)) return null; + if (typeof value.id !== 'string' || !value.id) return null; + if (typeof value.createdAt !== 'number' || !Number.isFinite(value.createdAt)) return null; + const content = readContent(value.content); + if (!content) return null; + return { id: value.id, createdAt: value.createdAt, content }; +} + +/** Accepts exactly the `CwdState` shape (`lib/src/lib/terminal-state.ts`): + * required `path`, `pathKind`, `isRemote`, `source`, `updatedAt`; optional + * `uri`, `host`, `scheme: 'file'`. */ +export function readCwdState(value: unknown): CwdState | null { + if (!isRecord(value)) return null; + if (typeof value.path !== 'string') return null; + if (typeof value.pathKind !== 'string' || !PATH_KINDS.includes(value.pathKind as PathKind)) return null; + if (typeof value.isRemote !== 'boolean') return null; + if (typeof value.source !== 'string' || !CWD_SOURCES.includes(value.source as CwdSource)) return null; + if (typeof value.updatedAt !== 'number' || !Number.isFinite(value.updatedAt)) return null; + const cwd: CwdState = { + path: value.path, + pathKind: value.pathKind as PathKind, + isRemote: value.isRemote, + source: value.source as CwdSource, + updatedAt: value.updatedAt, + }; + if (value.uri !== undefined) { + if (typeof value.uri !== 'string') return null; + cwd.uri = value.uri; + } + if (value.host !== undefined) { + if (typeof value.host !== 'string') return null; + cwd.host = value.host; + } + if (value.scheme !== undefined) { + if (value.scheme !== 'file') return null; + cwd.scheme = 'file'; + } + return cwd; +} + +function readBatch(value: unknown): ArchiveBatch | null { + if (!isRecord(value)) return null; + if (typeof value.id !== 'string' || !value.id) return null; + if (typeof value.closedAt !== 'number' || !Number.isFinite(value.closedAt)) return null; + if (typeof value.surfaceTitle !== 'string') return null; + if (typeof value.surfaceKind !== 'string' || !SURFACE_KINDS.includes(value.surfaceKind as SurfaceKind)) return null; + let cwd: CwdState | null = null; + if (value.cwd !== null) { + cwd = readCwdState(value.cwd); + if (!cwd) return null; + } + if (!Array.isArray(value.notes)) return null; + const notes: ArchivedNote[] = []; + for (const raw of value.notes) { + const note = readNote(raw); + if (!note) return null; + notes.push(note); + } + return { + id: value.id, + closedAt: value.closedAt, + surfaceTitle: value.surfaceTitle, + surfaceKind: value.surfaceKind as SurfaceKind, + cwd, + notes, + }; +} + +/** + * Validate a stored archive. Accepts the parsed object or its JSON string + * (host state APIs may hand back the serialized form). Returns `null` for + * anything that is not exactly a v1 archive — the caller reports it as + * unreadable rather than replacing it (docs/specs/notepad.md → Archive). + * Unknown fields are dropped by projection, so nothing foreign persists forward. + */ +export function readNotepadArchive(raw: unknown): NotepadArchiveV1 | null { + let value = raw; + if (typeof value === 'string') { + try { + value = JSON.parse(value); + } catch { + return null; + } + } + if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.batches)) return null; + const batches: ArchiveBatch[] = []; + const seen = new Set(); + for (const rawBatch of value.batches) { + const batch = readBatch(rawBatch); + if (!batch || seen.has(batch.id)) return null; + seen.add(batch.id); + batches.push(batch); + } + return { version: 1, batches }; +} + +/** Apply a mutation immutably. Appends first (skipping batch ids already + * present), then batch deletes, then note deletes; batches emptied by note + * deletes are dropped. Applying the same mutation twice yields the same archive. */ +export function applyArchiveMutation(archive: NotepadArchiveV1, mutation: NotepadArchiveMutation): NotepadArchiveV1 { + const present = new Set(archive.batches.map((b) => b.id)); + let batches = archive.batches.slice(); + for (const batch of mutation.append ?? []) { + if (present.has(batch.id)) continue; + present.add(batch.id); + batches.push(batch); + } + if (mutation.deleteBatchIds?.length) { + const gone = new Set(mutation.deleteBatchIds); + batches = batches.filter((b) => !gone.has(b.id)); + } + if (mutation.deleteNotes?.length) { + const goneByBatch = new Map>(); + for (const { batchId, noteId } of mutation.deleteNotes) { + let set = goneByBatch.get(batchId); + if (!set) { + set = new Set(); + goneByBatch.set(batchId, set); + } + set.add(noteId); + } + batches = batches.flatMap((b) => { + const gone = goneByBatch.get(b.id); + if (!gone) return [b]; + const notes = b.notes.filter((n) => !gone.has(n.id)); + return notes.length === 0 ? [] : [{ ...b, notes }]; + }); + } + return { version: 1, batches }; +} + +export function isEmptyMutation(mutation: NotepadArchiveMutation): boolean { + return !mutation.append?.length && !mutation.deleteBatchIds?.length && !mutation.deleteNotes?.length; +} + +/** Strip the runtime source link; the archive never carries markers. */ +export function toArchivedNote(note: LiveNote): ArchivedNote { + return { id: note.id, createdAt: note.createdAt, content: note.content }; +} + +/** The batch a closing Surface appends. `id` is minted once per closure and + * reused on retry, which is what makes the append idempotent. */ +export function buildArchiveBatch(input: { + id: string; + closedAt: number; + surfaceTitle: string; + surfaceKind: SurfaceKind; + cwd: CwdState | null; + notes: ReadonlyArray; +}): ArchiveBatch { + return { + id: input.id, + closedAt: input.closedAt, + surfaceTitle: input.surfaceTitle, + surfaceKind: input.surfaceKind, + cwd: input.cwd, + notes: input.notes.map(toArchivedNote), + }; +} + +/** The batch the VS Code host appends for a mirrored Surface it is tearing + * down (editor-panel disposal, deactivation). `null` when there is nothing + * to archive. */ +export function batchFromVolatile(surface: VolatileSurfaceNotes, id: string, closedAt: number): ArchiveBatch | null { + if (surface.notes.length === 0) return null; + return buildArchiveBatch({ + id, + closedAt, + surfaceTitle: surface.surfaceTitle, + surfaceKind: surface.surfaceKind, + cwd: surface.cwd, + notes: surface.notes, + }); +} diff --git a/lib/src/lib/notepad/types.ts b/lib/src/lib/notepad/types.ts new file mode 100644 index 000000000..107aee674 --- /dev/null +++ b/lib/src/lib/notepad/types.ts @@ -0,0 +1,122 @@ +// The notepad model (docs/specs/notepad.md). Bare TypeScript on purpose: the +// archive half is shared with the VS Code extension host, which applies +// mutations on the webview's behalf, so nothing here may reach for the DOM. +import type { IMarker } from '@xterm/xterm'; +import type { SurfaceKind } from 'dor/commands/types'; +import type { CwdState } from '../terminal-state'; + +/** One styled span of captured terminal text. Colors are normalized `#rrggbb` + * (lowercase); a missing color means the theme default, so a rich note stays + * theme-adaptive where the terminal was. Only these four attributes are kept — + * underline, dim, blink, strike-through, and hyperlinks are dropped at capture. */ +export interface RichTextRun { + text: string; + bold?: true; + italic?: true; + foreground?: string; + background?: string; +} + +export type NoteContent = + | { kind: 'plain'; text: string } + | { kind: 'terminal'; runs: RichTextRun[] }; + +/** Runtime-only link from a captured note back to the scrollback it came from. + * Never serialized: markers belong to one live xterm instance. */ +export interface RuntimeTerminalSource { + terminalId: string; + startMarker: IMarker; + endMarker: IMarker; + /** Normalized endpoint columns (start inclusive, end inclusive), so the range + * can be rebuilt from the markers' current lines. */ + startColumn: number; + endColumn: number; + shape: 'linewise' | 'block'; + /** `extractSelectionText` output at capture; a pin resolves only when the + * rebuilt range reads back exactly this. */ + expectedRawText: string; +} + +export interface LiveNote { + id: string; + createdAt: number; + content: NoteContent; + source?: RuntimeTerminalSource; +} + +export type ArchivedNote = Omit; + +export interface ArchiveBatch { + id: string; + closedAt: number; + surfaceTitle: string; + surfaceKind: SurfaceKind; + /** The Session's last known CWD at teardown, whole; `null` for browser + * Surfaces and terminals that never reported one. */ + cwd: CwdState | null; + notes: ArchivedNote[]; +} + +export interface NotepadArchiveV1 { + version: 1; + batches: ArchiveBatch[]; +} + +/** Idempotent by batch and note id: appending a batch already present is a + * no-op, deleting something already gone is a no-op. Appends apply before + * deletes; a batch left with no notes is dropped. */ +export interface NotepadArchiveMutation { + append?: ArchiveBatch[]; + deleteBatchIds?: string[]; + deleteNotes?: Array<{ batchId: string; noteId: string }>; +} + +/** What the VS Code extension host mirrors in memory for one live Surface: + * everything a close would archive, minus the markers. */ +export interface VolatileSurfaceNotes { + surfaceId: string; + surfaceTitle: string; + surfaceKind: SurfaceKind; + cwd: CwdState | null; + notes: ArchivedNote[]; +} + +export interface VolatileNotepadSnapshot { + surfaces: VolatileSurfaceNotes[]; + /** Archive deletions staged in an open Archive view, committed by the host + * if the webview is disposed before the view closes. */ + stagedDeletions: Pick; +} + +export interface NotepadArchiveLoadResult { + /** Whatever the host stored — validated by `readNotepadArchive`. */ + raw: unknown; + /** Opaque token naming the stored version; `save` is refused when it moved. */ + revision: string; +} + +/** + * The host side of the archive. Hosts store bytes and a revision; the shared + * layer (`archive-service.ts`) does the read-modify-write with + * `applyArchiveMutation`, retrying on `'conflict'`. One store per host, never + * shared across hosts (docs/specs/notepad.md). + */ +export interface NotepadArchivePort { + /** `null` when nothing has ever been archived. */ + load(): Promise; + /** Replace the stored archive iff it is still at `baseRevision` (`null` = + * nothing stored). Atomic and owner-only on disk. */ + save(archive: NotepadArchiveV1, baseRevision: string | null): Promise<'ok' | 'conflict'>; + /** User-initiated only: move unreadable stored data aside (never delete it) + * so the next `load` returns `null`. */ + resetUnreadable(): Promise; + /** Mirror live notes into host memory (VS Code). Never written to disk. */ + syncVolatile?(snapshot: VolatileNotepadSnapshot): void; + /** The mirror a resumed webview was booted with, consumed by a live resume + * only — never by a cold restore. */ + loadVolatile?(): VolatileNotepadSnapshot | null; +} + +export function isPlainNote(content: NoteContent): content is { kind: 'plain'; text: string } { + return content.kind === 'plain'; +} diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index fccd754a7..c45f29ea5 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -5,6 +5,7 @@ import type { ShellEntry } from '../shell-defaults'; // Defined in its own dependency-free file so the Node proxy in lib/src/host can // share it without pulling this browser-typed module into a Node tsconfig. import type { IframeProxyResult } from './iframe-proxy-types'; +import type { NotepadArchivePort } from '../notepad/types'; export interface PtyInfo { id: string; @@ -373,4 +374,21 @@ export interface PlatformAdapter { // State persistence saveState(state: unknown): void; getState(): unknown; + + /** + * The Surface notepad's archive store (docs/specs/notepad.md). Present on + * every host that has a notepad — standalone (owner-only JSON under app + * data), VS Code (`globalState`), the website demo (memory). Absent means no + * notepad at all: Pocket omits it and the header icon, popup action, and + * Settings entry all stay hidden. + */ + notepadArchive?: NotepadArchivePort; + + /** + * Whether the browser hosting this webview reserves the notepad chord + * (Cmd/Ctrl+N opens a new window, unpreventable), so Dormouse shows no + * shortcut and binds none. Absent reads as `false`; the website's demo + * adapter sets it `true`. + */ + browserReservesNotepadChord?: boolean; } From 34126a8a9d8d950ea1794ed14501e96b3d3019db Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 4 Sep 2026 16:14:23 -0700 Subject: [PATCH 02/31] Notepad: live-note store, archive service, and the three host ports Rich extraction over xterm buffer cells with theme-resolved colors, the plain+HTML clipboard exporter, terminal source pins backed by buffer markers, the per-Surface live-note store with its volatile mirror, and the compare-and-swap archive service. Hosts: an owner-only atomic JSON file behind Tauri commands, a globalState entry behind a serialized extension-host queue with the in-memory mirror that hydrates a live resume and is archived on editor-panel disposal and deactivation, and an in-memory port for the fake adapter and the browser-dev harness. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HqBmeMGWo1P98V3mFpsc1Y --- lib/src/lib/notepad/archive-model.test.ts | 304 +++++++++++++ lib/src/lib/notepad/archive-service.test.ts | 228 ++++++++++ lib/src/lib/notepad/archive-service.ts | 232 ++++++++++ lib/src/lib/notepad/memory-archive-port.ts | 87 ++++ lib/src/lib/notepad/notepad-store.test.ts | 413 +++++++++++++++++ lib/src/lib/notepad/notepad-store.ts | 366 +++++++++++++++ lib/src/lib/notepad/rich-clipboard.test.ts | 144 ++++++ lib/src/lib/notepad/rich-clipboard.ts | 78 ++++ lib/src/lib/notepad/rich-extract.test.ts | 423 ++++++++++++++++++ lib/src/lib/notepad/rich-extract.ts | 287 ++++++++++++ lib/src/lib/notepad/source-link.test.ts | 332 ++++++++++++++ lib/src/lib/notepad/source-link.ts | 138 ++++++ lib/src/lib/platform/fake-adapter.ts | 25 ++ lib/src/lib/platform/vscode-adapter.test.ts | 126 ++++++ lib/src/lib/platform/vscode-adapter.ts | 79 ++++ lib/src/lib/terminal-lifecycle.ts | 4 + lib/src/lib/vscode-notepad-global.ts | 81 ++++ standalone/src-tauri/src/lib.rs | 401 ++++++++++++++++- standalone/src/browser-sidecar-adapter.ts | 12 + standalone/src/tauri-adapter.test.ts | 67 +++ standalone/src/tauri-adapter.ts | 34 ++ vscode-ext/scripts/esbuild.mjs | 10 + vscode-ext/src/extension.ts | 23 +- vscode-ext/src/message-router.ts | 73 +++ vscode-ext/src/message-types.ts | 14 + vscode-ext/src/notepad-archive-store.ts | 205 +++++++++ vscode-ext/src/notepad-volatile.ts | Bin 0 -> 8076 bytes vscode-ext/src/webview-html.ts | 13 +- vscode-ext/src/webview-messaging.ts | 6 +- vscode-ext/src/webview-view-provider.ts | 14 +- vscode-ext/test/message-router.test.ts | 116 +++++ vscode-ext/test/notepad-archive-store.test.ts | 212 +++++++++ vscode-ext/test/notepad-volatile.test.ts | 156 +++++++ vscode-ext/test/webview-html.test.ts | 27 ++ vscode-ext/vitest.config.mts | 5 + 35 files changed, 4708 insertions(+), 27 deletions(-) create mode 100644 lib/src/lib/notepad/archive-model.test.ts create mode 100644 lib/src/lib/notepad/archive-service.test.ts create mode 100644 lib/src/lib/notepad/archive-service.ts create mode 100644 lib/src/lib/notepad/memory-archive-port.ts create mode 100644 lib/src/lib/notepad/notepad-store.test.ts create mode 100644 lib/src/lib/notepad/notepad-store.ts create mode 100644 lib/src/lib/notepad/rich-clipboard.test.ts create mode 100644 lib/src/lib/notepad/rich-clipboard.ts create mode 100644 lib/src/lib/notepad/rich-extract.test.ts create mode 100644 lib/src/lib/notepad/rich-extract.ts create mode 100644 lib/src/lib/notepad/source-link.test.ts create mode 100644 lib/src/lib/notepad/source-link.ts create mode 100644 lib/src/lib/vscode-notepad-global.ts create mode 100644 vscode-ext/src/notepad-archive-store.ts create mode 100644 vscode-ext/src/notepad-volatile.ts create mode 100644 vscode-ext/test/notepad-archive-store.test.ts create mode 100644 vscode-ext/test/notepad-volatile.test.ts diff --git a/lib/src/lib/notepad/archive-model.test.ts b/lib/src/lib/notepad/archive-model.test.ts new file mode 100644 index 000000000..ac40343a8 --- /dev/null +++ b/lib/src/lib/notepad/archive-model.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { IMarker } from '@xterm/xterm'; +import type { CwdState } from '../terminal-state'; +import { + applyArchiveMutation, + batchFromVolatile, + buildArchiveBatch, + isEmptyMutation, + readCwdState, + readNotepadArchive, + toArchivedNote, +} from './archive-model'; +import type { ArchiveBatch, LiveNote, NotepadArchiveV1, RuntimeTerminalSource } from './types'; + +const LOCAL_CWD: CwdState = { + path: '/home/ned/projects', + pathKind: 'posix', + isRemote: false, + source: 'osc7', + updatedAt: 1_700_000_000_000, +}; + +const REMOTE_CWD: CwdState = { + path: '/srv/app', + uri: 'file://build-box/srv/app', + host: 'build-box', + scheme: 'file', + pathKind: 'posix', + isRemote: true, + source: 'osc7', + updatedAt: 1_700_000_000_001, +}; + +function batch(id: string, notes: Array<{ id: string; text: string }>, cwd: CwdState | null = LOCAL_CWD): ArchiveBatch { + return { + id, + closedAt: 1_700_000_000_000, + surfaceTitle: `pane ${id}`, + surfaceKind: 'terminal', + cwd, + notes: notes.map((note) => ({ + id: note.id, + createdAt: 1_700_000_000_000, + content: { kind: 'plain', text: note.text }, + })), + }; +} + +function archive(...batches: ArchiveBatch[]): NotepadArchiveV1 { + return { version: 1, batches }; +} + +describe('readNotepadArchive', () => { + it('accepts a valid archive', () => { + const value = archive(batch('b1', [{ id: 'n1', text: 'hello' }])); + expect(readNotepadArchive(value)).toEqual(value); + }); + + it('accepts the JSON-string form a host state API may hand back', () => { + const value = archive(batch('b1', [{ id: 'n1', text: 'hello' }]), batch('b2', [], null)); + expect(readNotepadArchive(JSON.stringify(value))).toEqual(value); + }); + + it('rejects a string that is not JSON', () => { + expect(readNotepadArchive('{ not json')).toBeNull(); + }); + + it('rejects a missing or wrong version', () => { + expect(readNotepadArchive({ batches: [] })).toBeNull(); + expect(readNotepadArchive({ version: 2, batches: [] })).toBeNull(); + expect(readNotepadArchive(null)).toBeNull(); + expect(readNotepadArchive({ version: 1 })).toBeNull(); + }); + + it('rejects a run whose color is not a normalized hex triple', () => { + const rich = { + version: 1, + batches: [ + { + ...batch('b1', []), + notes: [ + { + id: 'n1', + createdAt: 1, + content: { kind: 'terminal', runs: [{ text: 'x', foreground: 'red' }] }, + }, + ], + }, + ], + }; + expect(readNotepadArchive(rich)).toBeNull(); + rich.batches[0].notes[0].content.runs[0].foreground = '#FF0000'; + expect(readNotepadArchive(rich), 'uppercase is not normalized').toBeNull(); + rich.batches[0].notes[0].content.runs[0].foreground = '#ff0000'; + expect(readNotepadArchive(rich)).not.toBeNull(); + }); + + it('rejects a batch whose cwd is the wrong shape', () => { + const bad = { ...batch('b1', [{ id: 'n1', text: 'x' }]), cwd: { path: '/tmp' } }; + expect(readNotepadArchive({ version: 1, batches: [bad] })).toBeNull(); + }); + + it('rejects duplicate batch ids outright', () => { + const value = archive(batch('b1', [{ id: 'n1', text: 'a' }]), batch('b1', [{ id: 'n2', text: 'b' }])); + expect(readNotepadArchive(value)).toBeNull(); + }); + + it('drops unknown fields by projection', () => { + const value = { + version: 1, + extra: 'ignored', + batches: [ + { + ...batch('b1', [{ id: 'n1', text: 'a' }]), + surfaceLabel: 'ignored', + notes: [{ id: 'n1', createdAt: 1, content: { kind: 'plain', text: 'a' }, source: { terminalId: 't1' } }], + }, + ], + }; + const read = readNotepadArchive(value); + expect(read).not.toBeNull(); + expect(read).not.toHaveProperty('extra'); + expect(read!.batches[0]).not.toHaveProperty('surfaceLabel'); + expect(read!.batches[0].notes[0]).not.toHaveProperty('source'); + }); +}); + +describe('readCwdState', () => { + it('reads a local cwd', () => { + expect(readCwdState(LOCAL_CWD)).toEqual(LOCAL_CWD); + }); + + it('reads a remote cwd with its host and uri', () => { + expect(readCwdState(REMOTE_CWD)).toEqual(REMOTE_CWD); + }); + + it('rejects a bad source, path kind, or scheme', () => { + expect(readCwdState({ ...LOCAL_CWD, source: 'guess' })).toBeNull(); + expect(readCwdState({ ...LOCAL_CWD, pathKind: 'dos' })).toBeNull(); + expect(readCwdState({ ...LOCAL_CWD, scheme: 'https' })).toBeNull(); + expect(readCwdState(null)).toBeNull(); + }); + + it('leaves a null cwd to the batch reader, which keeps it', () => { + expect(readCwdState(null)).toBeNull(); + const read = readNotepadArchive(archive(batch('b1', [{ id: 'n1', text: 'a' }], null))); + expect(read!.batches[0].cwd).toBeNull(); + }); + + it('keeps a remote cwd through a whole archive round trip', () => { + const read = readNotepadArchive(JSON.stringify(archive(batch('b1', [{ id: 'n1', text: 'a' }], REMOTE_CWD)))); + expect(read!.batches[0].cwd).toEqual(REMOTE_CWD); + }); +}); + +describe('applyArchiveMutation', () => { + it('appends, and appending a batch id already present is a no-op', () => { + const start = archive(batch('b1', [{ id: 'n1', text: 'a' }])); + const appended = applyArchiveMutation(start, { append: [batch('b2', [{ id: 'n2', text: 'b' }])] }); + expect(appended.batches.map((b) => b.id)).toEqual(['b1', 'b2']); + const again = applyArchiveMutation(appended, { append: [batch('b2', [{ id: 'n2', text: 'changed' }])] }); + expect(again.batches).toEqual(appended.batches); + }); + + it('deletes whole batches', () => { + const start = archive(batch('b1', [{ id: 'n1', text: 'a' }]), batch('b2', [{ id: 'n2', text: 'b' }])); + const next = applyArchiveMutation(start, { deleteBatchIds: ['b1', 'gone'] }); + expect(next.batches.map((b) => b.id)).toEqual(['b2']); + }); + + it('deletes individual notes and keeps the rest in order', () => { + const start = archive( + batch('b1', [ + { id: 'n1', text: 'a' }, + { id: 'n2', text: 'b' }, + { id: 'n3', text: 'c' }, + ]), + ); + const next = applyArchiveMutation(start, { deleteNotes: [{ batchId: 'b1', noteId: 'n2' }] }); + expect(next.batches[0].notes.map((n) => n.id)).toEqual(['n1', 'n3']); + }); + + it('drops a batch emptied by note deletes', () => { + const start = archive(batch('b1', [{ id: 'n1', text: 'a' }]), batch('b2', [{ id: 'n2', text: 'b' }])); + const next = applyArchiveMutation(start, { + deleteNotes: [{ batchId: 'b1', noteId: 'n1' }], + }); + expect(next.batches.map((b) => b.id)).toEqual(['b2']); + }); + + it('is a fixpoint: applying the same mutation twice changes nothing', () => { + const start = archive(batch('b1', [{ id: 'n1', text: 'a' }, { id: 'n2', text: 'b' }])); + const mutation = { + append: [batch('b2', [{ id: 'n3', text: 'c' }])], + deleteBatchIds: ['gone'], + deleteNotes: [{ batchId: 'b1', noteId: 'n1' }], + }; + const once = applyArchiveMutation(start, mutation); + const twice = applyArchiveMutation(once, mutation); + expect(twice).toEqual(once); + }); + + it('applies appends before deletes within one mutation', () => { + const start = archive(batch('b1', [{ id: 'n1', text: 'a' }])); + const next = applyArchiveMutation(start, { + append: [batch('b2', [{ id: 'n2', text: 'b' }, { id: 'n3', text: 'c' }])], + deleteNotes: [{ batchId: 'b2', noteId: 'n2' }], + }); + expect(next.batches.map((b) => b.id)).toEqual(['b1', 'b2']); + expect(next.batches[1].notes.map((n) => n.id)).toEqual(['n3']); + }); + + it('does not mutate the archive it was given', () => { + const start = archive(batch('b1', [{ id: 'n1', text: 'a' }])); + const before = JSON.stringify(start); + applyArchiveMutation(start, { append: [batch('b2', [])], deleteBatchIds: ['b1'] }); + expect(JSON.stringify(start)).toBe(before); + }); + + it('recognizes an empty mutation', () => { + expect(isEmptyMutation({})).toBe(true); + expect(isEmptyMutation({ append: [], deleteBatchIds: [], deleteNotes: [] })).toBe(true); + expect(isEmptyMutation({ deleteBatchIds: ['b1'] })).toBe(false); + }); +}); + +function fakeMarker(): IMarker { + return { id: 1, line: 4, isDisposed: false, dispose: vi.fn(), onDispose: vi.fn() } as unknown as IMarker; +} + +function fakeSource(terminalId = 't1'): RuntimeTerminalSource { + return { + terminalId, + startMarker: fakeMarker(), + endMarker: fakeMarker(), + startColumn: 0, + endColumn: 10, + shape: 'linewise', + expectedRawText: 'hello', + }; +} + +describe('buildArchiveBatch', () => { + it('strips the runtime source from every note', () => { + const notes: LiveNote[] = [ + { id: 'n1', createdAt: 1, content: { kind: 'terminal', runs: [{ text: 'hello', bold: true }] }, source: fakeSource() }, + { id: 'n2', createdAt: 2, content: { kind: 'plain', text: 'typed' } }, + ]; + const built = buildArchiveBatch({ + id: 'b1', + closedAt: 9, + surfaceTitle: 'zsh', + surfaceKind: 'terminal', + cwd: LOCAL_CWD, + notes, + }); + expect(built.notes).toEqual([ + { id: 'n1', createdAt: 1, content: { kind: 'terminal', runs: [{ text: 'hello', bold: true }] } }, + { id: 'n2', createdAt: 2, content: { kind: 'plain', text: 'typed' } }, + ]); + expect(built.notes[0]).not.toHaveProperty('source'); + // The projection survives validation, which is what the host will store. + expect(readNotepadArchive({ version: 1, batches: [built] })).not.toBeNull(); + }); + + it('toArchivedNote drops the source but keeps id, time, and content', () => { + const note: LiveNote = { id: 'n1', createdAt: 5, content: { kind: 'plain', text: 'x' }, source: fakeSource() }; + expect(toArchivedNote(note)).toEqual({ id: 'n1', createdAt: 5, content: { kind: 'plain', text: 'x' } }); + }); +}); + +describe('batchFromVolatile', () => { + it('returns null when the mirrored Surface has no notes', () => { + expect( + batchFromVolatile( + { surfaceId: 's1', surfaceTitle: 'zsh', surfaceKind: 'terminal', cwd: null, notes: [] }, + 'b1', + 9, + ), + ).toBeNull(); + }); + + it('builds a batch carrying the mirrored metadata', () => { + const built = batchFromVolatile( + { + surfaceId: 's1', + surfaceTitle: 'build', + surfaceKind: 'terminal', + cwd: REMOTE_CWD, + notes: [{ id: 'n1', createdAt: 1, content: { kind: 'plain', text: 'x' } }], + }, + 'b1', + 9, + ); + expect(built).toEqual({ + id: 'b1', + closedAt: 9, + surfaceTitle: 'build', + surfaceKind: 'terminal', + cwd: REMOTE_CWD, + notes: [{ id: 'n1', createdAt: 1, content: { kind: 'plain', text: 'x' } }], + }); + }); +}); diff --git a/lib/src/lib/notepad/archive-service.test.ts b/lib/src/lib/notepad/archive-service.test.ts new file mode 100644 index 000000000..94240464e --- /dev/null +++ b/lib/src/lib/notepad/archive-service.test.ts @@ -0,0 +1,228 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakePtyAdapter, setPlatform } from '../platform'; +import type { PlatformAdapter } from '../platform/types'; +import { applyArchiveMutation } from './archive-model'; +import { + ARCHIVE_ABSENT_MESSAGE, + ARCHIVE_UNREADABLE_MESSAGE, + __resetArchiveServiceForTests, + ensureArchiveLoaded, + getArchiveSnapshot, + hasNotepadArchive, + mutateArchive, + refreshArchive, + resetUnreadableArchive, + subscribeToArchive, +} from './archive-service'; +import { createMemoryNotepadArchivePort, type MemoryNotepadArchivePort } from './memory-archive-port'; +import type { ArchiveBatch, NotepadArchivePort, NotepadArchiveV1 } from './types'; + +function batch(id: string, noteText = id): ArchiveBatch { + return { + id, + closedAt: 1_700_000_000_000, + surfaceTitle: `pane ${id}`, + surfaceKind: 'terminal', + cwd: null, + notes: [{ id: `${id}-n1`, createdAt: 1, content: { kind: 'plain', text: noteText } }], + }; +} + +/** A platform that is nothing but its archive port — the service reads no other + * adapter surface. */ +function installPort(port: NotepadArchivePort | undefined): void { + setPlatform({ notepadArchive: port } as unknown as PlatformAdapter); +} + +let port: MemoryNotepadArchivePort; + +beforeEach(() => { + __resetArchiveServiceForTests(); + port = createMemoryNotepadArchivePort(); + installPort(port); +}); + +afterEach(() => { + __resetArchiveServiceForTests(); +}); + +describe('loading', () => { + it('reports an empty archive as ready when nothing was ever archived', async () => { + await ensureArchiveLoaded(); + expect(getArchiveSnapshot()).toEqual({ status: 'ready', archive: { version: 1, batches: [] } }); + }); + + it('loads what the host stored and notifies subscribers', async () => { + port.seed({ version: 1, batches: [batch('b1')] }); + const listener = vi.fn(); + subscribeToArchive(listener); + await ensureArchiveLoaded(); + expect(getArchiveSnapshot().status).toBe('ready'); + expect(getArchiveSnapshot().archive.batches.map((b) => b.id)).toEqual(['b1']); + expect(listener).toHaveBeenCalled(); + }); + + it('is idempotent: a second call joins the first load rather than re-reading', async () => { + const load = vi.spyOn(port, 'load'); + await Promise.all([ensureArchiveLoaded(), ensureArchiveLoaded()]); + await ensureArchiveLoaded(); + expect(load).toHaveBeenCalledTimes(1); + await refreshArchive(); + expect(load).toHaveBeenCalledTimes(2); + }); + + it('is absent when the host has no archive port', async () => { + installPort(undefined); + expect(hasNotepadArchive()).toBe(false); + await ensureArchiveLoaded(); + expect(getArchiveSnapshot().status).toBe('absent'); + await expect(mutateArchive({ append: [batch('b1')] })).rejects.toThrow(ARCHIVE_ABSENT_MESSAGE); + }); + + it('reports a read failure as a transient error', async () => { + vi.spyOn(port, 'load').mockRejectedValueOnce(new Error('disk on fire')); + await ensureArchiveLoaded(); + expect(getArchiveSnapshot().status).toBe('error'); + expect(getArchiveSnapshot().error).toBe('disk on fire'); + }); +}); + +describe('unreadable data', () => { + it('reports it and never replaces it', async () => { + port.corrupt({ version: 99, batches: 'nope' }); + await ensureArchiveLoaded(); + expect(getArchiveSnapshot().status).toBe('unreadable'); + expect(getArchiveSnapshot().error).toBe(ARCHIVE_UNREADABLE_MESSAGE); + expect(getArchiveSnapshot().archive.batches).toEqual([]); + + await expect(mutateArchive({ append: [batch('b1')] })).rejects.toThrow(ARCHIVE_UNREADABLE_MESSAGE); + // The failed append must not have written over it. + expect((await port.load())?.raw).toEqual({ version: 99, batches: 'nope' }); + expect(getArchiveSnapshot().status).toBe('unreadable'); + }); + + it('recovers only on the user-initiated reset, keeping the old data aside', async () => { + port.corrupt('{ not json'); + await ensureArchiveLoaded(); + expect(getArchiveSnapshot().status).toBe('unreadable'); + + await resetUnreadableArchive(); + expect(getArchiveSnapshot().status).toBe('ready'); + expect(getArchiveSnapshot().archive.batches).toEqual([]); + expect(port.unreadableCopies()).toEqual(['{ not json']); + + await mutateArchive({ append: [batch('b1')] }); + expect(getArchiveSnapshot().archive.batches.map((b) => b.id)).toEqual(['b1']); + }); +}); + +describe('mutating', () => { + it('appends and deletes through the host store', async () => { + await mutateArchive({ append: [batch('b1'), batch('b2')] }); + expect(getArchiveSnapshot().archive.batches.map((b) => b.id)).toEqual(['b1', 'b2']); + expect(getArchiveSnapshot().status).toBe('ready'); + + await mutateArchive({ deleteBatchIds: ['b1'] }); + expect(getArchiveSnapshot().archive.batches.map((b) => b.id)).toEqual(['b2']); + + await mutateArchive({ deleteNotes: [{ batchId: 'b2', noteId: 'b2-n1' }] }); + expect(getArchiveSnapshot().archive.batches).toEqual([]); + expect((await port.load())?.raw).toEqual({ version: 1, batches: [] }); + }); + + it('touches the host not at all for an empty mutation', async () => { + const load = vi.spyOn(port, 'load'); + const save = vi.spyOn(port, 'save'); + await mutateArchive({}); + await mutateArchive({ append: [], deleteBatchIds: [] }); + expect(load).not.toHaveBeenCalled(); + expect(save).not.toHaveBeenCalled(); + }); + + it('serializes concurrent mutations so both land without a conflict', async () => { + let conflicts = 0; + const save = port.save.bind(port); + vi.spyOn(port, 'save').mockImplementation(async (archive, base) => { + const outcome = await save(archive, base); + if (outcome === 'conflict') conflicts += 1; + return outcome; + }); + + await Promise.all([ + mutateArchive({ append: [batch('b1')] }), + mutateArchive({ append: [batch('b2')] }), + ]); + + expect(getArchiveSnapshot().archive.batches.map((b) => b.id)).toEqual(['b1', 'b2']); + expect(conflicts).toBe(0); + }); + + it('reloads and reapplies when someone else wrote first, landing both batches', async () => { + const stolen = batch('outsider'); + let firstSave = true; + const real = port.save.bind(port); + vi.spyOn(port, 'save').mockImplementation(async (archive, base) => { + if (!firstSave) return real(archive, base); + firstSave = false; + // Another writer (a second Window, the extension host) got there between + // our load and our save. + const loaded = await port.load(); + const current = (loaded?.raw as NotepadArchiveV1 | undefined) ?? { version: 1, batches: [] }; + await real(applyArchiveMutation(current, { append: [stolen] }), loaded?.revision ?? null); + return 'conflict'; + }); + + await mutateArchive({ append: [batch('mine')] }); + + expect(getArchiveSnapshot().archive.batches.map((b) => b.id)).toEqual(['outsider', 'mine']); + expect(getArchiveSnapshot().status).toBe('ready'); + }); + + it('gives up after a bounded number of conflicts', async () => { + vi.spyOn(port, 'save').mockResolvedValue('conflict'); + await expect(mutateArchive({ append: [batch('b1')] })).rejects.toThrow(/kept changing/); + expect(getArchiveSnapshot().status).toBe('error'); + expect(port.save).toHaveBeenCalledTimes(5); + }); + + it('surfaces a save failure and leaves the archive unchanged', async () => { + await mutateArchive({ append: [batch('b1')] }); + const before = getArchiveSnapshot().archive; + + vi.spyOn(port, 'save').mockRejectedValueOnce(new Error('read-only volume')); + await expect(mutateArchive({ append: [batch('b2')] })).rejects.toThrow('read-only volume'); + + expect(getArchiveSnapshot().status).toBe('error'); + expect(getArchiveSnapshot().error).toBe('read-only volume'); + expect(getArchiveSnapshot().archive).toBe(before); + expect((await port.load())?.raw).toEqual({ version: 1, batches: [batch('b1')] }); + + // The queue survives one caller's failure. + await mutateArchive({ append: [batch('b3')] }); + expect(getArchiveSnapshot().archive.batches.map((b) => b.id)).toEqual(['b1', 'b3']); + }); + + it('is idempotent by batch id across retries of the same closure', async () => { + await mutateArchive({ append: [batch('b1')] }); + await mutateArchive({ append: [batch('b1')] }); + expect(getArchiveSnapshot().archive.batches).toHaveLength(1); + }); +}); + +describe('through FakePtyAdapter', () => { + it('uses the adapter own in-memory port, including its seed and corrupt seams', async () => { + const adapter = new FakePtyAdapter(); + setPlatform(adapter); + adapter.seedNotepadArchive({ version: 1, batches: [batch('seeded')] }); + await ensureArchiveLoaded(); + expect(getArchiveSnapshot().archive.batches.map((b) => b.id)).toEqual(['seeded']); + + await mutateArchive({ append: [batch('closed')] }); + expect(adapter.notepadArchive.lastVolatileSnapshot()).toBeNull(); + expect(getArchiveSnapshot().archive.batches.map((b) => b.id)).toEqual(['seeded', 'closed']); + + adapter.corruptNotepadArchive(); + await refreshArchive(); + expect(getArchiveSnapshot().status).toBe('unreadable'); + }); +}); diff --git a/lib/src/lib/notepad/archive-service.ts b/lib/src/lib/notepad/archive-service.ts new file mode 100644 index 000000000..abe207521 --- /dev/null +++ b/lib/src/lib/notepad/archive-service.ts @@ -0,0 +1,232 @@ +// The one path between the UI and `getPlatform().notepadArchive`: a +// `useSyncExternalStore` store holding the loaded archive, and the +// read-modify-write that every mutation goes through +// (docs/specs/notepad.md → Archive). +// +// Hosts only store bytes and a revision, so the compare-and-swap lives here: +// load, validate, `applyArchiveMutation`, save against the revision we read, +// and on `'conflict'` do it again over whoever won. Mutations are idempotent by +// batch and note id, which is what makes that retry safe — a closure that +// already landed is re-applied as a no-op rather than a duplicate batch. +import { getPlatform } from '../platform'; +import { + applyArchiveMutation, + EMPTY_ARCHIVE, + isEmptyMutation, + readNotepadArchive, +} from './archive-model'; +import type { NotepadArchiveMutation, NotepadArchivePort, NotepadArchiveV1 } from './types'; + +export type NotepadArchiveStatus = 'absent' | 'loading' | 'ready' | 'unreadable' | 'error'; + +export interface NotepadArchiveState { + /** `absent` = this host has no archive port at all (Pocket, or before a + * platform exists). `unreadable` = stored data that validation rejected; the + * archive below is empty but the stored bytes are untouched. */ + status: NotepadArchiveStatus; + archive: NotepadArchiveV1; + /** User-presentable; set for `unreadable` and `error`. */ + error?: string; +} + +/** A save cannot win against an endlessly-rewritten archive, and an unbounded + * retry would spin instead of surfacing that. */ +const MAX_SAVE_ATTEMPTS = 5; + +export const ARCHIVE_ABSENT_MESSAGE = 'This host has no notepad archive.'; +export const ARCHIVE_UNREADABLE_MESSAGE = + 'The notepad archive could not be read. Recover it from the Archive view to start a new one.'; +export const ARCHIVE_BUSY_MESSAGE = + 'The notepad archive kept changing while saving. Try again.'; + +const INITIAL_STATE: NotepadArchiveState = { status: 'absent', archive: EMPTY_ARCHIVE }; + +let state: NotepadArchiveState = INITIAL_STATE; +const listeners = new Set<() => void>(); + +function setState(next: NotepadArchiveState): void { + state = next; + listeners.forEach((listener) => listener()); +} + +export function subscribeToArchive(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** Stable snapshot reference (changes only on mutation) for `useSyncExternalStore`. */ +export function getArchiveSnapshot(): NotepadArchiveState { + return state; +} + +/** + * The host's port, or `undefined` when there is none. `getPlatform()` throws + * before a platform is installed, which is a normal state for unit tests and + * for the boot frames before `initPlatform()` — neither is an error worth + * surfacing, both are simply "no archive". + */ +function archivePort(): NotepadArchivePort | undefined { + try { + return getPlatform().notepadArchive; + } catch { + return undefined; + } +} + +export function hasNotepadArchive(): boolean { + return archivePort() !== undefined; +} + +function messageOf(error: unknown): string { + return error instanceof Error && error.message ? error.message : String(error); +} + +let inFlightLoad: Promise | null = null; + +async function runLoad(port: NotepadArchivePort): Promise { + setState({ status: 'loading', archive: state.archive }); + try { + const loaded = await port.load(); + if (!loaded) { + setState({ status: 'ready', archive: EMPTY_ARCHIVE }); + return; + } + const archive = readNotepadArchive(loaded.raw); + if (!archive) { + // Never replace it: the stored bytes stay exactly as they are until the + // user asks for recovery (`resetUnreadableArchive`). + setState({ status: 'unreadable', archive: EMPTY_ARCHIVE, error: ARCHIVE_UNREADABLE_MESSAGE }); + return; + } + setState({ status: 'ready', archive }); + } catch (error) { + setState({ status: 'error', archive: state.archive, error: messageOf(error) }); + } +} + +/** Re-read the stored archive unconditionally. Never rejects — a failed read is + * reported through the state so every subscriber sees the same thing. */ +export function refreshArchive(): Promise { + const port = archivePort(); + if (!port) { + setState({ status: 'absent', archive: EMPTY_ARCHIVE }); + return Promise.resolve(); + } + const load = runLoad(port); + inFlightLoad = load; + void load.then(() => { + if (inFlightLoad === load) inFlightLoad = null; + }); + return load; +} + +/** + * Load once. Idempotent: the Archive view calls it on open and every mutation + * calls it first, and a second caller during a load joins the first rather than + * issuing a second read. A previous failure is retried; a successful load + * (including an unreadable verdict, which only recovery changes) is not. + */ +export function ensureArchiveLoaded(): Promise { + if (inFlightLoad) return inFlightLoad; + if (state.status === 'ready' || state.status === 'unreadable') return Promise.resolve(); + return refreshArchive(); +} + +// One queue for every mutation. Two closing Surfaces would otherwise read the +// same revision and one would lose its batch to the other's conflict; serialized, +// the second reads what the first wrote. +let tail: Promise = Promise.resolve(); + +function enqueue(task: () => Promise): Promise { + // Both arms run `task`: one caller's rejection must not cancel the next. + const next = tail.then(task, task); + tail = next.then( + () => undefined, + () => undefined, + ); + return next; +} + +async function runMutation(port: NotepadArchivePort, mutation: NotepadArchiveMutation): Promise { + for (let attempt = 0; attempt < MAX_SAVE_ATTEMPTS; attempt++) { + let loaded; + try { + loaded = await port.load(); + } catch (error) { + setState({ status: 'error', archive: state.archive, error: messageOf(error) }); + throw error instanceof Error ? error : new Error(messageOf(error)); + } + const base = loaded ? readNotepadArchive(loaded.raw) : EMPTY_ARCHIVE; + if (!base) { + setState({ status: 'unreadable', archive: EMPTY_ARCHIVE, error: ARCHIVE_UNREADABLE_MESSAGE }); + throw new Error(ARCHIVE_UNREADABLE_MESSAGE); + } + const next = applyArchiveMutation(base, mutation); + let outcome: 'ok' | 'conflict'; + try { + outcome = await port.save(next, loaded?.revision ?? null); + } catch (error) { + // The stored archive is whatever it was; publish the failure and leave + // our copy alone rather than pretending the write landed. + setState({ status: 'error', archive: state.archive, error: messageOf(error) }); + throw error instanceof Error ? error : new Error(messageOf(error)); + } + if (outcome === 'ok') { + setState({ status: 'ready', archive: next }); + return; + } + // Conflict: someone else wrote between our load and save. Re-read and + // re-apply — idempotency makes the second pass land only what is missing. + } + setState({ status: 'error', archive: state.archive, error: ARCHIVE_BUSY_MESSAGE }); + throw new Error(ARCHIVE_BUSY_MESSAGE); +} + +/** + * Apply one mutation to the stored archive. Rejects with a user-presentable + * message when the archive cannot be written — closure paths surface that as + * "Keep open / Close anyway" rather than dropping the notes. + */ +export function mutateArchive(mutation: NotepadArchiveMutation): Promise { + // Nothing to write is not a reason to touch the host's store, and a closure + // with no notes takes this path. + if (isEmptyMutation(mutation)) return Promise.resolve(); + const port = archivePort(); + if (!port) { + setState({ status: 'absent', archive: EMPTY_ARCHIVE }); + return Promise.reject(new Error(ARCHIVE_ABSENT_MESSAGE)); + } + return enqueue(() => runMutation(port, mutation)); +} + +/** + * Move unreadable stored data aside and start empty. The only path that + * replaces an archive validation rejected, and only ever from an explicit user + * action in the Archive view. + */ +export async function resetUnreadableArchive(): Promise { + const port = archivePort(); + if (!port) { + setState({ status: 'absent', archive: EMPTY_ARCHIVE }); + throw new Error(ARCHIVE_ABSENT_MESSAGE); + } + await enqueue(async () => { + try { + await port.resetUnreadable(); + } catch (error) { + setState({ status: 'error', archive: state.archive, error: messageOf(error) }); + throw error instanceof Error ? error : new Error(messageOf(error)); + } + }); + await refreshArchive(); +} + +/** Test-only helper. Do not use in application code. */ +export function __resetArchiveServiceForTests(): void { + state = INITIAL_STATE; + listeners.clear(); + inFlightLoad = null; + tail = Promise.resolve(); +} diff --git a/lib/src/lib/notepad/memory-archive-port.ts b/lib/src/lib/notepad/memory-archive-port.ts new file mode 100644 index 000000000..e05312a51 --- /dev/null +++ b/lib/src/lib/notepad/memory-archive-port.ts @@ -0,0 +1,87 @@ +// The archive store as plain memory: the website demo's entire implementation, +// the standalone browser-dev harness's stand-in for the Tauri file, and what +// `FakePtyAdapter` hands tests and stories. It honors the same +// compare-and-swap contract as the real hosts, so a conflict exercised here is +// the same retry loop the file and `globalState` hosts drive +// (docs/specs/notepad.md). +import type { + NotepadArchiveLoadResult, + NotepadArchivePort, + NotepadArchiveV1, + VolatileNotepadSnapshot, +} from './types'; + +export interface MemoryNotepadArchivePort extends NotepadArchivePort { + /** Install stored data without going through `save` — the seam tests, + * stories, and the dev harness use to start from a populated archive. */ + seed(archive: NotepadArchiveV1): void; + /** Store something `readNotepadArchive` rejects, so the unreadable path can + * be exercised without hand-writing a corrupt file. */ + corrupt(raw?: unknown): void; + /** Copies moved aside by `resetUnreadable`, newest last. Nothing this port + * holds is ever destroyed by recovery. */ + unreadableCopies(): readonly unknown[]; + /** The last mirror `syncVolatile` received; `null` until one arrives. */ + lastVolatileSnapshot(): VolatileNotepadSnapshot | null; + /** Forget everything, including the set-aside copies (test teardown). */ + clear(): void; +} + +/** Stored data is JSON on every real host, so the memory port round-trips it + * too: a caller that mutates the archive it saved must not reach back into + * the store. */ +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +export function createMemoryNotepadArchivePort(): MemoryNotepadArchivePort { + // `undefined` is "nothing has ever been archived" (a `null` load); any other + // value is stored bytes, valid or not — validation is the shared layer's job. + let stored: unknown | undefined; + let revision = 0; + const setAside: unknown[] = []; + let volatileSnapshot: VolatileNotepadSnapshot | null = null; + + const revisionToken = (): string | null => (stored === undefined ? null : String(revision)); + + return { + async load(): Promise { + if (stored === undefined) return null; + return { raw: stored, revision: String(revision) }; + }, + async save(archive: NotepadArchiveV1, baseRevision: string | null): Promise<'ok' | 'conflict'> { + if (baseRevision !== revisionToken()) return 'conflict'; + stored = clone(archive); + revision += 1; + return 'ok'; + }, + async resetUnreadable(): Promise { + if (stored !== undefined) setAside.push(stored); + stored = undefined; + revision += 1; + }, + syncVolatile(snapshot: VolatileNotepadSnapshot): void { + volatileSnapshot = snapshot; + }, + seed(archive: NotepadArchiveV1): void { + stored = clone(archive); + revision += 1; + }, + corrupt(raw: unknown = '{ not an archive'): void { + stored = raw; + revision += 1; + }, + unreadableCopies(): readonly unknown[] { + return setAside; + }, + lastVolatileSnapshot(): VolatileNotepadSnapshot | null { + return volatileSnapshot; + }, + clear(): void { + stored = undefined; + revision += 1; + setAside.length = 0; + volatileSnapshot = null; + }, + }; +} diff --git a/lib/src/lib/notepad/notepad-store.test.ts b/lib/src/lib/notepad/notepad-store.test.ts new file mode 100644 index 000000000..f253a9903 --- /dev/null +++ b/lib/src/lib/notepad/notepad-store.test.ts @@ -0,0 +1,413 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IMarker } from '@xterm/xterm'; +import { FakePtyAdapter, setPlatform } from '../platform'; +import type { CwdState } from '../terminal-state'; +import { + addPlainNote, + addTerminalNote, + buildVolatileSnapshot, + clearAllNotepads, + deleteNote, + dropSource, + dropSourcesForTerminal, + getNotepadSnapshot, + getNotes, + getOpenNotepadId, + hydrateNotepadFromVolatile, + noteCount, + pruneEmptyNote, + removeSurface, + setNotepadSurfaceMetaResolver, + setNoteText, + setOpenNotepadId, + setStagedArchiveDeletions, + subscribeToNotepad, + subscribeToOpenNotepad, + transferNotepad, +} from './notepad-store'; +import type { RuntimeTerminalSource } from './types'; + +let adapter: FakePtyAdapter; + +/** One microtask tick — the volatile sync is coalesced onto the microtask + * queue, so a test that asserts on it has to let that run. */ +const flush = (): Promise => Promise.resolve(); + +function marker(): IMarker & { dispose: ReturnType } { + return { id: 1, line: 3, isDisposed: false, dispose: vi.fn(), onDispose: vi.fn() } as unknown as IMarker & { + dispose: ReturnType; + }; +} + +function source(terminalId = 'term-1'): RuntimeTerminalSource { + return { + terminalId, + startMarker: marker(), + endMarker: marker(), + startColumn: 0, + endColumn: 12, + shape: 'linewise', + expectedRawText: 'error: boom', + }; +} + +beforeEach(async () => { + clearAllNotepads(); + adapter = new FakePtyAdapter(); + setPlatform(adapter); + await flush(); +}); + +describe('notes', () => { + it('adds, edits, deletes, and keeps creation order', () => { + const first = addPlainNote('s1', 'one'); + const second = addPlainNote('s1'); + const third = addTerminalNote('s1', [{ text: 'boom', bold: true }]); + expect(getNotes('s1').map((n) => n.id)).toEqual([first, second, third]); + expect(noteCount('s1')).toBe(3); + + setNoteText('s1', second, 'two'); + expect(getNotes('s1')[1].content).toEqual({ kind: 'plain', text: 'two' }); + + deleteNote('s1', second); + expect(getNotes('s1').map((n) => n.id)).toEqual([first, third]); + expect(noteCount('s2')).toBe(0); + expect(getNotes('s2')).toEqual([]); + }); + + it('keeps Surfaces separate and forgets a Surface with no notes left', () => { + addPlainNote('s1', 'a'); + const other = addPlainNote('s2', 'b'); + expect(getNotepadSnapshot().size).toBe(2); + deleteNote('s2', other); + expect(getNotepadSnapshot().has('s2')).toBe(false); + expect(getNotes('s1')).toHaveLength(1); + }); + + it('notifies subscribers and hands out a stable snapshot between changes', () => { + const listener = vi.fn(); + const unsubscribe = subscribeToNotepad(listener); + const before = getNotepadSnapshot(); + expect(getNotepadSnapshot()).toBe(before); + + addPlainNote('s1', 'a'); + expect(listener).toHaveBeenCalledTimes(1); + const after = getNotepadSnapshot(); + expect(after).not.toBe(before); + expect(getNotepadSnapshot()).toBe(after); + + unsubscribe(); + addPlainNote('s1', 'b'); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); + +describe('rich to plain conversion', () => { + it('converts only when the text actually changes', () => { + const id = addTerminalNote('s1', [{ text: 'boom', foreground: '#ff0000' }]); + + // Reading, focusing, moving the caret: none of that reaches the store. + expect(getNotes('s1')[0].content).toEqual({ kind: 'terminal', runs: [{ text: 'boom', foreground: '#ff0000' }] }); + expect(noteCount('s1')).toBe(1); + + setNoteText('s1', id, 'boomm'); + expect(getNotes('s1')[0].content).toEqual({ kind: 'plain', text: 'boomm' }); + }); + + it('leaves a plain note alone when the text is unchanged', () => { + const id = addPlainNote('s1', 'same'); + const before = getNotes('s1')[0]; + setNoteText('s1', id, 'same'); + expect(getNotes('s1')[0]).toBe(before); + }); + + it('keeps the source link across the conversion', () => { + const src = source(); + const id = addTerminalNote('s1', [{ text: 'boom' }], src); + setNoteText('s1', id, 'edited'); + expect(getNotes('s1')[0].source).toBe(src); + expect(src.startMarker.dispose).not.toHaveBeenCalled(); + }); + + it('ignores an unknown Surface or note', () => { + setNoteText('nope', 'nope', 'x'); + const id = addPlainNote('s1', 'a'); + setNoteText('s1', 'other', 'x'); + expect(getNotes('s1')[0].content).toEqual({ kind: 'plain', text: 'a' }); + expect(id).toBeTruthy(); + }); +}); + +describe('pruneEmptyNote', () => { + it('removes an untouched empty plain note', () => { + const id = addPlainNote('s1'); + expect(pruneEmptyNote('s1', id)).toBe(true); + expect(noteCount('s1')).toBe(0); + }); + + it('keeps a note with text and a rich note that was never edited', () => { + const typed = addPlainNote('s1', 'x'); + const rich = addTerminalNote('s1', []); + expect(pruneEmptyNote('s1', typed)).toBe(false); + expect(pruneEmptyNote('s1', rich)).toBe(false); + expect(pruneEmptyNote('s1', 'gone')).toBe(false); + expect(noteCount('s1')).toBe(2); + }); +}); + +describe('source links', () => { + it('dropSource disposes both markers and leaves the note', () => { + const src = source(); + const id = addTerminalNote('s1', [{ text: 'boom' }], src); + dropSource('s1', id); + expect(src.startMarker.dispose).toHaveBeenCalledTimes(1); + expect(src.endMarker.dispose).toHaveBeenCalledTimes(1); + expect(getNotes('s1')).toHaveLength(1); + expect(getNotes('s1')[0].source).toBeUndefined(); + // Idempotent: a second drop finds nothing to dispose. + dropSource('s1', id); + expect(src.startMarker.dispose).toHaveBeenCalledTimes(1); + }); + + it('dropSourcesForTerminal clears every pin into that terminal, across Surfaces', () => { + const doomed = source('term-1'); + const alsoDoomed = source('term-1'); + const survivor = source('term-2'); + addTerminalNote('s1', [{ text: 'a' }], doomed); + addPlainNote('s1', 'typed'); + addTerminalNote('s2', [{ text: 'b' }], alsoDoomed); + addTerminalNote('s2', [{ text: 'c' }], survivor); + + const listener = vi.fn(); + subscribeToNotepad(listener); + dropSourcesForTerminal('term-1'); + + expect(listener).toHaveBeenCalledTimes(1); + expect(doomed.startMarker.dispose).toHaveBeenCalled(); + expect(alsoDoomed.endMarker.dispose).toHaveBeenCalled(); + expect(survivor.startMarker.dispose).not.toHaveBeenCalled(); + expect(getNotes('s1').map((n) => n.source)).toEqual([undefined, undefined]); + expect(getNotes('s2').map((n) => n.source)).toEqual([undefined, survivor]); + + // Nothing left to drop: no second notification. + dropSourcesForTerminal('term-1'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('deleting a note disposes the markers it owned', () => { + const src = source(); + const id = addTerminalNote('s1', [{ text: 'a' }], src); + deleteNote('s1', id); + expect(src.startMarker.dispose).toHaveBeenCalledTimes(1); + expect(src.endMarker.dispose).toHaveBeenCalledTimes(1); + }); +}); + +describe('surface lifecycle', () => { + it('transferNotepad moves the notes and drops pins into the replaced terminal', () => { + const oldPin = source('old'); + const otherPin = source('other'); + addTerminalNote('old', [{ text: 'a' }], oldPin); + addTerminalNote('old', [{ text: 'b' }], otherPin); + addPlainNote('old', 'typed'); + setOpenNotepadId('old'); + + transferNotepad('old', 'new'); + + expect(getNotes('old')).toEqual([]); + expect(getNotes('new').map((n) => n.content)).toEqual([ + { kind: 'terminal', runs: [{ text: 'a' }] }, + { kind: 'terminal', runs: [{ text: 'b' }] }, + { kind: 'plain', text: 'typed' }, + ]); + expect(oldPin.startMarker.dispose).toHaveBeenCalled(); + expect(getNotes('new')[0].source).toBeUndefined(); + // A pin into some other terminal is still live; only the replaced one goes. + expect(getNotes('new')[1].source).toBe(otherPin); + expect(otherPin.startMarker.dispose).not.toHaveBeenCalled(); + // The open panel follows the Surface to its new id. + expect(getOpenNotepadId()).toBe('new'); + }); + + it('transferNotepad is a no-op for an empty or self-referential move', () => { + const listener = vi.fn(); + subscribeToNotepad(listener); + transferNotepad('empty', 'new'); + addPlainNote('s1', 'a'); + transferNotepad('s1', 's1'); + expect(listener).toHaveBeenCalledTimes(1); + expect(getNotes('s1')).toHaveLength(1); + }); + + it('removeSurface forgets the notes, disposes markers, and closes its panel', () => { + const src = source(); + addTerminalNote('s1', [{ text: 'a' }], src); + setOpenNotepadId('s1'); + removeSurface('s1'); + expect(src.startMarker.dispose).toHaveBeenCalledTimes(1); + expect(src.endMarker.dispose).toHaveBeenCalledTimes(1); + expect(getNotes('s1')).toEqual([]); + expect(getNotepadSnapshot().has('s1')).toBe(false); + expect(getOpenNotepadId()).toBeNull(); + }); +}); + +describe('open panel', () => { + it('holds one id at a time and notifies its own subscribers', () => { + const listener = vi.fn(); + const unsubscribe = subscribeToOpenNotepad(listener); + expect(getOpenNotepadId()).toBeNull(); + + setOpenNotepadId('s1'); + expect(getOpenNotepadId()).toBe('s1'); + setOpenNotepadId('s1'); + expect(listener).toHaveBeenCalledTimes(1); + + setOpenNotepadId('s2'); + expect(getOpenNotepadId()).toBe('s2'); + setOpenNotepadId(null); + expect(listener).toHaveBeenCalledTimes(3); + + unsubscribe(); + setOpenNotepadId('s3'); + expect(listener).toHaveBeenCalledTimes(3); + }); + + it('does not wake note subscribers', () => { + const listener = vi.fn(); + subscribeToNotepad(listener); + setOpenNotepadId('s1'); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +const CWD: CwdState = { + path: '/srv/app', + pathKind: 'posix', + isRemote: false, + source: 'osc7', + updatedAt: 5, +}; + +describe('volatile mirror', () => { + it('mirrors every Surface holding notes, without markers, once per burst', async () => { + const sync = vi.spyOn(adapter.notepadArchive, 'syncVolatile'); + setNotepadSurfaceMetaResolver((surfaceId) => + surfaceId === 's1' ? { surfaceTitle: 'zsh', surfaceKind: 'terminal', cwd: CWD } : null, + ); + + const src = source(); + addTerminalNote('s1', [{ text: 'boom', bold: true }], src); + addPlainNote('s1', 'typed'); + addPlainNote('s2', 'elsewhere'); + await flush(); + + // A burst of edits is one snapshot, not one per keystroke. + expect(sync).toHaveBeenCalledTimes(1); + const snapshot = adapter.notepadArchive.lastVolatileSnapshot()!; + expect(snapshot.surfaces).toEqual([ + { + surfaceId: 's1', + surfaceTitle: 'zsh', + surfaceKind: 'terminal', + cwd: CWD, + notes: [ + { id: expect.any(String), createdAt: expect.any(Number), content: { kind: 'terminal', runs: [{ text: 'boom', bold: true }] } }, + { id: expect.any(String), createdAt: expect.any(Number), content: { kind: 'plain', text: 'typed' } }, + ], + }, + { + // No resolver answer yet: empty metadata rather than a missing Surface. + surfaceId: 's2', + surfaceTitle: '', + surfaceKind: 'terminal', + cwd: null, + notes: [{ id: expect.any(String), createdAt: expect.any(Number), content: { kind: 'plain', text: 'elsewhere' } }], + }, + ]); + expect(JSON.stringify(snapshot)).not.toContain('startMarker'); + }); + + it('carries staged archive deletions', async () => { + setStagedArchiveDeletions({ deleteBatchIds: ['b1'], deleteNotes: [{ batchId: 'b2', noteId: 'n7' }] }); + addPlainNote('s1', 'a'); + await flush(); + expect(adapter.notepadArchive.lastVolatileSnapshot()!.stagedDeletions).toEqual({ + deleteBatchIds: ['b1'], + deleteNotes: [{ batchId: 'b2', noteId: 'n7' }], + }); + }); + + it('drops a Surface from the mirror once its last note goes', async () => { + const id = addPlainNote('s1', 'a'); + await flush(); + expect(adapter.notepadArchive.lastVolatileSnapshot()!.surfaces).toHaveLength(1); + deleteNote('s1', id); + await flush(); + expect(adapter.notepadArchive.lastVolatileSnapshot()!.surfaces).toEqual([]); + }); + + it('keeps working on a host with no archive port at all', async () => { + const bare = new FakePtyAdapter(); + delete (bare as { notepadArchive?: unknown }).notepadArchive; + setPlatform(bare); + addPlainNote('s1', 'a'); + await expect(flush()).resolves.toBeUndefined(); + expect(buildVolatileSnapshot().surfaces).toHaveLength(1); + }); +}); + +describe('hydrateNotepadFromVolatile', () => { + it('restores only live Surfaces, and never over an existing notepad', () => { + addPlainNote('live-with-notes', 'already here'); + hydrateNotepadFromVolatile( + { + surfaces: [ + { + surfaceId: 'live', + surfaceTitle: 'zsh', + surfaceKind: 'terminal', + cwd: null, + notes: [{ id: 'n1', createdAt: 1, content: { kind: 'terminal', runs: [{ text: 'boom' }] } }], + }, + { + surfaceId: 'dead', + surfaceTitle: 'gone', + surfaceKind: 'terminal', + cwd: null, + notes: [{ id: 'n2', createdAt: 2, content: { kind: 'plain', text: 'lost' } }], + }, + { + surfaceId: 'live-with-notes', + surfaceTitle: 'zsh', + surfaceKind: 'terminal', + cwd: null, + notes: [{ id: 'n3', createdAt: 3, content: { kind: 'plain', text: 'stale' } }], + }, + ], + stagedDeletions: {}, + }, + ['live', 'live-with-notes'], + ); + + expect(getNotes('live').map((n) => n.id)).toEqual(['n1']); + // Restored notes carry no source: the markers died with the old webview. + expect(getNotes('live')[0].source).toBeUndefined(); + expect(getNotes('dead')).toEqual([]); + expect(getNotes('live-with-notes').map((n) => n.content)).toEqual([{ kind: 'plain', text: 'already here' }]); + }); + + it('does not notify when there is nothing to restore', () => { + const listener = vi.fn(); + subscribeToNotepad(listener); + hydrateNotepadFromVolatile({ surfaces: [], stagedDeletions: {} }, ['live']); + hydrateNotepadFromVolatile( + { + surfaces: [{ surfaceId: 'live', surfaceTitle: '', surfaceKind: 'terminal', cwd: null, notes: [] }], + stagedDeletions: {}, + }, + ['live'], + ); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/src/lib/notepad/notepad-store.ts b/lib/src/lib/notepad/notepad-store.ts new file mode 100644 index 000000000..466b54ff2 --- /dev/null +++ b/lib/src/lib/notepad/notepad-store.ts @@ -0,0 +1,366 @@ +// The renderer's live notes, keyed by Surface id, with a +// `useSyncExternalStore`-compatible subscription API. Notes live here and +// nowhere else: they are never written to a session snapshot, Lath persistence, +// `localStorage`, or webview state. The one mirror is `syncVolatile`, host +// memory that exists so a VS Code webview re-resolved over live PTYs can get its +// notes back (docs/specs/notepad.md). +import type { SurfaceKind } from 'dor/commands/types'; +import { getPlatform } from '../platform'; +import type { CwdState } from '../terminal-state'; +import { toArchivedNote } from './archive-model'; +import type { + LiveNote, + NotepadArchiveMutation, + NotepadArchivePort, + RichTextRun, + RuntimeTerminalSource, + VolatileNotepadSnapshot, + VolatileSurfaceNotes, +} from './types'; + +/** What the volatile mirror needs about a Surface that the notes themselves do + * not carry. The Wall owns this; see `setNotepadSurfaceMetaResolver`. */ +export interface NotepadSurfaceMeta { + surfaceTitle: string; + surfaceKind: SurfaceKind; + cwd: CwdState | null; +} + +export type NotepadSurfaceMetaResolver = (surfaceId: string) => NotepadSurfaceMeta | null; + +/** Shared identity for "this Surface has no notes", so `getNotes` stays stable + * for the (common) empty case instead of handing React a new array each render. */ +const NO_NOTES: readonly LiveNote[] = Object.freeze([]); + +const notesBySurface = new Map(); +const listeners = new Set<() => void>(); +let cachedSnapshot: Map | null = null; + +function notify(): void { + cachedSnapshot = null; + listeners.forEach((listener) => listener()); + scheduleVolatileSync(); +} + +export function subscribeToNotepad(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** Stable snapshot reference (changes only on mutation) for `useSyncExternalStore`. */ +export function getNotepadSnapshot(): Map { + if (cachedSnapshot) return cachedSnapshot; + cachedSnapshot = new Map(notesBySurface); + return cachedSnapshot; +} + +export function getNotes(surfaceId: string): readonly LiveNote[] { + return notesBySurface.get(surfaceId) ?? NO_NOTES; +} + +export function noteCount(surfaceId: string): number { + return notesBySurface.get(surfaceId)?.length ?? 0; +} + +let idCounter = 0; + +/** `crypto.randomUUID` everywhere it exists; the counter is for the odd + * environment (an insecure origin, an older runtime) where it does not, since + * a note with no id cannot be addressed for edit, delete, or archive. */ +function newNoteId(): string { + const webCrypto = globalThis.crypto as Crypto | undefined; + if (webCrypto && typeof webCrypto.randomUUID === 'function') return webCrypto.randomUUID(); + idCounter += 1; + return `note-${Date.now().toString(36)}-${idCounter.toString(36)}`; +} + +function replaceNotes(surfaceId: string, next: LiveNote[]): void { + if (next.length === 0) notesBySurface.delete(surfaceId); + else notesBySurface.set(surfaceId, next); + notify(); +} + +function appendNote(surfaceId: string, note: LiveNote): string { + replaceNotes(surfaceId, [...(notesBySurface.get(surfaceId) ?? []), note]); + return note.id; +} + +/** Add an empty (or pre-filled) plain note at the bottom. The panel focuses it; + * an untouched one is removed again by `pruneEmptyNote`. */ +export function addPlainNote(surfaceId: string, text = ''): string { + return appendNote(surfaceId, { + id: newNoteId(), + createdAt: Date.now(), + content: { kind: 'plain', text }, + }); +} + +/** Add a captured terminal selection. `source` is present only for + * normal-buffer captures — it is what a pin resolves against. */ +export function addTerminalNote( + surfaceId: string, + runs: RichTextRun[], + source?: RuntimeTerminalSource, +): string { + const note: LiveNote = { + id: newNoteId(), + createdAt: Date.now(), + content: { kind: 'terminal', runs }, + }; + if (source) note.source = source; + return appendNote(surfaceId, note); +} + +/** + * The one path that changes note text, and therefore the one place a rich note + * becomes plain. Conversion and the edit are a single transition: a caret moving + * through a rich note, or a read of its runs, changes nothing. + * + * The source link survives the conversion — it points at where the text came + * from, which an edit does not move. + */ +export function setNoteText(surfaceId: string, noteId: string, text: string): void { + const current = notesBySurface.get(surfaceId); + if (!current) return; + const index = current.findIndex((note) => note.id === noteId); + if (index === -1) return; + const note = current[index]; + if (note.content.kind === 'plain' && note.content.text === text) return; + const next = current.slice(); + next[index] = { ...note, content: { kind: 'plain', text } }; + replaceNotes(surfaceId, next); +} + +function disposeSource(note: LiveNote): void { + if (!note.source) return; + note.source.startMarker.dispose(); + note.source.endMarker.dispose(); +} + +export function deleteNote(surfaceId: string, noteId: string): void { + const current = notesBySurface.get(surfaceId); + if (!current) return; + const note = current.find((candidate) => candidate.id === noteId); + if (!note) return; + // The markers exist only to serve this note's pin; nothing else can reach + // them once it is gone. + disposeSource(note); + replaceNotes( + surfaceId, + current.filter((candidate) => candidate.id !== noteId), + ); +} + +/** + * Remove a note only if it is plain and empty — the blur/close rule for an + * Add New that was never typed into. A rich note or one with text is kept, so + * this is safe to call on every blur. + */ +export function pruneEmptyNote(surfaceId: string, noteId: string): boolean { + const note = notesBySurface.get(surfaceId)?.find((candidate) => candidate.id === noteId); + if (!note) return false; + if (note.content.kind !== 'plain' || note.content.text !== '') return false; + deleteNote(surfaceId, noteId); + return true; +} + +/** Drop one note's source link: the pin disappears, the note stays. Called when + * a pin fails to resolve (disposed markers, trimmed scrollback, text mismatch). */ +export function dropSource(surfaceId: string, noteId: string): void { + const current = notesBySurface.get(surfaceId); + if (!current) return; + const index = current.findIndex((note) => note.id === noteId); + if (index === -1) return; + const note = current[index]; + if (!note.source) return; + disposeSource(note); + const next = current.slice(); + const { source: _dropped, ...rest } = note; + next[index] = rest; + replaceNotes(surfaceId, next); +} + +/** + * Drop every pin pointing at a terminal that is being disposed, across all + * Surfaces. Markers belong to one xterm instance, so replacing or killing that + * instance invalidates them immediately; the notes themselves are untouched. + */ +export function dropSourcesForTerminal(terminalId: string): void { + let changed = false; + for (const [surfaceId, current] of notesBySurface) { + if (!current.some((note) => note.source?.terminalId === terminalId)) continue; + const next = current.map((note) => { + if (note.source?.terminalId !== terminalId) return note; + disposeSource(note); + const { source: _dropped, ...rest } = note; + return rest; + }); + notesBySurface.set(surfaceId, next); + changed = true; + } + if (changed) notify(); +} + +/** + * Follow an in-place replacement (renderer swap, browser/terminal mode change, + * untouched-shell replace) to the new Surface id it mints. The notes move as + * they are; pins into the *old* terminal go, because that instance is being + * disposed as part of the replacement. + */ +export function transferNotepad(oldId: string, newId: string): void { + if (oldId === newId) return; + const moving = notesBySurface.get(oldId); + if (!moving || moving.length === 0) return; + const carried = moving.map((note) => { + if (note.source?.terminalId !== oldId) return note; + disposeSource(note); + const { source: _dropped, ...rest } = note; + return rest; + }); + notesBySurface.delete(oldId); + notesBySurface.set(newId, [...(notesBySurface.get(newId) ?? []), ...carried]); + if (openNotepadId === oldId) setOpenNotepadId(newId); + notify(); +} + +/** Forget a Surface's notes (it closed; anything worth keeping was archived + * before teardown). */ +export function removeSurface(surfaceId: string): void { + const current = notesBySurface.get(surfaceId); + if (!current) { + if (openNotepadId === surfaceId) setOpenNotepadId(null); + return; + } + current.forEach(disposeSource); + notesBySurface.delete(surfaceId); + if (openNotepadId === surfaceId) setOpenNotepadId(null); + notify(); +} + +/** Tests and Storybook: module state outlives components. */ +export function clearAllNotepads(): void { + for (const notes of notesBySurface.values()) notes.forEach(disposeSource); + notesBySurface.clear(); + metaResolver = null; + stagedDeletions = {}; + setOpenNotepadId(null); + notify(); +} + +// --- Open panel --- +// +// Only one Surface notepad is open per Wall, so this is a single id rather than +// per-Surface open state. Its own listener set: a note edit must not re-render +// every header that only cares about which panel is open, and vice versa. + +let openNotepadId: string | null = null; +const openListeners = new Set<() => void>(); + +export function subscribeToOpenNotepad(listener: () => void): () => void { + openListeners.add(listener); + return () => { + openListeners.delete(listener); + }; +} + +export function getOpenNotepadId(): string | null { + return openNotepadId; +} + +export function setOpenNotepadId(surfaceId: string | null): void { + if (openNotepadId === surfaceId) return; + openNotepadId = surfaceId; + openListeners.forEach((listener) => listener()); +} + +// --- Volatile mirror --- + +let metaResolver: NotepadSurfaceMetaResolver | null = null; +let stagedDeletions: Pick = {}; + +/** The Wall installs this; until it does, the mirror carries empty metadata + * rather than nothing, so notes still survive a live resume. */ +export function setNotepadSurfaceMetaResolver(resolver: NotepadSurfaceMetaResolver | null): void { + metaResolver = resolver; + scheduleVolatileSync(); +} + +/** Archive deletions staged in an open Archive view, mirrored so a host that + * loses the webview can still commit them. */ +export function setStagedArchiveDeletions( + deletions: Pick, +): void { + stagedDeletions = deletions; + scheduleVolatileSync(); +} + +function archivePort(): NotepadArchivePort | undefined { + // `getPlatform()` throws before a platform is installed — normal in unit + // tests and during boot. No platform simply means no mirror. + try { + return getPlatform().notepadArchive; + } catch { + return undefined; + } +} + +/** Everything a close would archive for every Surface holding notes, minus the + * markers (`toArchivedNote` strips them). */ +export function buildVolatileSnapshot(): VolatileNotepadSnapshot { + const surfaces: VolatileSurfaceNotes[] = []; + for (const [surfaceId, notes] of notesBySurface) { + if (notes.length === 0) continue; + const meta = metaResolver?.(surfaceId) ?? null; + surfaces.push({ + surfaceId, + surfaceTitle: meta?.surfaceTitle ?? '', + surfaceKind: meta?.surfaceKind ?? 'terminal', + cwd: meta?.cwd ?? null, + notes: notes.map(toArchivedNote), + }); + } + return { surfaces, stagedDeletions }; +} + +let syncScheduled = false; + +/** One snapshot per burst: typing a line of text is one keystroke per change, + * and the mirror only has to be right by the time control returns to the host. */ +function scheduleVolatileSync(): void { + if (syncScheduled) return; + syncScheduled = true; + queueMicrotask(() => { + syncScheduled = false; + const port = archivePort(); + if (!port?.syncVolatile) return; + port.syncVolatile(buildVolatileSnapshot()); + }); +} + +/** + * Restore mirrored notes on a live resume — a webview re-resolved over PTYs the + * host still owns. Only ids in `liveSurfaceIds` are restored, and only into + * Surfaces that have no notes yet, so this can never overwrite a live notepad + * or resurrect notes for a Surface that is gone. Sources are not restored: the + * markers died with the previous webview's xterm instances. + */ +export function hydrateNotepadFromVolatile( + snapshot: VolatileNotepadSnapshot, + liveSurfaceIds: Iterable, +): void { + const live = new Set(liveSurfaceIds); + let changed = false; + for (const surface of snapshot.surfaces) { + if (!live.has(surface.surfaceId)) continue; + if (surface.notes.length === 0) continue; + if ((notesBySurface.get(surface.surfaceId)?.length ?? 0) > 0) continue; + notesBySurface.set( + surface.surfaceId, + surface.notes.map((note) => ({ id: note.id, createdAt: note.createdAt, content: note.content })), + ); + changed = true; + } + if (changed) notify(); +} diff --git a/lib/src/lib/notepad/rich-clipboard.test.ts b/lib/src/lib/notepad/rich-clipboard.test.ts new file mode 100644 index 000000000..920fe4bd2 --- /dev/null +++ b/lib/src/lib/notepad/rich-clipboard.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { NoteContent } from './types'; +import { copyNoteToClipboard, noteToHtml, noteToPlainText } from './rich-clipboard'; + +class FakeClipboardItem { + constructor(readonly data: Record) {} +} + +const write = vi.fn<(items: unknown[]) => Promise>(); +const writeText = vi.fn<(text: string) => Promise>(); + +/** The one ClipboardItem the last `write` call carried. */ +function writtenItem(): FakeClipboardItem { + expect(write).toHaveBeenCalledTimes(1); + const [items] = write.mock.calls[0]; + expect(items).toHaveLength(1); + return items[0] as FakeClipboardItem; +} + +const RICH: NoteContent = { + kind: 'terminal', + runs: [ + { text: 'error: ', bold: true, foreground: '#ff0000' }, + { text: '' }, + ], +}; + +beforeEach(() => { + write.mockReset().mockResolvedValue(undefined); + writeText.mockReset().mockResolvedValue(undefined); + vi.stubGlobal('ClipboardItem', FakeClipboardItem); + vi.stubGlobal('navigator', { clipboard: { write, writeText } }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('noteToPlainText', () => { + it('returns a plain note verbatim', () => { + expect(noteToPlainText({ kind: 'plain', text: 'a\nb' })).toBe('a\nb'); + }); + + it('concatenates the runs of a terminal note', () => { + expect(noteToPlainText(RICH)).toBe('error: '); + }); +}); + +describe('noteToHtml', () => { + it('wraps everything in a whitespace-preserving container', () => { + expect(noteToHtml({ kind: 'plain', text: 'x y\nz' })) + .toBe('
x  y\nz
'); + }); + + it('escapes the four dangerous characters in every chunk', () => { + const content: NoteContent = { + kind: 'terminal', + runs: [{ text: '\n `, + ` \n `, ); return { html, messageToken }; diff --git a/vscode-ext/src/webview-messaging.ts b/vscode-ext/src/webview-messaging.ts index 1b48a575b..640eb79e7 100644 --- a/vscode-ext/src/webview-messaging.ts +++ b/vscode-ext/src/webview-messaging.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { HOST_MESSAGE_TOKEN_FIELD } from '../../lib/src/lib/vscode-message-token'; import { getWebviewHtml } from './webview-html'; import type { ExtensionMessage } from './message-types'; +import type { VolatileNotepadSnapshot } from '../../lib/src/lib/notepad/types'; /** * The host's handle on a served webview. `serveWebview` returns one of these @@ -30,8 +31,11 @@ export function serveWebview( initialState?: unknown, selectedShell?: { shell?: string; args?: string[] } | null, recoveryCommands?: Record | null, + notepadVolatile?: VolatileNotepadSnapshot | null, ): WebviewChannel { - const { html, messageToken } = getWebviewHtml(webview, mediaPath, initialState, selectedShell, recoveryCommands); + const { html, messageToken } = getWebviewHtml( + webview, mediaPath, initialState, selectedShell, recoveryCommands, notepadVolatile, + ); webview.html = html; return { diff --git a/vscode-ext/src/webview-view-provider.ts b/vscode-ext/src/webview-view-provider.ts index 98120f019..96845c7a3 100644 --- a/vscode-ext/src/webview-view-provider.ts +++ b/vscode-ext/src/webview-view-provider.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { attachRouter, getAlertStates } from './message-router'; import { serveWebview, type WebviewChannel } from './webview-messaging'; import { takeRecoveryCommands, getSavedSessionState, saveSessionState, mergeAlertStates } from './session-state'; +import { snapshotForLiveResume } from './notepad-volatile'; import type { ExtensionMessage } from './message-types'; import * as ptyManager from './pty-manager'; import { resolveSelectedShell } from './shell-selection'; @@ -81,12 +82,23 @@ export class DormouseViewProvider implements vscode.WebviewViewProvider { this.context, (savedSession?.panes ?? []).map((pane) => pane.id), ); - this.channel = serveWebview(view.webview, mediaPath, savedSession, this.selectedShell, recoveryCommands); + // The one path that hydrates the notepad mirror: this view's `onDidDispose` + // leaves its PTYs alive, so a re-resolve (a move between panel containers) is + // a live resume and the notes for those panes are still in extension-host + // memory. Same pane ids as the recovery claim above; a cold restore finds + // nothing mirrored and gets `null` (docs/specs/notepad.md). + const notepadVolatile = snapshotForLiveResume( + (savedSession?.panes ?? []).map((pane) => pane.id), + ); + this.channel = serveWebview( + view.webview, mediaPath, savedSession, this.selectedShell, recoveryCommands, notepadVolatile, + ); this.routerDisposable?.dispose(); this.routerDisposable = attachRouter(this.channel, { reconnect: true, savedSession, + context: this.context, onSaveState: (state) => { void saveSessionState(this.context, mergeAlertStates(state, getAlertStates())); }, diff --git a/vscode-ext/test/message-router.test.ts b/vscode-ext/test/message-router.test.ts index 80b3e3f5a..9f2ee7287 100644 --- a/vscode-ext/test/message-router.test.ts +++ b/vscode-ext/test/message-router.test.ts @@ -40,6 +40,7 @@ vi.mock('../src/remote-host', () => ({ })); type RouterModule = typeof import('../src/message-router'); +type MirrorModule = typeof import('../src/notepad-volatile'); /** One webview: what it was sent, and a way to make it say something back. */ function fakeWebview() { @@ -69,12 +70,16 @@ function fakeWebview() { } let router: RouterModule; +let mirror: MirrorModule; beforeEach(async () => { vi.resetModules(); wiring.peer = null; wiring.invalidations = 0; router = (await import('../src/message-router')) as RouterModule; + // The same instance the router holds — `resetModules` gave this test its own + // extension host, and both imports land in that one registry. + mirror = (await import('../src/notepad-volatile')) as MirrorModule; }); afterEach(() => { @@ -126,6 +131,117 @@ describe('webview fan-out', () => { }); }); +/** + * The notepad archive lives in `globalState`, which only the extension host can + * reach (docs/specs/notepad.md). What this side owns is the request/response + * plumbing and the disposal rule: an editor panel closing archives its mirrored + * notes, the bottom-panel view's disposal does not — its PTYs stay alive. + */ +describe('notepad archive requests', () => { + function fakeContext() { + const store = new Map(); + const context = { + globalState: { + get: (key: string) => store.get(key), + update: async (key: string, value: unknown) => { + if (value === undefined) store.delete(key); + else store.set(key, value); + }, + }, + }; + return { context: context as never, store }; + } + + /** Every archive reply this webview was sent, in order. */ + function results(webview: ReturnType) { + return webview.posted + .filter((message) => message.type === 'notepad:result') + .map((message) => message as { requestId: string; ok: boolean; result?: unknown; error?: string }); + } + + const mirrored = { + surfaceId: 'pane-1', + surfaceTitle: 'zsh', + surfaceKind: 'terminal', + cwd: null, + notes: [{ id: 'n1', createdAt: 1, content: { kind: 'plain', text: 'remember this' } }], + }; + + it('round-trips a save and a load through globalState', async () => { + const webview = fakeWebview(); + const { context } = fakeContext(); + const disposable = router.attachRouter(webview.channel, { context }); + try { + webview.send({ type: 'notepad:load', requestId: 'np-1' } as never); + await vi.waitFor(() => expect(results(webview)).toHaveLength(1)); + // Nothing archived yet, and `null` is the base revision that says so. + expect(results(webview)[0]).toEqual({ type: 'notepad:result', requestId: 'np-1', ok: true, result: null }); + + const state = JSON.stringify({ version: 1, batches: [] }); + webview.send({ type: 'notepad:save', requestId: 'np-2', state, baseRevision: null } as never); + await vi.waitFor(() => expect(results(webview)).toHaveLength(2)); + expect(results(webview)[1]).toMatchObject({ requestId: 'np-2', ok: true, result: 'ok' }); + + webview.send({ type: 'notepad:load', requestId: 'np-3' } as never); + await vi.waitFor(() => expect(results(webview)).toHaveLength(3)); + expect(results(webview)[2].result).toMatchObject({ raw: state }); + } finally { + disposable.dispose(); + } + }); + + it('answers a failed archive write rather than leaving the webview waiting', async () => { + // The port has no deadline of its own, and an archive that cannot be written + // has to become the closure error path, never a Surface that never closes. + const webview = fakeWebview(); + const context = { + globalState: { + get: () => { throw new Error('globalState is gone'); }, + update: async () => {}, + }, + } as never; + const disposable = router.attachRouter(webview.channel, { context }); + try { + webview.send({ type: 'notepad:load', requestId: 'np-1' } as never); + await vi.waitFor(() => expect(results(webview)).toHaveLength(1)); + expect(results(webview)[0]).toMatchObject({ ok: false, error: 'globalState is gone' }); + } finally { + disposable.dispose(); + } + }); + + it('archives an editor panel\'s mirrored notes when its router is killed on dispose', async () => { + const webview = fakeWebview(); + const { context, store } = fakeContext(); + const disposable = router.attachRouter(webview.channel, { context, killOnDispose: true }); + + webview.send({ type: 'notepad:volatile', snapshot: { surfaces: [mirrored], stagedDeletions: {} } } as never); + // Closing the tab is a deliberate ending, and the webview is already gone — + // so nothing but the host can archive what it was holding. + disposable.dispose(); + + await vi.waitFor(() => expect(store.get('dormouse.notepadArchive.v1')).toBeDefined()); + const archive = JSON.parse(store.get('dormouse.notepadArchive.v1') as string); + expect(archive.batches).toHaveLength(1); + expect(archive.batches[0]).toMatchObject({ surfaceTitle: 'zsh', notes: [{ id: 'n1' }] }); + }); + + it('keeps the mirror when the bottom-panel view is disposed, so the next resolve hydrates it', async () => { + const webview = fakeWebview(); + const { context, store } = fakeContext(); + // No `killOnDispose`: the `WebviewView`'s disposal leaves its PTYs alive, so + // it is not a closure and the notes are not archived. + const disposable = router.attachRouter(webview.channel, { context }); + + webview.send({ type: 'notepad:volatile', snapshot: { surfaces: [mirrored], stagedDeletions: {} } } as never); + disposable.dispose(); + await Promise.resolve(); + + expect(store.get('dormouse.notepadArchive.v1')).toBeUndefined(); + expect(mirror.snapshotForLiveResume(['pane-1'])?.surfaces).toEqual([mirrored]); + }); +}); + /** * `dor await` parks in the shared alert manager, which lives here rather than in * the webview (docs/specs/alert.md → Await). What this side owns is the diff --git a/vscode-ext/test/notepad-archive-store.test.ts b/vscode-ext/test/notepad-archive-store.test.ts new file mode 100644 index 000000000..a1729ce8c --- /dev/null +++ b/vscode-ext/test/notepad-archive-store.test.ts @@ -0,0 +1,212 @@ +/** + * The archive as the extension host stores it (`docs/specs/notepad.md`). + * + * What is worth pinning here is not the JSON — that is `archive-model`'s — but + * the two things only this side can get wrong: the compare-and-swap that keeps + * two webviews from clobbering each other, and the queue that keeps a read from + * landing between someone else's read and write. + */ + +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import type { ArchiveBatch, NotepadArchiveV1 } from '../../lib/src/lib/notepad/types'; +import { + appendNotepadBatches, + archiveVolatileMirror, + commitStagedDeletions, + loadNotepadArchive, + NOTEPAD_ARCHIVE_KEY, + resetUnreadableNotepadArchive, + saveNotepadArchive, +} from '../src/notepad-archive-store'; + +/** + * A `globalState` whose writes land a microtask late, like the real one's do. + * That delay is the whole reason the store has a queue: a second reader that + * gets in before an `update` settles would build its mutation on the value from + * before it. + */ +function fakeContext() { + const store = new Map(); + const globalState = { + get: (key: string) => store.get(key), + update: async (key: string, value: unknown) => { + await Promise.resolve(); + if (value === undefined) store.delete(key); + else store.set(key, value); + }, + keys: () => [...store.keys()], + }; + return { context: { globalState } as never, store }; +} + +function batch(id: string, noteIds: string[]): ArchiveBatch { + return { + id, + closedAt: 1_700_000_000_000, + surfaceTitle: `surface ${id}`, + surfaceKind: 'terminal', + cwd: null, + notes: noteIds.map((noteId) => ({ + id: noteId, + createdAt: 1_700_000_000_001, + content: { kind: 'plain', text: `note ${noteId}` }, + })), + }; +} + +function stored(store: Map): NotepadArchiveV1 { + return JSON.parse(store.get(NOTEPAD_ARCHIVE_KEY) as string) as NotepadArchiveV1; +} + +const archiveOf = (...batches: ArchiveBatch[]): NotepadArchiveV1 => ({ version: 1, batches }); + +describe('the notepad archive in globalState', () => { + it('reports nothing archived, then round-trips what a webview saved', async () => { + const { context, store } = fakeContext(); + expect(await loadNotepadArchive(context)).toBeNull(); + + // `null` is the base revision for "nothing stored" — the only one a first + // save may pass. + expect(await saveNotepadArchive(context, JSON.stringify(archiveOf(batch('b1', ['n1']))), null)).toBe('ok'); + + const loaded = await loadNotepadArchive(context); + expect(JSON.parse(loaded!.raw)).toEqual(archiveOf(batch('b1', ['n1']))); + expect(stored(store).batches).toHaveLength(1); + }); + + it('refuses a save built on a revision someone else has moved', async () => { + const { context, store } = fakeContext(); + // Two webviews load the same archive; the first one to save wins, and the + // second is told to re-read rather than allowed to drop the first's batch. + await saveNotepadArchive(context, JSON.stringify(archiveOf(batch('b1', ['n1']))), null); + const first = await loadNotepadArchive(context); + const second = await loadNotepadArchive(context); + expect(second!.revision).toBe(first!.revision); + + expect(await saveNotepadArchive(context, JSON.stringify(archiveOf(batch('b2', ['n2']))), first!.revision)) + .toBe('ok'); + expect(await saveNotepadArchive(context, JSON.stringify(archiveOf(batch('b3', ['n3']))), second!.revision)) + .toBe('conflict'); + + expect(stored(store).batches.map((b) => b.id)).toEqual(['b2']); + // The conflicted webview re-reads and now has a revision that works. + const retry = await loadNotepadArchive(context); + expect(await saveNotepadArchive(context, JSON.stringify(archiveOf(batch('b3', ['n3']))), retry!.revision)) + .toBe('ok'); + }); + + it('moves an unreadable archive aside instead of deleting it', async () => { + const { context, store } = fakeContext(); + await context.globalState.update(NOTEPAD_ARCHIVE_KEY, '{ this is not json'); + + await resetUnreadableNotepadArchive(context); + + // The main key is empty, so appends work again... + expect(await loadNotepadArchive(context)).toBeNull(); + // ...and the user's data is still on disk under a sibling key, because only + // a human can tell whether it is recoverable. + const rescued = [...store.keys()].filter((key) => key.startsWith(`${NOTEPAD_ARCHIVE_KEY}.unreadable-`)); + expect(rescued).toHaveLength(1); + expect(store.get(rescued[0])).toBe('{ this is not json'); + }); + + it('appends idempotently by batch id', async () => { + const { context, store } = fakeContext(); + // A teardown that retries — or two paths that both archive the same closure + // — must not double the batch. The id is minted once, which is what makes + // the repeat a no-op. + await appendNotepadBatches(context, [batch('b1', ['n1'])]); + await appendNotepadBatches(context, [batch('b1', ['n1']), batch('b2', ['n2'])]); + + expect(stored(store).batches.map((b) => b.id)).toEqual(['b1', 'b2']); + }); + + it('never drops an append that raced another one', async () => { + const { context, store } = fakeContext(); + // Both start before either has written. Without the queue the second would + // read the pre-write archive and save a copy of it with only its own batch. + await Promise.all([ + appendNotepadBatches(context, [batch('b1', ['n1'])]), + appendNotepadBatches(context, [batch('b2', ['n2'])]), + ]); + + expect(stored(store).batches.map((b) => b.id).sort()).toEqual(['b1', 'b2']); + }); + + it('refuses to append onto an unreadable archive', async () => { + const { context, store } = fakeContext(); + await context.globalState.update(NOTEPAD_ARCHIVE_KEY, JSON.stringify({ version: 99 })); + + await expect(appendNotepadBatches(context, [batch('b1', ['n1'])])).rejects.toThrow(/unreadable/); + // Whatever is in there is still in there: replacing it is the user's call. + expect(store.get(NOTEPAD_ARCHIVE_KEY)).toBe(JSON.stringify({ version: 99 })); + }); + + it('commits staged deletions and drops a batch they empty', async () => { + const { context, store } = fakeContext(); + await appendNotepadBatches(context, [batch('b1', ['n1', 'n2']), batch('b2', ['n3'])]); + + await commitStagedDeletions(context, { + deleteBatchIds: [], + deleteNotes: [{ batchId: 'b1', noteId: 'n1' }, { batchId: 'b2', noteId: 'n3' }], + }); + + const batches = stored(store).batches; + expect(batches.map((b) => b.id)).toEqual(['b1']); + expect(batches[0].notes.map((n) => n.id)).toEqual(['n2']); + }); + + it('archives a drained mirror as one batch per Surface that had notes', async () => { + const { context, store } = fakeContext(); + await appendNotepadBatches(context, [batch('old', ['n0'])]); + + await archiveVolatileMirror(context, { + surfaces: [ + { + surfaceId: 'pane-1', + surfaceTitle: 'zsh', + surfaceKind: 'terminal', + cwd: { + path: '/repo', pathKind: 'posix', isRemote: false, source: 'osc7', updatedAt: 5, + }, + notes: [{ id: 'n1', createdAt: 3, content: { kind: 'plain', text: 'hi' } }], + }, + // No notes, so nothing to archive — not an empty batch. + { surfaceId: 'pane-2', surfaceTitle: 'bash', surfaceKind: 'terminal', cwd: null, notes: [] }, + ], + stagedDeletions: { deleteBatchIds: ['old'], deleteNotes: [] }, + }); + + const batches = stored(store).batches; + expect(batches).toHaveLength(1); + expect(batches[0]).toMatchObject({ + surfaceTitle: 'zsh', + surfaceKind: 'terminal', + cwd: { path: '/repo' }, + notes: [{ id: 'n1' }], + }); + // The mirror's staged deletions are committed too, so an Archive view whose + // webview was destroyed still gets its deletions. + expect(batches.map((b) => b.id)).not.toContain('old'); + }); +}); + +/** + * The one rule about this key that no unit test can reach through the API: the + * archive is machine-local, and Settings Sync would carry captured terminal + * excerpts to every machine the user signs into (`docs/specs/notepad.md`). VS + * Code syncs only what an extension registers, so the check is that this + * extension registers nothing. + */ +it('never opts any key into Settings Sync', () => { + const dir = fileURLToPath(new URL('../src/', import.meta.url)); + const offenders = readdirSync(dir) + .filter((name) => name.endsWith('.ts') || name.endsWith('.js')) + // A call, not a mention — the rule is stated in a comment on the key itself. + .filter((name) => /setKeysForSync\s*\(/.test(readFileSync(join(dir, name), 'utf8'))); + expect(offenders).toEqual([]); +}); diff --git a/vscode-ext/test/notepad-volatile.test.ts b/vscode-ext/test/notepad-volatile.test.ts new file mode 100644 index 000000000..c9cb56622 --- /dev/null +++ b/vscode-ext/test/notepad-volatile.test.ts @@ -0,0 +1,156 @@ +/** + * The extension host's in-memory notepad mirror (`docs/specs/notepad.md` → + * Archive and Lifecycle). + * + * It exists because VS Code can destroy a webview without asking, so a teardown + * has to archive from here instead of from the Surface. What that makes + * load-bearing: what it refuses to mirror (anything the archive validator would + * later choke on), whose notes a router may retire, and the fact that a live + * resume reads it without consuming it. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { VolatileSurfaceNotes } from '../../lib/src/lib/notepad/types'; + +type MirrorModule = typeof import('../src/notepad-volatile'); + +let mirror: MirrorModule; + +beforeEach(async () => { + // Module state is the mirror, exactly as it is in a real extension host, so + // each test gets its own host. + vi.resetModules(); + mirror = await import('../src/notepad-volatile'); +}); + +function surface(surfaceId: string, text = 'note'): VolatileSurfaceNotes { + return { + surfaceId, + surfaceTitle: `title ${surfaceId}`, + surfaceKind: 'terminal', + cwd: null, + notes: [{ id: `${surfaceId}-n1`, createdAt: 1, content: { kind: 'plain', text } }], + }; +} + +const noDeletions = { deleteBatchIds: [], deleteNotes: [] }; + +describe('the volatile notepad mirror', () => { + it('refuses a Surface the archive validator would later reject', () => { + // One malformed note written verbatim into globalState would make the whole + // archive unreadable on the next load — and by then there is no webview left + // to blame. So the mirror is the gate. + mirror.setVolatileForRouter('router-1', { + surfaces: [ + surface('good'), + { ...surface('bad-kind'), surfaceKind: 'spreadsheet' }, + { ...surface('bad-note'), notes: [{ id: 'n', createdAt: 'yesterday', content: { kind: 'plain', text: '' } }] }, + { ...surface('bad-colour'), notes: [{ + id: 'n', createdAt: 1, content: { kind: 'terminal', runs: [{ text: 'x', foreground: 'red' }] }, + }] }, + { surfaceId: '', surfaceTitle: 't', surfaceKind: 'terminal', cwd: null, notes: [] }, + ], + stagedDeletions: noDeletions, + }); + + expect(mirror.surfaceIdsForRouter('router-1')).toEqual(['good']); + }); + + it('keeps a rich note whole, and drops the fields the archive does not carry', () => { + mirror.setVolatileForRouter('router-1', { + surfaces: [{ + ...surface('pane-1'), + // `source` is the runtime marker link, which is never archived. + notes: [{ + id: 'n1', createdAt: 1, source: { terminalId: 't' }, + content: { kind: 'terminal', runs: [{ text: 'ok', bold: true, foreground: '#00ff00' }] }, + }], + }], + stagedDeletions: noDeletions, + }); + + const [mirrored] = mirror.takeVolatileForSurfaces(['pane-1']); + expect(mirrored.notes).toEqual([{ + id: 'n1', + createdAt: 1, + content: { kind: 'terminal', runs: [{ text: 'ok', bold: true, foreground: '#00ff00' }] }, + }]); + }); + + it('lets a router retire only what it stopped reporting', () => { + // Two webviews mirror into the same map. A snapshot that no longer mentions + // a Surface means that Surface closed through the ordinary path — but only + // for the router that sent it. + mirror.setVolatileForRouter('router-1', { + surfaces: [surface('a'), surface('b')], + stagedDeletions: noDeletions, + }); + mirror.setVolatileForRouter('router-2', { surfaces: [surface('c')], stagedDeletions: noDeletions }); + + mirror.setVolatileForRouter('router-1', { surfaces: [surface('b')], stagedDeletions: noDeletions }); + + expect(mirror.surfaceIdsForRouter('router-1')).toEqual(['b']); + expect(mirror.surfaceIdsForRouter('router-2')).toEqual(['c']); + }); + + it('hands a live resume its own panes without consuming them', () => { + mirror.setVolatileForRouter('router-1', { + surfaces: [surface('pane-1'), surface('pane-2')], + stagedDeletions: { deleteBatchIds: ['batch-1'], deleteNotes: [] }, + }); + + const resumed = mirror.snapshotForLiveResume(['pane-1', 'pane-missing']); + expect(resumed!.surfaces.map((s) => s.surfaceId)).toEqual(['pane-1']); + // Deletions are archive-wide, so a resume inherits what was staged behind it. + expect(resumed!.stagedDeletions.deleteBatchIds).toEqual(['batch-1']); + + // Still mirrored: a webview served this and then lost (a crash before its + // first sync) must still have its notes archived at deactivate. + expect(mirror.surfaceIdsForRouter('router-1')).toEqual(['pane-1', 'pane-2']); + }); + + it('gives a cold restore nothing', () => { + mirror.setVolatileForRouter('router-1', { surfaces: [surface('pane-1')], stagedDeletions: noDeletions }); + // A cold restore's pane ids are from a previous extension host; nothing in + // this one's memory answers to them. + expect(mirror.snapshotForLiveResume(['pane-from-last-week'])).toBeNull(); + }); + + it('drains one router without touching another', () => { + mirror.setVolatileForRouter('router-1', { + surfaces: [surface('a')], + stagedDeletions: { deleteBatchIds: ['batch-1'], deleteNotes: [] }, + }); + mirror.setVolatileForRouter('router-2', { + surfaces: [surface('b')], + stagedDeletions: { deleteBatchIds: ['batch-2'], deleteNotes: [] }, + }); + + const drained = mirror.takeVolatileForRouter('router-1'); + expect(drained.surfaces.map((s) => s.surfaceId)).toEqual(['a']); + expect(drained.stagedDeletions.deleteBatchIds).toEqual(['batch-1']); + + // Drained means gone: `deactivate()` must not archive these a second time + // under a fresh batch id. + expect(mirror.takeVolatileForRouter('router-1').surfaces).toEqual([]); + expect(mirror.surfaceIdsForRouter('router-2')).toEqual(['b']); + }); + + it('drains every router at once, merging their staged deletions', () => { + mirror.setVolatileForRouter('router-1', { + surfaces: [surface('a')], + stagedDeletions: { deleteBatchIds: ['batch-1'], deleteNotes: [{ batchId: 'batch-9', noteId: 'n1' }] }, + }); + mirror.setVolatileForRouter('router-2', { + surfaces: [surface('b')], + stagedDeletions: { deleteBatchIds: ['batch-1', 'batch-2'], deleteNotes: [] }, + }); + + const all = mirror.takeAllVolatile(); + expect(all.surfaces.map((s) => s.surfaceId).sort()).toEqual(['a', 'b']); + expect(all.stagedDeletions.deleteBatchIds.sort()).toEqual(['batch-1', 'batch-2']); + expect(all.stagedDeletions.deleteNotes).toEqual([{ batchId: 'batch-9', noteId: 'n1' }]); + expect(mirror.takeAllVolatile().surfaces).toEqual([]); + }); +}); diff --git a/vscode-ext/test/webview-html.test.ts b/vscode-ext/test/webview-html.test.ts index 1edec9ffd..b5751d881 100644 --- a/vscode-ext/test/webview-html.test.ts +++ b/vscode-ext/test/webview-html.test.ts @@ -2,6 +2,7 @@ import { writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { NOTEPAD_VOLATILE_GLOBAL } from '../../lib/src/lib/vscode-notepad-global'; import { CSP_NONCE_PLACEHOLDER } from '../src/csp-nonce-placeholder'; import { getWebviewHtml } from '../src/webview-html'; import { removeDir, tempStorageDir } from './helpers'; @@ -147,6 +148,32 @@ describe('getWebviewHtml', () => { expect(html).toContain(`${CSP_SOURCE}${mediaPath}/assets/rolldown-runtime-BBBBBBBB.js`); }); + it('boots with no notepad mirror unless one is handed to it', () => { + // Only a live resume gets one; every other document — a cold restore, an + // editor panel — must find `null` there (docs/specs/notepad.md). + const { html } = getWebviewHtml(webview, mediaPath); + expect(html).toContain(`globalThis.${NOTEPAD_VOLATILE_GLOBAL} = null;`); + }); + + it('cannot be broken out of by a captured note', () => { + // Notes carry arbitrary terminal output, and this payload is an inline + // script. `` inside a note would otherwise end the tag early, + // leaving the rest of the archive as markup in the document. + const note = { id: 'n1', createdAt: 1, content: { kind: 'plain' as const, text: '' } }; + const { html } = getWebviewHtml(webview, mediaPath, undefined, null, null, { + surfaces: [{ + surfaceId: 'pane-1', surfaceTitle: 'zsh', surfaceKind: 'terminal', cwd: null, notes: [note], + }], + stagedDeletions: { deleteBatchIds: [], deleteNotes: [] }, + }); + + const inline = /