/** * Plan Diff Hook * * Manages plan diff state: version fetching, diff computation, and version browsing. * Consumes the version history API endpoints. */ import { useState, useMemo, useCallback, useEffect, useRef } from "react"; import { computePlanDiff, type PlanDiffBlock, type PlanDiffStats, } from "../utils/planDiffEngine"; export interface VersionInfo { version: number; totalVersions: number; project: string; } export interface VersionEntry { version: number; timestamp: string; } export interface UsePlanDiffReturn { /** The version we're comparing against */ diffBaseVersion: number | null; /** Content of the base version */ diffBasePlan: string | null; /** Computed diff blocks (null if no base plan to diff against) */ diffBlocks: PlanDiffBlock[] | null; /** Computed diff stats (null if no diff) */ diffStats: PlanDiffStats | null; /** Whether a previous version exists to diff against */ hasPreviousVersion: boolean; /** Change the base version to diff against */ selectBaseVersion: (version: number) => Promise; /** All versions of the current plan */ versions: VersionEntry[]; /** Whether version list is loading */ isLoadingVersions: boolean; /** Whether a version selection fetch is in progress */ isSelectingVersion: boolean; /** Which version is currently being fetched (null if none) */ fetchingVersion: number | null; /** Fetch the version list for the sidebar */ fetchVersions: () => Promise; } export interface PlanDiffFetchers { /** Fetch a specific version's plan content. Default → GET /api/plan/version?v=N */ fetchVersion?: (version: number) => Promise<{ plan: string; version: number }>; /** Fetch the version list. Default → GET /api/plan/versions */ fetchVersions?: () => Promise<{ project: string; slug: string; versions: VersionEntry[]; }>; } const defaultFetchVersion = async ( version: number ): Promise<{ plan: string; version: number }> => { const res = await fetch(`/api/plan/version?v=${version}`); if (!res.ok) { throw new Error(`Failed to load version ${version}.`); } return (await res.json()) as { plan: string; version: number }; }; const defaultFetchVersions = async (): Promise<{ project: string; slug: string; versions: VersionEntry[]; }> => { const res = await fetch("/api/plan/versions"); if (!res.ok) { throw new Error("Failed to load versions."); } return (await res.json()) as { project: string; slug: string; versions: VersionEntry[]; }; }; export function usePlanDiff( currentPlan: string, initialPreviousPlan: string | null, versionInfo: VersionInfo | null, fetchers?: PlanDiffFetchers, /** * Identity of the document `initialPreviousPlan`/`versionInfo` belong to * (e.g. a folder/linked document's filepath). When this changes between * renders, the diff-base state below switches to that document's own * remembered base selection (if this document was visited earlier in the * session — see baseSelectionsByDocKeyRef below) or, the first time a * document is seen, seeds from its `initialPreviousPlan`/`versionInfo` — * either way, never keeping whatever the previously active document had * selected, since that would silently keep diffing the new document's text * against the old document's base plan. Omit (or keep it referentially * stable, as the root document does for the life of a session) to preserve * exactly today's one-time-hydration behavior via the two sync effects * below. */ docKey?: string | null ): UsePlanDiffReturn { const fetchVersionImpl = fetchers?.fetchVersion ?? defaultFetchVersion; const fetchVersionsImpl = fetchers?.fetchVersions ?? defaultFetchVersions; const [diffBasePlan, setDiffBasePlan] = useState( initialPreviousPlan ); const [diffBaseVersion, setDiffBaseVersion] = useState( versionInfo && versionInfo.version > 1 ? versionInfo.version - 1 : null ); const [versions, setVersions] = useState([]); const [isLoadingVersions, setIsLoadingVersions] = useState(false); const [isSelectingVersion, setIsSelectingVersion] = useState(false); const [fetchingVersion, setFetchingVersion] = useState(null); // Sync diffBasePlan when initialPreviousPlan arrives after mount (API response) useEffect(() => { if (initialPreviousPlan && !diffBasePlan) { setDiffBasePlan(initialPreviousPlan); } }, [initialPreviousPlan]); // Sync diffBaseVersion when versionInfo arrives after mount useEffect(() => { if (versionInfo && versionInfo.version > 1 && diffBaseVersion === null) { setDiffBaseVersion(versionInfo.version - 1); } }, [versionInfo]); // Remember each document's diff-base selection across navigation, keyed by // docKey (including `null`/`undefined`) — so returning to a previously // visited document (e.g. the root doc after a linked-doc detour) restores // whatever base version the user had picked there instead of re-seeding // defaults. A plain ref (not state) since writing it must never itself // trigger a render — it's only read/written from inside the docKey-change // effect below. Never shared across keys: each key's entry is only ever // populated from that same key's own state at the moment it stops being // active. const baseSelectionsByDocKeyRef = useRef( new Map< string | null | undefined, { diffBasePlan: string | null; diffBaseVersion: number | null } >() ); // Reset (or restore) diff-base state whenever the active document identity // changes (e.g. folder/linked-doc navigation). docKey is undefined/stable // for a session with no per-doc identity (the root document), so this // never fires there and the sync effects above keep owning its one-time // hydration — byte-identical to before this seam existed. const prevDocKeyRef = useRef(docKey); useEffect(() => { if (prevDocKeyRef.current === docKey) return; // Persist the outgoing document's current selection before switching, so // coming back to it later (even after other documents were visited in // between) restores it rather than re-seeding defaults. baseSelectionsByDocKeyRef.current.set(prevDocKeyRef.current, { diffBasePlan, diffBaseVersion, }); prevDocKeyRef.current = docKey; const remembered = baseSelectionsByDocKeyRef.current.get(docKey); if (remembered) { setDiffBasePlan(remembered.diffBasePlan); setDiffBaseVersion(remembered.diffBaseVersion); } else { setDiffBasePlan(initialPreviousPlan); setDiffBaseVersion( versionInfo && versionInfo.version > 1 ? versionInfo.version - 1 : null ); } setVersions([]); setIsLoadingVersions(false); setIsSelectingVersion(false); setFetchingVersion(null); // Only the identity change (docKey) should trigger this reset — the sync // effects above already handle initialPreviousPlan/versionInfo arriving // late for a stable identity, and re-running this on every value change // would fight version selection (selectBaseVersion intentionally leaves // docKey unchanged while it swaps diffBasePlan/diffBaseVersion). // eslint-disable-next-line react-hooks/exhaustive-deps }, [docKey]); const hasPreviousVersion = versionInfo !== null && versionInfo.totalVersions > 1 && diffBasePlan !== null; // Compute diff whenever currentPlan or diffBasePlan changes const diffResult = useMemo(() => { if (!diffBasePlan) return null; return computePlanDiff(diffBasePlan, currentPlan); }, [currentPlan, diffBasePlan]); const diffBlocks = diffResult?.blocks ?? null; const diffStats = diffResult?.stats ?? null; const selectBaseVersion = useCallback( async (version: number) => { setIsSelectingVersion(true); setFetchingVersion(version); try { const data = await fetchVersionImpl(version); setDiffBasePlan(data.plan); setDiffBaseVersion(version); } catch { alert(`Failed to load version ${version}.`); } finally { setIsSelectingVersion(false); setFetchingVersion(null); } }, [fetchVersionImpl] ); const fetchVersions = useCallback(async () => { setIsLoadingVersions(true); try { const data = await fetchVersionsImpl(); setVersions(data.versions); } catch { // Failed to fetch versions } finally { setIsLoadingVersions(false); } }, [fetchVersionsImpl]); return { diffBaseVersion, diffBasePlan, diffBlocks, diffStats, hasPreviousVersion, selectBaseVersion, versions, isLoadingVersions, isSelectingVersion, fetchingVersion, fetchVersions, }; }