Files
backnotprop__plannotator/packages/ui/hooks/useLinkedDoc.ts
Ben Newman 6b542da8b9 feat(annotate): extend per-file version diff to folder sessions (#1105)
* refactor(annotate): extract per-file version history into a shared helper

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.

* feat(annotate): extend per-file version history to folder annotate sessions

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.

* test(annotate): cover folder annotate version history

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).

* feat(ui): add a docKey seam to usePlanDiff for per-document resets

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.

* feat(ui): carry a per-document version-diff baseline through useLinkedDoc

/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.

* feat(editor): render folder-doc version diffs via the active document

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.

* feat(pi): extend per-file version history to folder annotate sessions

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.

* test(pi): cover folder annotate version history

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.

* ci: run docKey/linked-doc DOM tests in CI

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.

* refactor(annotate): drop diffCurrent from the folder /api/doc path

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

* test(annotate): stop leaking history dirs; update diffCurrent expectations

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.

* fix(ui): remember per-document diff-base selection across navigation

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.

* fix(annotate): match folder history eligibility to the single-file plain-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.

* feat(ui): label the folder diff badge with its baseline

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.

* fix(editor): exit diff view when the active document loses its baseline

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.

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-07-26 15:33:18 -07:00

562 lines
20 KiB
TypeScript

/**
* Linked Document Hook
*
* Manages same-view navigation to local .md files referenced in plans.
* Handles state swapping (save plan state, load doc, restore on back),
* annotation caching per filepath, and highlight re-application.
*/
import { useState, useCallback, useRef } from "react";
import type { Annotation, ImageAttachment } from "../types";
import type { ViewerHandle } from "../components/Viewer";
import type { SidebarTab } from "./useSidebar";
import type { SourceSaveCapability } from "@plannotator/core/source-save";
import type { VersionInfo } from "./usePlanDiff";
export interface LinkedDocLoadData {
markdown?: string;
filepath?: string;
isConverted?: boolean;
renderAs?: 'markdown' | 'html';
rawHtml?: string;
shareHtml?: string;
sourceSave?: SourceSaveCapability;
/**
* Per-file version-diff baseline (annotate folder sessions only) — the same
* field names/shapes /api/plan already returns for single-file sessions.
* /api/doc only populates these for eligible folder files (local annotatable
* plain-text files — .md/.mdx/.txt plus the config set, per
* `isAnnotatableTextPath` — markdown-branch, not converted/HTML); every other
* document — including
* plain linked docs outside folder mode — omits them, which is what keeps
* the diff badge/version browser from appearing for those.
*/
previousPlan?: string | null;
versionInfo?: VersionInfo | null;
}
/**
* Resolve the version-diff baseline for a document being activated: prefer
* an already-cached baseline (set the first time this document was opened)
* over the freshly-fetched `data`, so re-opening a document during a session
* never loses — or needs to re-request — its baseline. Pure so it's testable
* without mounting the hook; used by activateDocument below.
*
* Gated on whether a baseline was EVER captured for this document (versionInfo
* presence), not on truthiness of the cached previousPlan value — a document
* at its first-ever version legitimately caches `previousPlan: null` ("no
* earlier version to diff against yet"), which is a resolved fact, not a
* cache miss. Falling through to `data` on a plain `??` would silently
* discard that fact whenever `data` lacks the fields too (e.g. the
* missing-on-disk openLoaded path, which never fetches /api/doc).
*/
export function resolveDiffBaseline(
cached: Pick<CachedDocState, 'previousPlan' | 'versionInfo'> | undefined,
data: Pick<LinkedDocLoadData, 'previousPlan' | 'versionInfo'>,
): { previousPlan: string | null | undefined; versionInfo: VersionInfo | null | undefined } {
if (cached && cached.versionInfo !== undefined) {
return { previousPlan: cached.previousPlan, versionInfo: cached.versionInfo };
}
return { previousPlan: data.previousPlan, versionInfo: data.versionInfo };
}
export interface UseLinkedDocOptions {
markdown: string;
annotations: Annotation[];
selectedAnnotationId: string | null;
globalAttachments: ImageAttachment[];
setMarkdown: (md: string) => void;
setAnnotations: (anns: Annotation[]) => void;
setSelectedAnnotationId: (id: string | null) => void;
setGlobalAttachments: (att: ImageAttachment[]) => void;
/** Current render mode + raw HTML of the base document. An HTML linked/folder file
* swaps these to render raw; back() restores the base values from this snapshot. */
renderAs: 'markdown' | 'html';
rawHtml: string;
shareHtml: string;
setRenderAs: (r: 'markdown' | 'html') => void;
setRawHtml: (html: string) => void;
setShareHtml: (html: string) => void;
viewerRef: React.RefObject<ViewerHandle | null>;
sidebar: { open: (tab?: SidebarTab) => void };
/** Absolute path of the primary document — enables getDocAnnotations() to include
* stashed original-file annotations when viewing a linked doc. */
sourceFilePath?: string;
/** Whether the primary document was converted from HTML/URL — propagated to the
* stashed entry so feedback caveats survive cross-doc navigation. */
sourceConverted?: boolean;
/** Snapshot live editor state before linked/folder navigation swaps documents. */
onBeforeNavigate?: () => void;
/** Let the host initialize/restore editable document state and optionally
* override the markdown displayed for this file. */
onDocumentLoaded?: (doc: LinkedDocLoadData) => string | undefined;
/** Read current host-owned text when caching a linked doc. */
getDocumentMarkdown?: (filepath: string, fallback?: string) => string | undefined;
/** Let the host restore any state that was suspended while a linked doc was active. */
onAfterBack?: () => void;
}
interface SavedPlanState {
markdown: string;
annotations: Annotation[];
selectedAnnotationId: string | null;
globalAttachments: ImageAttachment[];
renderAs: 'markdown' | 'html';
rawHtml: string;
shareHtml: string;
}
export interface CachedDocState {
annotations: Annotation[];
globalAttachments: ImageAttachment[];
markdown?: string;
isConverted?: boolean;
/** Version-diff baseline captured the first time this document was
* activated — see LinkedDocLoadData. Persisted here so re-opening the
* same document later in the session reuses it instead of losing it (or
* needing to re-fetch it) once the fresh /api/doc data is gone. */
previousPlan?: string | null;
versionInfo?: VersionInfo | null;
}
export interface LinkedDocSessionState {
root: SavedPlanState;
docs: Map<string, CachedDocState>;
}
export interface UseLinkedDocReturn {
/** Whether a linked doc is currently active */
isActive: boolean;
/** Resolved filepath of the active linked doc */
filepath: string | null;
/** Error from the last open attempt */
error: string | null;
/** Whether a fetch is in progress */
isLoading: boolean;
/** Open a linked document by path (saves plan state, fetches doc, swaps) */
open: (docPath: string, buildUrl?: (path: string) => string, targetTab?: SidebarTab) => Promise<void>;
/** Open an already-loaded linked document without refetching from disk */
openLoaded: (
doc: LinkedDocLoadData & { filepath: string },
targetTab?: SidebarTab,
options?: { notifyDocumentLoaded?: boolean },
) => void;
/** Return to the plan (caches doc annotations, restores plan state) */
back: () => void;
/** Dismiss the current error */
dismissError: () => void;
/** All linked doc annotations including the active doc's live state (keyed by filepath) */
getDocAnnotations: () => Map<string, CachedDocState>;
/** Snapshot the root document plus linked-doc cache for cross-document session swaps */
snapshotSession: () => LinkedDocSessionState;
/** Restore a root document plus linked-doc cache, closing any active linked document */
restoreSession: (state: LinkedDocSessionState) => void;
/** Reactive count of annotations on non-active documents (updates on open() and back()) */
docAnnotationCount: number;
/** Active document's version-diff baseline (folder annotate only). Null
* when no document is active, or the active document has no eligible
* history — including every non-folder linked doc, since /api/doc never
* populates these fields for those. */
diffPreviousPlan: string | null;
diffVersionInfo: VersionInfo | null;
}
const HIGHLIGHT_REAPPLY_DELAY = 100;
export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
const {
markdown,
annotations,
selectedAnnotationId,
globalAttachments,
setMarkdown,
setAnnotations,
setSelectedAnnotationId,
setGlobalAttachments,
renderAs,
rawHtml,
shareHtml,
setRenderAs,
setRawHtml,
setShareHtml,
viewerRef,
sidebar,
sourceFilePath,
sourceConverted,
onBeforeNavigate,
onDocumentLoaded,
getDocumentMarkdown,
onAfterBack,
} = options;
const [linkedDoc, setLinkedDoc] = useState<{
filepath: string;
isConverted?: boolean;
markdown?: string;
previousPlan?: string | null;
versionInfo?: VersionInfo | null;
} | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [docAnnotationCount, setDocAnnotationCount] = useState(0);
// Stash plan state when navigating to a linked doc
const savedPlanState = useRef<SavedPlanState | null>(null);
// Cache linked doc annotations keyed by filepath (persists across back/forth within session)
const docCache = useRef<Map<string, CachedDocState>>(new Map());
const defaultBuildUrl = useCallback(
(path: string) => `/api/doc?path=${encodeURIComponent(path)}`,
[]
);
const back = useCallback(() => {
if (!savedPlanState.current) return;
onBeforeNavigate?.();
// Clear web-highlighter marks before swapping content to prevent React DOM mismatch
viewerRef.current?.clearAllHighlights();
// Cache current linked doc annotations
if (linkedDoc) {
docCache.current.set(linkedDoc.filepath, {
annotations: [...annotations],
globalAttachments: [...globalAttachments],
markdown: getDocumentMarkdown?.(linkedDoc.filepath, linkedDoc.markdown) ?? linkedDoc.markdown,
isConverted: linkedDoc.isConverted,
previousPlan: linkedDoc.previousPlan,
versionInfo: linkedDoc.versionInfo,
});
// Update reactive count so button labels can respond
let total = 0;
for (const cached of docCache.current.values()) {
total += cached.annotations.length + cached.globalAttachments.length;
}
setDocAnnotationCount(total);
}
// Restore plan state (including render mode — an HTML base restores to HTML)
const saved = savedPlanState.current;
setRenderAs(saved.renderAs);
setRawHtml(saved.rawHtml);
setShareHtml(saved.shareHtml);
setMarkdown(saved.markdown);
setAnnotations(saved.annotations);
setGlobalAttachments(saved.globalAttachments);
setSelectedAnnotationId(saved.selectedAnnotationId);
setLinkedDoc(null);
setError(null);
savedPlanState.current = null;
onAfterBack?.();
// Re-apply plan annotation highlights after DOM settles
if (saved.annotations.length) {
setTimeout(() => {
viewerRef.current?.clearAllHighlights();
viewerRef.current?.applySharedAnnotations(saved.annotations);
}, HIGHLIGHT_REAPPLY_DELAY);
}
}, [
linkedDoc,
annotations,
globalAttachments,
setMarkdown,
setAnnotations,
setSelectedAnnotationId,
setGlobalAttachments,
setRenderAs,
setRawHtml,
setShareHtml,
viewerRef,
onBeforeNavigate,
getDocumentMarkdown,
onAfterBack,
]);
const activateDocument = useCallback((
data: LinkedDocLoadData & { filepath: string },
targetTab?: SidebarTab,
options: { snapshotCurrent?: boolean; notifyDocumentLoaded?: boolean } = {},
) => {
const snapshotCurrent = options.snapshotCurrent ?? true;
const notifyDocumentLoaded = options.notifyDocumentLoaded ?? true;
if (snapshotCurrent) onBeforeNavigate?.();
// Backlink detection: if a linked doc links back to the source file (e.g.,
// original.md → design.md → link back to original.md), opening it as a linked
// doc would create two competing Map entries for the same filepath in
// getDocAnnotations(), and the empty linked-doc entry would overwrite the
// stashed annotations. Instead, treat the backlink as a back() navigation —
// the current linked doc gets cached and the source file restores with its
// annotations intact.
if (sourceFilePath && data.filepath === sourceFilePath && savedPlanState.current) {
back();
return;
}
// Clear web-highlighter marks before swapping content to prevent React DOM mismatch
viewerRef.current?.clearAllHighlights();
// Save current state (plan or another linked doc)
if (!savedPlanState.current) {
savedPlanState.current = {
markdown,
annotations: [...annotations],
selectedAnnotationId,
globalAttachments: [...globalAttachments],
renderAs,
rawHtml,
shareHtml,
};
let total = annotations.length + globalAttachments.length;
for (const [fp, cached] of docCache.current.entries()) {
if (fp === data.filepath) continue; // destination becomes active — don't double-count
total += cached.annotations.length + cached.globalAttachments.length;
}
setDocAnnotationCount(total);
} else if (linkedDoc) {
// Already viewing a linked doc — cache its annotations before moving on
docCache.current.set(linkedDoc.filepath, {
annotations: [...annotations],
globalAttachments: [...globalAttachments],
markdown: getDocumentMarkdown?.(linkedDoc.filepath, linkedDoc.markdown) ?? linkedDoc.markdown,
isConverted: linkedDoc.isConverted,
previousPlan: linkedDoc.previousPlan,
versionInfo: linkedDoc.versionInfo,
});
let total = 0;
for (const [fp, cached] of docCache.current.entries()) {
if (fp === data.filepath) continue; // destination becomes active — don't double-count
total += cached.annotations.length + cached.globalAttachments.length;
}
if (savedPlanState.current) {
total += savedPlanState.current.annotations.length + savedPlanState.current.globalAttachments.length;
}
setDocAnnotationCount(total);
}
// Check cache for previous annotations on this file
const cached = docCache.current.get(data.filepath);
// Swap to linked doc — an .html file renders raw (HtmlViewer), a markdown
// file parses to blocks (Viewer). Drive renderAs/rawHtml per file so the
// App's renderAs === 'html' ? HtmlViewer : Viewer switch flips automatically.
const docRenderAs = data.renderAs === 'html' ? 'html' : 'markdown';
const hostMarkdown = docRenderAs === 'html' || !notifyDocumentLoaded ? undefined : onDocumentLoaded?.(data);
const nextMarkdown = notifyDocumentLoaded
? hostMarkdown ?? cached?.markdown ?? data.markdown ?? ''
: data.markdown ?? cached?.markdown ?? '';
setRenderAs(docRenderAs);
setRawHtml(docRenderAs === 'html' ? (data.rawHtml ?? '') : '');
setShareHtml(docRenderAs === 'html' ? (data.shareHtml ?? '') : '');
setMarkdown(docRenderAs === 'html' ? '' : nextMarkdown);
setAnnotations(cached?.annotations ?? []);
setGlobalAttachments(cached?.globalAttachments ?? []);
setSelectedAnnotationId(null);
const diffBaseline = resolveDiffBaseline(cached, data);
setLinkedDoc({
filepath: data.filepath,
isConverted: !!data.isConverted,
markdown: nextMarkdown,
previousPlan: diffBaseline.previousPlan,
versionInfo: diffBaseline.versionInfo,
});
setError(null);
sidebar.open(targetTab ?? "toc");
// Re-apply cached annotations after DOM settles
if (cached?.annotations.length) {
setTimeout(() => {
viewerRef.current?.clearAllHighlights();
viewerRef.current?.applySharedAnnotations(cached.annotations);
}, HIGHLIGHT_REAPPLY_DELAY);
}
}, [
markdown,
annotations,
selectedAnnotationId,
globalAttachments,
renderAs,
rawHtml,
shareHtml,
linkedDoc,
setMarkdown,
setAnnotations,
setSelectedAnnotationId,
setGlobalAttachments,
setRenderAs,
setRawHtml,
setShareHtml,
viewerRef,
sidebar,
sourceFilePath,
onBeforeNavigate,
onDocumentLoaded,
getDocumentMarkdown,
back,
]);
const openLoaded = useCallback((
doc: LinkedDocLoadData & { filepath: string },
targetTab?: SidebarTab,
options?: { notifyDocumentLoaded?: boolean },
) => {
activateDocument(doc, targetTab, {
snapshotCurrent: true,
notifyDocumentLoaded: options?.notifyDocumentLoaded,
});
}, [activateDocument]);
const open = useCallback(
async (docPath: string, buildUrl?: (path: string) => string, targetTab?: SidebarTab) => {
onBeforeNavigate?.();
setIsLoading(true);
setError(null);
try {
const url = (buildUrl ?? defaultBuildUrl)(docPath);
const res = await fetch(url);
const data = (await res.json()) as LinkedDocLoadData & {
error?: string;
matches?: string[];
};
if (!res.ok || data.error) {
setError(data.error || "Failed to load document");
return;
}
if (!data.filepath) {
setError("Failed to load document");
return;
}
activateDocument({ ...data, filepath: data.filepath }, targetTab, { snapshotCurrent: false });
} catch {
setError("Failed to connect to server");
} finally {
setIsLoading(false);
}
},
[
onBeforeNavigate,
activateDocument,
]
);
const dismissError = useCallback(() => setError(null), []);
const snapshotSession = useCallback((): LinkedDocSessionState => {
const docs = new Map(docCache.current);
if (linkedDoc) {
docs.set(linkedDoc.filepath, {
annotations: [...annotations],
globalAttachments: [...globalAttachments],
markdown: getDocumentMarkdown?.(linkedDoc.filepath, linkedDoc.markdown) ?? linkedDoc.markdown,
isConverted: linkedDoc.isConverted,
previousPlan: linkedDoc.previousPlan,
versionInfo: linkedDoc.versionInfo,
});
}
const root = savedPlanState.current
? {
markdown: savedPlanState.current.markdown,
renderAs: savedPlanState.current.renderAs,
rawHtml: savedPlanState.current.rawHtml,
shareHtml: savedPlanState.current.shareHtml,
annotations: [...savedPlanState.current.annotations],
selectedAnnotationId: savedPlanState.current.selectedAnnotationId,
globalAttachments: [...savedPlanState.current.globalAttachments],
}
: {
markdown,
renderAs,
rawHtml,
shareHtml,
annotations: [...annotations],
selectedAnnotationId,
globalAttachments: [...globalAttachments],
};
return { root, docs };
}, [linkedDoc, annotations, globalAttachments, markdown, renderAs, rawHtml, shareHtml, selectedAnnotationId, getDocumentMarkdown]);
const restoreSession = useCallback((state: LinkedDocSessionState) => {
viewerRef.current?.clearAllHighlights();
savedPlanState.current = null;
docCache.current = new Map(state.docs);
let total = 0;
for (const cached of docCache.current.values()) {
total += cached.annotations.length + cached.globalAttachments.length;
}
setDocAnnotationCount(total);
setMarkdown(state.root.markdown);
setRenderAs(state.root.renderAs);
setRawHtml(state.root.rawHtml);
setShareHtml(state.root.shareHtml);
setAnnotations([...state.root.annotations]);
setGlobalAttachments([...state.root.globalAttachments]);
setSelectedAnnotationId(state.root.selectedAnnotationId);
setLinkedDoc(null);
setError(null);
if (state.root.annotations.length) {
setTimeout(() => {
viewerRef.current?.clearAllHighlights();
viewerRef.current?.applySharedAnnotations(state.root.annotations);
}, HIGHLIGHT_REAPPLY_DELAY);
}
}, [
setMarkdown,
setAnnotations,
setSelectedAnnotationId,
setGlobalAttachments,
setRenderAs,
setRawHtml,
setShareHtml,
viewerRef,
]);
const getDocAnnotations = useCallback((): Map<string, CachedDocState> => {
const result = new Map(docCache.current);
// Include stashed original-file annotations when viewing a linked doc
if (linkedDoc && savedPlanState.current && sourceFilePath) {
result.set(sourceFilePath, {
annotations: [...savedPlanState.current.annotations],
globalAttachments: [...savedPlanState.current.globalAttachments],
markdown: savedPlanState.current.markdown,
isConverted: !!sourceConverted,
});
}
if (linkedDoc) {
result.set(linkedDoc.filepath, {
annotations: [...annotations],
globalAttachments: [...globalAttachments],
markdown: getDocumentMarkdown?.(linkedDoc.filepath, linkedDoc.markdown) ?? linkedDoc.markdown,
isConverted: linkedDoc.isConverted,
});
}
return result;
}, [linkedDoc, annotations, globalAttachments, sourceFilePath, sourceConverted, getDocumentMarkdown]);
return {
isActive: linkedDoc !== null,
filepath: linkedDoc?.filepath ?? null,
error,
isLoading,
open,
openLoaded,
back,
dismissError,
getDocAnnotations,
snapshotSession,
restoreSession,
docAnnotationCount,
diffPreviousPlan: linkedDoc?.previousPlan ?? null,
diffVersionInfo: linkedDoc?.versionInfo ?? null,
};
}