mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
6b542da8b9
* 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>
562 lines
20 KiB
TypeScript
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,
|
|
};
|
|
}
|