Skip to the content

Capsid menu

app/routes/history.tsx

233 lines and 9 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.

Functions

The functions of history.tsx
FunctionLinesComplexityCoveredCRAPMutation
meta31 to 3330%WatchCRAP 12No data: mutation testing has not run on this repo yet
utc42 to 421100%OkCRAP 1No data: mutation testing has not run on this repo yet
loader44 to 1543892%CriticalCRAP 38.8No data: mutation testing has not run on this repo yet
<anonymous:101>101 to 1011100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:149>149 to 1491100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:152>152 to 1521100%OkCRAP 1No data: mutation testing has not run on this repo yet
sides157 to 161275%OkCRAP 2.1No data: mutation testing has not run on this repo yet
History163 to 253200%CriticalCRAP 420No data: mutation testing has not run on this repo yet
setBy172 to 17610%OkCRAP 2No data: mutation testing has not run on this repo yet

Imports

It imports app/components/frame.tsx, app/components/history/draft-links.tsx, app/components/history/revision-picker.tsx, app/lib/ai.server.ts, app/lib/content.server.ts, app/lib/context.ts, app/lib/history.server.ts, app/lib/kind-words.ts, app/lib/patch.ts, app/lib/projects.server.ts, app/lib/sites.server.ts. No module of this repo imports it.

Source

// One item's history: its revisions from the site, newest first, each opening its source; the
// changes between any two; and the person's own working draft set against the site's text or an AI
// draft. Everything here is read-only and works for a Reader. Revision diffs are the site's own
// unified patches; the two comparisons that have both sides in Carrel build the patch here.
// "By word" sets the same two texts against each other sentence by sentence.

import { Link, useLocation, useNavigate, useNavigation } from "react-router";
import { Alert, Banner } from "capsomer/react/banner";
import { DraftCompare, DraftPatch } from "capsomer/react/draft-compare";
import { Empty } from "capsomer/react/empty";
import { Panel } from "capsomer/react/panel";
import { Segmented } from "capsomer/react/segmented";
import { Pill } from "capsomer/react/status";
import { Time } from "capsomer/react/time";
import { PageHead } from "capsomer/react/page-head";

import { DraftLinks } from "~/components/history/draft-links";
import { RevisionPicker } from "~/components/history/revision-picker";
import { RouterLink } from "~/components/frame";
import { listAiDrafts, readAiDraft } from "~/lib/ai.server";
import { readDoc, readDraft } from "~/lib/content.server";
import { getEnv, getViewer } from "~/lib/context";
import { listRevisions, orderPair, readRevision, revisionPatch } from "~/lib/history.server";
import { kindNoun } from "~/lib/kind-words";
import { unifiedPatch } from "~/lib/patch";
import { requireSiteProject } from "~/lib/projects.server";
import { SiteNotConnected } from "~/lib/sites.server";

import type { Route } from "./+types/history";

export function meta({ loaderData: data }: Route.MetaArgs) {
  return [{ title: data ? `History of ${data.title || data.itemId} ยท Carrel` : "Carrel" }];
}

type Side = { title: string; who?: string; time?: string; text: string };
type View =
  | { kind: "none" }
  | { kind: "problem"; message: string }
  | { kind: "source"; version: string; message: string; author: string; at: string; source: string; current: boolean; previous: string | null; newest: string | null }
  | { kind: "compare"; heading: string; lead: string; patch: string | null; before: Side | null; after: Side | null };

const utc = (iso: string) => `${iso.slice(0, 16).replace("T", " ")} UTC`;

export async function loader({ params, request, context }: Route.LoaderArgs) {
  const env = getEnv(context);
  const viewer = getViewer(context);
  const project = await requireSiteProject(env.DB, viewer, params.project, "read");
  const itemId = params.item;
  const query = new URL(request.url).searchParams;
  const by: "line" | "word" = query.get("by") === "word" ? "word" : "line";

  let doc = null;
  let revisions = null;
  let siteError: string | null = null;
  try {
    doc = await readDoc(env, project, itemId);
    revisions = await listRevisions(env, project, itemId);
  } catch (error) {
    siteError = error instanceof SiteNotConnected ? error.detail : "The site did not answer, so its history cannot be shown.";
  }
  const [draft, aiDrafts] = await Promise.all([readDraft(env.DB, project, viewer, itemId), listAiDrafts(env.DB, project, viewer, itemId)]);
  if (!doc && !draft && aiDrafts.length === 0 && !siteError) throw new Response("Not found", { status: 404 });

  const noun = kindNoun(doc?.kind);
  const list = revisions ?? [];
  const picks = query.getAll("v");
  let view: View = { kind: "none" };
  let picked: string[] = [];

  if (query.get("compare") === "site") {
    if (!draft) view = { kind: "problem", message: `You have no working draft of this ${noun} in Carrel, so there is nothing to set against the site's text.` };
    else if (!doc) view = { kind: "problem", message: siteError ?? `This ${noun} is not on the site yet, so there is no site version to compare with.` };
    else {
      const before: Side = { title: "On the site", who: doc.status === "published" ? "Published" : doc.status === "scheduled" ? "Scheduled" : "Draft", time: doc.updatedAt ?? undefined, text: doc.source };
      const after: Side = { title: "Your working draft", who: viewer.name, time: draft.updatedAt, text: draft.source };
      view = sides("Your working draft against the site's version", "Your autosaved copy in Carrel, set against what the site holds now.", itemId, before, after, by);
    }
  } else if (query.get("compare") === "ai") {
    const id = Number(query.get("ai"));
    if (!Number.isInteger(id) || id <= 0) {
      view = { kind: "problem", message: `That AI draft is not one of yours for this ${noun}.` };
    } else {
      const ai = await readAiDraft(env.DB, project, viewer, itemId, id);
      const mine: Side = draft
        ? { title: "Your working draft", who: viewer.name, time: draft.updatedAt, text: draft.source }
        : { title: "On the site", time: doc?.updatedAt ?? undefined, text: doc?.source ?? "" };
      const theirs: Side = { title: `AI draft from ${ai.client}`, who: ai.client, time: ai.createdAt, text: ai.source };
      view = sides(
        `Your ${draft ? "draft" : noun} against the AI draft from ${ai.client}`,
        draft ? "Your autosaved copy in Carrel, set against the AI draft saved beside it." : "You have no working draft, so this is the site's text against the AI draft saved beside it.",
        itemId,
        mine,
        theirs,
        by,
      );
    }
  } else if (siteError) {
    view = { kind: "none" };
  } else if (query.get("version")) {
    const version = query.get("version")!;
    const at = list.findIndex((r) => r.version === version);
    if (at < 0) {
      view = { kind: "problem", message: `The site has no such revision of this ${noun}.` };
    } else {
      const revision = list[at]!;
      const source = await readRevision(env, project, itemId, version);
      view =
        source === null
          ? { kind: "problem", message: "The site could not open that revision's source. A site on an older version of the site API cannot." }
          : { kind: "source", version, message: revision.message, author: revision.author, at: revision.at, source, current: at === 0, previous: list[at + 1]?.version ?? null, newest: at === 0 ? null : list[0]!.version };
    }
  } else if (picks.length > 0) {
    const pair = orderPair(list, picks);
    if (!pair) {
      view = { kind: "problem", message: "Pick two different revisions to compare." };
      picked = picks;
    } else {
      picked = [pair.from.version, pair.to.version];
      const heading = `${utc(pair.from.at)} to ${utc(pair.to.at)}`;
      const lead = `What changed on the site between the revision by ${pair.from.author} and the one by ${pair.to.author}.`;
      if (by === "line") {
        const patch = await revisionPatch(env, project, itemId, pair.from.version, pair.to.version);
        view = patch === null ? { kind: "problem", message: "The site could not give the changes between those revisions." } : { kind: "compare", heading, lead, patch, before: null, after: null };
      } else {
        const [before, after] = await Promise.all([readRevision(env, project, itemId, pair.from.version), readRevision(env, project, itemId, pair.to.version)]);
        view =
          before === null || after === null
            ? { kind: "problem", message: "The site could not open one of those revisions' source. A site on an older version of the site API cannot." }
            : {
                kind: "compare",
                heading,
                lead,
                patch: null,
                before: { title: "Earlier revision", who: pair.from.author, time: pair.from.at, text: before },
                after: { title: "Later revision", who: pair.to.author, time: pair.to.at, text: after },
              };
      }
    }
  }

  return {
    project: { slug: project.slug, name: project.name },
    itemId,
    title: doc?.title ?? "",
    noun,
    by,
    view,
    picked,
    revisions: list.map((r) => ({ version: r.version, at: r.at, author: r.author, message: r.message })),
    siteError,
    againstSite: Boolean(draft && doc),
    aiDrafts: aiDrafts.map((d) => ({ id: d.id, client: d.client, createdAt: d.createdAt })),
  };
}

/** Both texts are in Carrel: the patch is built here, and the same two texts feed the word view. */
function sides(heading: string, lead: string, itemId: string, before: Side, after: Side, by: "line" | "word"): View {
  if (by === "word") return { kind: "compare", heading, lead, patch: null, before, after };
  const patch = unifiedPatch(`${itemId}.md`, { text: before.text, label: before.title }, { text: after.text, label: after.title });
  return { kind: "compare", heading, lead, patch, before: null, after: null };
}

export default function History({ loaderData }: Route.ComponentProps) {
  const { project, itemId, title, noun, by, view, picked, revisions, siteError, aiDrafts, againstSite } = loaderData;
  const editor = `/p/${project.slug}/e/${encodeURIComponent(itemId)}`;
  const historyPath = `${editor}/history`;
  const navigate = useNavigate();
  const location = useLocation();
  // The switch moves at once, while the other view loads; the loader's answer confirms it.
  const pending = useNavigation().location;
  const shown = pending ? (new URLSearchParams(pending.search).get("by") === "word" ? "word" : "line") : by;
  const setBy = (next: "line" | "word") => {
    const params = new URLSearchParams(location.search);
    params.set("by", next);
    void navigate(`${location.pathname}?${params.toString()}`, { preventScrollReset: true });
  };

  return (
    <div className="app-page">
      <PageHead
        renderLink={RouterLink}
        crumbs={[{ label: project.name, href: `/p/${project.slug}` }, { label: title || itemId, href: editor }, { label: "History" }]}
        title={`History of ${title || itemId}`}
        lead={`What the site remembers of this ${noun}, newest first. Looking here changes nothing.`}
        actions={
          <Link to={editor} className="cap-btn">
            Back to the editor
          </Link>
        }
      />

      {siteError ? <Banner tone="warn">{siteError}</Banner> : null}

      {view.kind === "problem" ? <Alert tone="warn">{view.message}</Alert> : null}

      {view.kind === "source" ? (
        <Panel
          title={view.message || "Revision"}
          src={
            <>
              by {view.author}, <Time at={view.at} format="exact" />
            </>
          }
          actions={
            <>
              {view.current ? <Pill tone="ok">Current</Pill> : null}
              {view.previous ? (
                <Link className="cap-btn" to={`${historyPath}?v=${encodeURIComponent(view.previous)}&v=${encodeURIComponent(view.version)}`}>
                  Changes in this revision
                </Link>
              ) : null}
              {view.newest ? (
                <Link className="cap-btn" to={`${historyPath}?v=${encodeURIComponent(view.version)}&v=${encodeURIComponent(view.newest)}`}>
                  Compare with the current version
                </Link>
              ) : null}
            </>
          }
        >
          <pre className="app-ai-draft" aria-label="Source of this revision">
            {view.source}
          </pre>
        </Panel>
      ) : null}

      {view.kind === "compare" ? (
        <Panel
          title={view.heading}
          description={view.lead}
          actions={<Segmented legend="Compare by" hideLegend size="sm" value={shown} onChange={setBy} options={[{ value: "line", label: "By line" }, { value: "word", label: "By word" }]} />}
        >
          {view.patch !== null ? <DraftPatch patch={view.patch} title={view.heading} id="history-patch" /> : view.before && view.after ? <DraftCompare before={view.before} after={view.after} id="history-compare" /> : null}
        </Panel>
      ) : null}

      {againstSite || aiDrafts.length > 0 ? (
        <Panel title="Your draft" src="Your autosaved copy in Carrel">
          <DraftLinks historyPath={historyPath} againstSite={againstSite} aiDrafts={aiDrafts} />
        </Panel>
      ) : null}

      <Panel title="Revisions" count={revisions.length} src="Saves and publishes on the site">
        {revisions.length === 0 ? (
          <Empty kind="nothing-yet" title="No history to show">
            {siteError ? "The site is not answering." : `The site has no saved revisions of this ${noun} yet.`}
          </Empty>
        ) : (
          <RevisionPicker revisions={revisions} historyPath={historyPath} picked={picked} label={`Revisions of this ${noun}`} />
        )}
      </Panel>
    </div>
  );
}