DocumentHistoryView.tsx8.3 KBView on GitHub
'use client';

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useTRPC } from '@/providers/query-provider';
import { base64ToUint8Array, getProvider } from '@/modules/documents/yjs';
import { HistoryDiffView, type HistoryDiffMode } from './HistoryDiffView';
import { HistorySidebar, type HistorySidebarSession } from './HistorySidebar';
import { useDocumentHistory } from './useDocumentHistory';
import { useVersionDiff } from './useVersionDiff';

export interface DocumentHistoryViewProps {
  documentId: string;
  onClose: () => void;
}

/**
 * Read-only history mode for a document, rendered as a modal over the
 * editor. Mounts on demand and unmounts on close so the live editor and the
 * history queries are mutually exclusive (lazy contract). The sidebar
 * timeline drives selection; the main pane shows the selected version with
 * inline word-level diffs vs the previous session; the Restore button
 * commits a new system-origin save and closes the modal.
 *
 * Sizing:
 *   - the main reading column is capped at 100ch (matches the live editor
 *     `max-w-[100ch]` so the diff renders at the same measure)
 *   - the sidebar is a fixed 18rem
 *   - the dialog itself caps at `100ch + 18rem` total
 */
export function DocumentHistoryView({ documentId, onClose }: DocumentHistoryViewProps) {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const { data, isLoading } = useDocumentHistory(documentId, true);

  // Backend returns sessions in from_seq ascending order. The sidebar shows
  // newest at top; selection logic works on the ascending list so the
  // "previous session" lookup is `idx - 1`.
  const sessionsAsc: HistorySidebarSession[] = useMemo(
    () => (data?.sessions ?? []) as HistorySidebarSession[],
    [data?.sessions],
  );

  const [selectedSeq, setSelectedSeq] = useState<number | null>(null);
  const [diffMode, setDiffMode] = useState<HistoryDiffMode>('diff');
  useEffect(() => {
    if (selectedSeq != null) return;
    if (sessionsAsc.length === 0) return;
    setSelectedSeq(sessionsAsc[sessionsAsc.length - 1].toSeq);
  }, [sessionsAsc, selectedSeq]);

  const selectedIdx = useMemo(
    () => sessionsAsc.findIndex((s) => s.toSeq === selectedSeq),
    [sessionsAsc, selectedSeq],
  );
  const selected = selectedIdx >= 0 ? sessionsAsc[selectedIdx] : null;
  const previousSeq = selectedIdx > 0 ? sessionsAsc[selectedIdx - 1].toSeq : null;

  const diff = useVersionDiff(documentId, selected?.toSeq ?? null, previousSeq);

  const restoreMutation = useMutation({
    ...trpc.documents.restoreVersion.mutationOptions(),
    onSuccess: (result) => {
      // A REPLACING restore (a table) must be pushed into the live provider here, not left to a
      // refetch. Invalidating `getDoc` only re-seeds on a fresh mount, and the provider stays
      // mounted behind this modal — so the Y.Doc would keep the pre-restore state while the
      // server holds the replacement. The next cell edit then flushes a delta computed against a
      // state vector from a DISJOINT history, the server merges the two, and the document ends up
      // holding both copies of every row. `applyServerReplace` discards local state instead,
      // which is the only correct response to a new clientID and clock space.
      const replacement = (result as { replaced?: boolean; fullState?: string } | undefined)
        ?.fullState;
      if (replacement) {
        const provider = getProvider(documentId);
        provider?.applyServerReplace(base64ToUint8Array(replacement));
      }

      void queryClient.invalidateQueries(
        trpc.documents.history.queryOptions({ documentId }),
      );
      void queryClient.invalidateQueries(
        // Editor's getDoc cache — force a refetch when the user returns to
        // editing so they see the restored content even if SSE hasn't
        // delivered yet.
        trpc.documents.getDoc.queryOptions({ documentId }),
      );
      onClose();
    },
  });

  const sessionsDesc = useMemo(() => [...sessionsAsc].reverse(), [sessionsAsc]);

  return (
    <Dialog
      open
      onOpenChange={(open) => {
        if (!open) onClose();
      }}
    >
      <DialogContent
        showCloseButton={false}
        className="sm:max-w-[calc(100ch+18rem)] h-[85vh] max-h-[85vh] gap-0 overflow-hidden p-0"
      >
        <DialogTitle className="sr-only">Version history</DialogTitle>
        <div className="flex h-full min-h-0 flex-1" data-testid="document-history-view">
          <div className="flex min-w-0 flex-1 flex-col">
            <div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-2">
              <div className="flex items-center gap-3">
                <button
                  type="button"
                  onClick={onClose}
                  className="text-xs text-muted-foreground hover:text-foreground"
                  data-testid="history-back"
                >
                  ← Back to editing
                </button>
                <div className="flex items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5">
                  <button
                    type="button"
                    onClick={() => setDiffMode('diff')}
                    data-testid="history-mode-diff"
                    aria-pressed={diffMode === 'diff'}
                    className={cn(
                      'rounded px-2 py-0.5 text-xs',
                      diffMode === 'diff'
                        ? 'bg-background text-foreground shadow-sm'
                        : 'text-muted-foreground hover:text-foreground',
                    )}
                  >
                    Diff
                  </button>
                  <button
                    type="button"
                    onClick={() => setDiffMode('formatted')}
                    data-testid="history-mode-formatted"
                    aria-pressed={diffMode === 'formatted'}
                    className={cn(
                      'rounded px-2 py-0.5 text-xs',
                      diffMode === 'formatted'
                        ? 'bg-background text-foreground shadow-sm'
                        : 'text-muted-foreground hover:text-foreground',
                    )}
                  >
                    Formatted
                  </button>
                </div>
              </div>
              <div className="flex items-center gap-2 text-xs text-muted-foreground">
                {selected ? (
                  <span>
                    Viewing v{selected.toSeq} ·{' '}
                    {selected.origin === 'human'
                      ? 'human'
                      : selected.origin === 'agent'
                        ? selected.actorLabel ?? 'agent'
                        : selected.actorLabel ?? 'system'}
                  </span>
                ) : isLoading ? (
                  <span>Loading history…</span>
                ) : null}
                <button
                  type="button"
                  onClick={() =>
                    selected &&
                    restoreMutation.mutate({ documentId, seq: selected.toSeq })
                  }
                  disabled={!selected || restoreMutation.isPending}
                  data-testid="history-restore"
                  className="rounded bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
                >
                  {restoreMutation.isPending
                    ? 'Restoring…'
                    : selected
                      ? `Restore to v${selected.toSeq}`
                      : 'Restore'}
                </button>
              </div>
            </div>
            <div className="flex-1 overflow-y-auto">
              <div className="mx-auto w-full max-w-[100ch] px-6 py-6">
                <HistoryDiffView
                  markdown={diff.selectedMarkdown}
                  previousMarkdown={diff.previousMarkdown}
                  mode={diffMode}
                  isLoading={diff.isLoading}
                />
              </div>
            </div>
          </div>
          <HistorySidebar
            sessions={sessionsDesc}
            selectedSeq={selectedSeq}
            onSelect={setSelectedSeq}
          />
        </div>
      </DialogContent>
    </Dialog>
  );
}