Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

136 linesCodeBlameRaw
1import { folioIdFrom, type DocDiffLine, type Folio, type FolioVersion, type FolioVersionDetail } from "@g1t/contracts";
2import { ArrowLeft, History, RotateCcw } from "lucide-react";
3import { useEffect, useState } from "react";
4import { Link, data, redirect, useNavigate } from "react-router";
5
6import type { Route } from "./+types/history";
7import { foliosQuery, foliosRequest } from "../../../components/folios/actions";
8import { FolioGlyph } from "../../../components/folios/kinds";
9import { Face } from "../../../components/folios/parts";
10import { EmptyState, ErrorText } from "../../../components/ui";
11import { Button } from "../../../components/ui/button";
12import { Card } from "../../../components/ui/card";
13import { canDo } from "../../../lib/folios";
14import { page } from "../../../lib/meta";
15import { folios } from "../../../lib/services.server";
16import { requireUser, roleIn } from "../../../lib/session.server";
17
18export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
19 return page(args, { title: `History · ${loaded?.folio.title || "Untitled"} · ${params.owner} · g1t` });
20}
21
22export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ folio: Folio; versions: FolioVersion[] }> {
23 const viewer = requireUser(context, request);
24 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
25 const slug = params.owner.toLowerCase();
26 const id = folioIdFrom(params.folio);
27 if (!id) throw data(null, { status: 404 });
28 const [folio, versions] = await Promise.all([folios.folio(slug, viewer, id), folios.versions(slug, viewer, id)]);
29 if (!folio.ok || !versions.ok) throw data(null, { status: 404 });
30 const url = new URL(request.url);
31 // A navigation in the app asks for this page's data with `.data` on the end: the same address.
32 if (url.pathname.replace(/\.data$/, "") !== `${folio.value.path}/history`) throw redirect(`${folio.value.path}/history`);
33 return { folio: folio.value, versions: versions.value };
34}
35
36const KIND_LABELS: Record<FolioVersion["kind"], string> = { created: "Created", edit: "Edited", agent: "Edited by an agent", suggestion: "Suggestion accepted", proposal: "Proposal applied", restore: "Restored" };
37
38function DiffView({ lines }: { lines: DocDiffLine[] }) {
39 if (!lines.length) return <p className="p-4 text-xs text-faint">No changes in the text.</p>;
40 return (
41 <div className="font-mono text-[0.75rem] leading-relaxed">
42 {lines.map((l, i) => (
43 <div key={i} className={`flex gap-2 px-3 whitespace-pre-wrap ${l.op === "add" ? "bg-success/10 text-fg" : l.op === "del" ? "bg-danger/10 text-fg-soft line-through decoration-danger/50" : "text-muted"}`}>
44 <span className="w-3 shrink-0 text-faint select-none">{l.op === "add" ? "+" : l.op === "del" ? "−" : " "}</span>
45 <span className="min-w-0">{l.text || " "}</span>
46 </div>
47 ))}
48 </div>
49 );
50}
51
52/** An artifact's history: every version, who made it, what changed against the one before, and restoring one (a new version; nothing is lost). */
53export default function FolioHistory({ loaderData, params }: Route.ComponentProps) {
54 const slug = params.owner.toLowerCase();
55 const { folio, versions } = loaderData;
56 const navigate = useNavigate();
57 const [chosen, setChosen] = useState<string | null>(versions[0]?.id ?? null);
58 const [detail, setDetail] = useState<FolioVersionDetail | null>(null);
59 const [error, setError] = useState<string | null>(null);
60 const [restoring, setRestoring] = useState(false);
61 useEffect(() => {
62 if (!chosen) return setDetail(null);
63 setDetail(null);
64 void foliosQuery<FolioVersionDetail>(slug, { version: chosen, folio: folio.id }).then((r) => (r.ok ? setDetail(r.value) : setError(r.error.message)));
65 }, [chosen, slug, folio.id]);
66 const canRestore = canDo(folio.viewer_role, "edit") && !folio.trashed_at;
67 return (
68 <div className="mx-auto max-w-6xl">
69 <Link to={folio.path} className="inline-flex items-center gap-1.5 text-xs text-muted hover:text-fg">
70 <ArrowLeft size={13} /> Back to <FolioGlyph folio={folio} size={12} /> {folio.title || "Untitled"}
71 </Link>
72 <div className="mt-3 flex flex-wrap items-center gap-3">
73 <h1 className="flex grow items-center gap-2 text-xl font-semibold tracking-tight">
74 <History size={18} className="text-faint" /> History
75 </h1>
76 {canRestore && chosen && versions[0]?.id !== chosen && (
77 <Button
78 type="button"
79 variant="accent"
80 disabled={restoring}
81 onClick={async () => {
82 setRestoring(true);
83 const done = await foliosRequest(slug, "restore_version", { folio_id: folio.id, version_id: chosen });
84 setRestoring(false);
85 if (done.ok) navigate(folio.path);
86 else setError(done.error.message);
87 }}
88 >
89 <RotateCcw size={14} /> {restoring ? "Restoring…" : "Restore this version"}
90 </Button>
91 )}
92 </div>
93 <p className="mt-1 text-sm text-muted">Every version, who made it, and what changed. Restoring makes a new version; nothing is lost.</p>
94 {error && (
95 <div className="mt-3">
96 <ErrorText>{error}</ErrorText>
97 </div>
98 )}
99 {versions.length === 0 ? (
100 <div className="mt-6">
101 <EmptyState title="No versions yet">Versions are saved as people edit.</EmptyState>
102 </div>
103 ) : (
104 <div className="mt-6 grid gap-4 md:grid-cols-[17rem_minmax(0,1fr)]">
105 <ul className="space-y-px md:max-h-[calc(100dvh-14rem)] md:overflow-y-auto md:[scrollbar-width:thin]">
106 {versions.map((v) => (
107 <li key={v.id}>
108 <button type="button" onClick={() => setChosen(v.id)} aria-current={chosen === v.id} className={`w-full rounded-md px-2.5 py-2 text-left transition-colors ${chosen === v.id ? "bg-raised" : "hover:bg-raised/60"}`}>
109 <span className="block text-xs font-medium text-fg" suppressHydrationWarning>
110 {new Date(v.created_at).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}
111 </span>
112 <span className="mt-0.5 block text-[0.6875rem] text-faint">{v.note ?? KIND_LABELS[v.kind]}</span>
113 <span className="mt-1 flex items-center gap-1">
114 {v.authors.slice(0, 4).map((a) => (
115 <Face key={`${a.kind}:${a.id}`} who={a} size={14} />
116 ))}
117 <span className="ml-1 truncate text-[0.6875rem] text-muted">{v.authors.map((a) => a.display_name).join(", ")}</span>
118 </span>
119 </button>
120 </li>
121 ))}
122 </ul>
123 <Card className="min-w-0 overflow-hidden">
124 {detail ? (
125 <div className="max-h-[calc(100dvh-14rem)] overflow-auto py-2 [scrollbar-width:thin]">
126 <DiffView lines={detail.diff} />
127 </div>
128 ) : (
129 <p className="p-4 text-xs text-faint">{chosen ? "Loading…" : "Choose a version."}</p>
130 )}
131 </Card>
132 </div>
133 )}
134 </div>
135 );
136}