Skip to content

feat(annotate): extend per-file version diff to folder sessions#1105

Open
BenNewman100 wants to merge 15 commits into
backnotprop:mainfrom
BenNewman100:feat/annotate-folder-diff
Open

feat(annotate): extend per-file version diff to folder sessions#1105
BenNewman100 wants to merge 15 commits into
backnotprop:mainfrom
BenNewman100:feat/annotate-folder-diff

Conversation

@BenNewman100

Copy link
Copy Markdown

Closes #1104

What

Extends the annotate version diff (#960#961) to folder sessions. Opening a file inside plannotator annotate <dir> now shows the same highlighted "what changed since the last version" view single-file sessions get — inline diff blocks, the +N/−M badge, and a per-file Version Browser. Files snapshot lazily on first open in the session, and history is continuous across modes: a file has one version thread whether opened solo or via its folder.

How

  • packages/shared/annotate-history.ts (new). The single-file history block (eligibility → path-keyed slug → saveToHistorypreviousPlan/versionInfo) extracted into a runtime-agnostic helper; both runtimes now call it (Pi vendors it via vendor.sh like storage.ts). Single-file behavior is unchanged — same slug scheme, so existing stored history keeps working.
  • Server (packages/server/annotate.ts + reference-handlers.ts). Eligible folder files served via /api/doc run the history pipeline once per resolved path per server launch (memoized; saveToHistory's dedupe means unchanged files never mint versions) and their responses gain previousPlan/versionInfo. /api/plan/version + /api/plan/versions accept an optional ?path=&base=, validated with the same allowed-roots containment /api/doc uses; the slug is always derived server-side — a client can never supply one.
  • Editor (packages/ui + packages/editor). usePlanDiff gained an optional docKey seam: diff-base state resets per document (and remembers each document's manually-selected base version, including the root's). useLinkedDoc carries each doc's baseline through its existing per-file cache, so back()/reopen keeps the baseline without refetching. The badge/Version Browser wiring now selects the active document's diff data — previously it was suppressed for linked docs. Root-document, plan, review, and HTML-surface behavior is unchanged.
  • Pi runtime (apps/pi-extension/server/serverAnnotate.ts + reference.ts). Mirrored end to end per AGENTS.md: same snapshot timing, payload fields, eligibility, endpoint params, and degradation.
  • Eligibility mirrors single-file gates: local .md/.txt markdown-branch docs under the session root, the existing 2MB annotatable cap, the annotateHistory config toggle, and storage failures degrade to a plain render (never an error).

Testing

  • packages/server/annotate.test.ts — 8 new folder-history tests: first-open mint, memoized reopen, content dedupe, cross-mode slug continuity, no-history first launch, toggle off, ineligible types, unwritable data dir, plus path-param endpoint coverage (out-of-root → 403).
  • apps/pi-extension/server/annotate-history.test.ts (new) — 6 tests mirroring the same behaviors on the Node runtime.
  • packages/ui/hooks/usePlanDiff.test.tsx, useLinkedDoc.test.tsx, useLinkedDoc.diffBaseline.test.ts (new) — per-document reset/restore, baseline-survives-live-reload, cache reuse on reopen, no cross-document leaks; the DOM-gated files are added to the CI DOM_TESTS=1 batch. (First direct usePlanDiff test coverage.)
  • tsc --noEmit clean across all packages; existing annotate suites stay green.
  • Manually verified end to end on a real multi-doc folder: first open renders plain, second launch after edits shows inline highlights + badge, Version Browser scoped per file, files without history stay plain.

Notes

  • v1 scope is .md/.txt folder docs; HTML folder docs could follow the same seam with html-diff, as a follow-up.
  • No history-based badges in the file tree — the git workspaceStatus badges are untouched; the diff lives in the file view.
  • Per the question in Annotate mode: extend the version diff to folder sessions #1104: this rides the existing annotateHistory toggle (default-on). Happy to move it behind a separate opt-in if preferred.

BenNewman100 and others added 15 commits July 21, 2026 19:04
…lper

Move the single-file annotate-history pipeline (slug derivation,
saveToHistory, previous-version lookup, degrade-on-error) out of the
Bun-specific annotate server and into packages/shared/annotate-history.ts,
built on node:fs/node:path/node:crypto only so other runtimes can vendor it
unmodified.

annotate.ts now calls computeAnnotateHistory() instead of inlining the
pipeline; behavior for single-file sessions is unchanged.
…ssions

Eligible folder files served through /api/doc now get snapshotted into the
same version history the single-file flow uses, and their doc responses
carry the same previousPlan/versionInfo/diffCurrent fields /api/plan already
returns for single-file sessions. The pipeline runs lazily on first open and
is memoized per resolved absolute path for the life of the server, so
reopening a file never re-snapshots it.

Eligibility mirrors the single-file source-save gates: a local file under
the session's folder root, markdown-branch documents only (.md/.txt, not
HTML, not a Turndown-converted doc), under the existing 2MB annotatable-file
cap, and gated by the same annotateHistory config toggle. Storage failures
degrade to a plain render (never a gate on the request) via the same
try/catch computeAnnotateHistory already wraps.

/api/plan/version and /api/plan/versions gain an optional path (+ base)
query param so folder sessions can ask for a specific file's history; the
slug is always derived server-side from the resolved, containment-checked
path — never accepted from the client, since it gets joined unsanitized
into a filesystem path. Omitting path keeps today's single-session-binding
behavior unchanged.
Adds a new describe block exercising the folder-mode history pipeline added
in the previous commit: first-open snapshot + same-session memoization,
storage-level dedupe, cross-mode slug continuity with the single-file flow,
first-ever-open field shape, the config toggle, an ineligible (HTML) file
type, degrade-on-unwritable-history-dir, and the path-parameterized version
endpoints (including containment rejection and the no-path fallback).
usePlanDiff's diff-base state (diffBasePlan, diffBaseVersion, versions, ...)
was seeded once from its constructor args and only ever synced later via a
"still falsy" guard - fine for a single root document, but switching to a
different document (a different previousPlan/versionInfo) would silently
keep the previous document's diff base around instead of adopting the new
one's.

Add an optional docKey param identifying which document the current
previousPlan/versionInfo belong to. When it changes between renders, reset
diffBasePlan/diffBaseVersion/versions (and in-flight loading/selecting
flags) to the newly-provided values. Omitting docKey (or keeping it stable)
preserves exactly today's one-time-hydration behavior, so the root
document's call site is unaffected until it opts in.

No caller passes docKey yet - this is purely additive.
…dDoc

/api/doc now returns previousPlan/versionInfo/diffCurrent for eligible
folder files (same shape /api/plan already returns for single-file
sessions). Extend LinkedDocLoadData with those fields and carry them
through the same activate/cache/back lifecycle annotations and markdown
already use, so a document's diff baseline:

- is captured once when the document is first opened
- persists in the per-filepath cache across back()/re-open, instead of
  being lost or needing a re-fetch
- resolves cache-first via the new resolveDiffBaseline helper, gated on
  whether a baseline was ever captured (versionInfo presence) rather than
  truthiness of previousPlan - a document at its first-ever version
  legitimately caches previousPlan: null, which is a resolved fact, not a
  cache miss

The hook exposes the active document's baseline as diffPreviousPlan/
diffVersionInfo, both null when no document is active or the active one has
no eligible history (every non-folder linked doc, since /api/doc never
populates these fields for those).

Not yet consumed by App.tsx - purely additive.
Folder annotate's version-diff UI (inline PlanDiffViewer blocks, the +N/-M
badge, and the Version Browser) was root-document-coupled: usePlanDiff was
fed only the root's previousPlan/versionInfo, and every render site keyed
off linkedDocHook.isActive to blank out the badge/version tab whenever any
linked or folder document was open.

Wire the two new per-document seams together instead:
- Feed usePlanDiff the active document's own previousPlan/versionInfo/
  filepath (falling back to the root document's when none is active), using
  the document's filepath as usePlanDiff's new docKey so switching documents
  resets the diff base instead of inheriting the previous one's.
- Add per-document fetchers (fetchVersion/fetchVersions with
  &path=<filepath>) so selecting a base version or listing versions targets
  the active document's own history, not the session-bound bare endpoints.
- Replace the root-only versionInfo/showVersionsTab reads with the active
  document's, so the Version Browser now reflects whichever document is on
  screen (previously it kept showing the root document's versions while a
  linked doc was open).
- Drop the blanket "linkedDocHook.isActive ? null/false : ..." suppression
  at the Viewer callsite and in DocBadges - planDiffStats/hasPreviousVersion
  already resolve to the active document's own (possibly absent) diff data,
  so the badge now shows for folder docs with history and stays hidden for
  every other document exactly as it did before.

Root-document behavior (single-file, plan, review, HTML surfaces) is
unaffected: none of those ever set a docKey or have an eligible document
history, so they fall through to the same defaults as before.
Mirrors the Bun runtime's folder annotate history support
(packages/server/annotate.ts + reference-handlers.ts) in the Pi Node server:

- Vendor the shared annotate-history helper (deriveAnnotateHistorySlug,
  computeAnnotateHistory) from packages/shared into generated/ via
  vendor.sh, and delegate the single-file version-history pipeline in
  serverAnnotate.ts to it instead of the hand-duplicated inline block.
  Behavior for single-file sessions is unchanged.
- Eligible folder files served through /api/doc now get snapshotted into
  the same version history the single-file flow uses, and their doc
  responses carry the same previousPlan/versionInfo/diffCurrent fields
  /api/plan already returns. The pipeline runs lazily on first open and
  is memoized per resolved absolute path for the life of the server, so
  reopening a file never re-snapshots it.
- /api/plan/version and /api/plan/versions gain an optional path
  (+ base) query param so folder sessions can ask for a specific file's
  history; the slug is always derived server-side from the resolved,
  containment-checked path (resolveAllowedDocPath in reference.ts) —
  never accepted from the client.
Adds apps/pi-extension/server/annotate-history.test.ts, the Node mirror of
packages/server/annotate.test.ts's folder-history describe block: first-open
snapshot + same-session memoization, cross-mode slug continuity with the
single-file flow, the config toggle, an ineligible (HTML) file type,
degrade-on-unwritable-history-dir, and the path-parameterized version
endpoints (including containment rejection and the no-path fallback).

History writes land in the real ~/.plannotator data dir rather than a
per-test PLANNOTATOR_DATA_DIR override: generated/storage.js caches its
data directory in a module-level constant at first import, so a per-test
env var override taken after that point silently no-ops. Each test uses
its own unique project namespace instead, same approach as the Bun-side
suite.
usePlanDiff.test.tsx and useLinkedDoc.test.tsx use the test.skipIf(!hasDom)
pattern but were never added to the DOM_TESTS step, so they silently
skipped under CI's plain `bun test` and never actually ran.
diffCurrent equals the document's own markdown and the client never reads
it off /api/doc — it only exists on /api/plan for legacy single-file
shape parity, which is untouched. Stop merging it into folder /api/doc
responses and stop retaining it in the per-launch folder history memo
(Bun and Pi), and drop the now-unused field from LinkedDocLoadData.

- packages/server/reference-handlers.ts: new FolderAnnotateHistory type
  (AnnotateHistoryResult minus diffCurrent); applyDocOptions no longer
  copies diffCurrent onto the response
- packages/server/annotate.ts: the folder memo now stores/returns only
  slug/previousPlan/versionInfo
- apps/pi-extension/server/reference.ts + serverAnnotate.ts: mirrored
  changes for the Pi runtime
- packages/ui/hooks/useLinkedDoc.ts: removed the unused diffCurrent field
  from LinkedDocLoadData
…tions

The folder annotate history tests (Bun and Pi) minted a fresh project
namespace per test but never cleaned up, leaving hundreds of directories
under the real ~/.plannotator/history over repeated runs. Track every
minted project and remove its history directory in afterAll — this also
covers the stray non-directory artifact the "unwritable data dir" test
deliberately plants inside its own project's history dir, since removing
the project dir recursively takes it with it.

Also update the two assertions that expected diffCurrent on the folder
/api/doc response: that field is no longer propagated on the folder path
(see the preceding diffCurrent-removal commit), so both now assert its
absence instead.
usePlanDiff reset diffBasePlan/diffBaseVersion to the newly-provided
document's defaults on every docKey change. That discarded a manually
selected base version when navigating away from a document and back
(e.g. root -> linked doc -> root), regressing behavior upstream relied
on keeping (nothing reset the selection before this seam existed).

Track each docKey's selection in a ref-held Map (keyed by docKey,
including null for the root document) and restore it on return instead
of re-seeding defaults; a key visited for the first time still seeds
from its own initialPreviousPlan/versionInfo exactly as before, and
selections never leak between distinct keys.

Adds two DOM-gated tests: restoring a manual selection after a detour to
another document, and confirming distinct docKeys don't leak into each
other.
…ain-text set

The folder /api/doc history gate was a hardcoded /\.(md|txt)$/i in both
runtimes, so any other annotatable plain-text file (.mdx, .yaml, .json,
.toml, ...) opened via a folder session silently skipped snapshotting —
breaking the cross-mode continuity this feature advertises (a .yaml with an
existing single-file version thread showed no diff when opened via its
folder).

Reuse the canonical predicate instead: isAnnotatableTextPath
(ANNOTATABLE_TEXT_REGEX in @plannotator/core/annotatable), the exact set the
single-file pipeline snapshots. HTML stays deferred and .env stays excluded,
both by that same definition. Tests extended in both runtimes: .mdx mints on
first open, .yaml single-file history serves as the folder baseline, .env
mints nothing, .html unchanged.
The in-file version-diff badge in annotate/folder sessions shows +N/-M
against the file's last-reviewed snapshot, while the git badges in the file
tree count uncommitted-vs-HEAD — same numbers, different baselines. Give the
badge an optional baseline suffix and tooltip override (PlanDiffBadge
baselineLabel/baselineTooltip, threaded through DocBadges, Viewer, and
StickyHeaderLane) and have annotate mode pass 'since last review' /
'Changes since you last reviewed this file'. Plan review passes nothing and
renders byte-identically to before. DOM tests cover both the labeled and the
unchanged default rendering.
Follow-up to the per-document diff baselines: with diff view active on file
A, opening a history-less file B left isPlanDiffActive latched on — the diff
viewer could not render for B, but the stale flag hid the annotation
toolstrip and sticky header until the user pressed Escape. Auto-exit the
diff view whenever the active (non-HTML) document has no baseline. The
--render-html surface is explicitly gated out: its diff view is driven by
htmlDiffHtml with usePlanDiff fed nulls, so hasPreviousVersion is always
false there and auto-exiting would kill the HTML diff toggle. Plan review is
unaffected — the root document's baseline never goes false mid-session. DOM
tests cover the exit, the keep-active document switch, the HTML gate, and
the no-baseline activation snap-back.
@backnotprop

backnotprop commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Thanks for this PR. It went through a deep review, including live probing of the runtime behavior, and it held up very well: the lazy per-file snapshots, the shared-handler gating, the toggle and 2MB cap handling, slug continuity across single-file and folder sessions, path traversal protection, the client-side diff-state resets, and the Bun/Pi parity all checked out under adversarial testing. The test coverage you wrote is excellent, and the memoization comments made the review much easier. Really strong work, especially for a first contribution here.

Two small alignment items came out of the review, and rather than cost you a round trip we pushed them directly to your branch (maintainer edits):

  • d90a8cf widens the folder history eligibility from the hardcoded md/txt regex to the canonical single-file plain-text set (isAnnotatableTextPath from @plannotator/core/annotatable), per the earlier issue-comment nudge. Before this, a .yaml or .mdx with an existing single-file version thread showed no diff when opened via its folder. Tests extended in both runtimes: .mdx and .yaml now eligible, .html and .env still excluded.
  • 700918a gives the in-file diff badge a baseline label in annotate and folder sessions: a "since last review" suffix plus a "Changes since you last reviewed this file" tooltip, so its numbers read against the right baseline next to the git badges in the file tree. Plan review renders byte-identically to before; the new props are optional and default off.

We also included a small follow-on your PR half-addressed already (50a6fcd): with diff view active on file A, opening a history-less file B used to leave the diff flag latched on and the annotation toolstrip hidden until Escape. The view now auto-exits when the active document has no baseline, with the HTML render surface explicitly gated out since its diff is driven by htmlDiffHtml. One corner deliberately left alone as a known follow-up: switching from an active markdown diff to an .html folder file can still carry the stale flag, since that surface's diff state lives outside usePlanDiff.

Full suite is green on the branch (2238 pass, plus the DOM suites), and we verified the .yaml folder scenario end to end against a live server. Thanks again, this was a pleasure to review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Annotate mode: extend the version diff to folder sessions

2 participants