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.

151 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> = {
37 created: "Created",
38 edit: "Edited",
39 agent: "Edited by an agent",
40 suggestion: "Suggestion accepted",
41 proposal: "Proposal applied",
42 restore: "Restored",
43};
44
45function DiffView({ lines }: { lines: DocDiffLine[] }) {
46 if (!lines.length) return <p className="p-4 text-xs text-faint">No changes in the text.</p>;
47 return (
48 <div className="font-mono text-[0.75rem] leading-relaxed">
49 {lines.map((l, i) => (
50 <div
51 key={i}
52 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"}`}
53 >
54 <span className="w-3 shrink-0 text-faint select-none">{l.op === "add" ? "+" : l.op === "del" ? "−" : " "}</span>
55 <span className="min-w-0">{l.text || " "}</span>
56 </div>
57 ))}
58 </div>
59 );
60}
61
62/** An artifact's history: every version, who made it, what changed against the one before, and restoring one (a new version; nothing is lost). */
63export default function FolioHistory({ loaderData, params }: Route.ComponentProps) {
64 const slug = params.owner.toLowerCase();
65 const { folio, versions } = loaderData;
66 const navigate = useNavigate();
67 const [chosen, setChosen] = useState<string | null>(versions[0]?.id ?? null);
68 const [detail, setDetail] = useState<FolioVersionDetail | null>(null);
69 const [error, setError] = useState<string | null>(null);
70 const [restoring, setRestoring] = useState(false);
71 useEffect(() => {
72 if (!chosen) return setDetail(null);
73 setDetail(null);
74 void foliosQuery<FolioVersionDetail>(slug, { version: chosen, folio: folio.id }).then((r) => (r.ok ? setDetail(r.value) : setError(r.error.message)));
75 }, [chosen, slug, folio.id]);
76 const canRestore = canDo(folio.viewer_role, "edit") && !folio.trashed_at;
77 return (
78 <div className="mx-auto max-w-6xl">
79 <Link to={folio.path} className="inline-flex items-center gap-1.5 text-xs text-muted hover:text-fg">
80 <ArrowLeft size={13} /> Back to <FolioGlyph folio={folio} size={12} /> {folio.title || "Untitled"}
81 </Link>
82 <div className="mt-3 flex flex-wrap items-center gap-3">
83 <h1 className="flex grow items-center gap-2 text-xl font-semibold tracking-tight">
84 <History size={18} className="text-faint" /> History
85 </h1>
86 {canRestore && chosen && versions[0]?.id !== chosen && (
87 <Button
88 type="button"
89 variant="accent"
90 disabled={restoring}
91 onClick={async () => {
92 setRestoring(true);
93 const done = await foliosRequest(slug, "restore_version", { folio_id: folio.id, version_id: chosen });
94 setRestoring(false);
95 if (done.ok) navigate(folio.path);
96 else setError(done.error.message);
97 }}
98 >
99 <RotateCcw size={14} /> {restoring ? "Restoring…" : "Restore this version"}
100 </Button>
101 )}
102 </div>
103 <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>
104 {error && (
105 <div className="mt-3">
106 <ErrorText>{error}</ErrorText>
107 </div>
108 )}
109 {versions.length === 0 ? (
110 <div className="mt-6">
111 <EmptyState title="No versions yet">Versions are saved as people edit.</EmptyState>
112 </div>
113 ) : (
114 <div className="mt-6 grid gap-4 md:grid-cols-[17rem_minmax(0,1fr)]">
115 <ul className="space-y-px md:max-h-[calc(100dvh-14rem)] md:overflow-y-auto md:scrollbar-thin">
116 {versions.map((v) => (
117 <li key={v.id}>
118 <button
119 type="button"
120 onClick={() => setChosen(v.id)}
121 aria-current={chosen === v.id}
122 className={`w-full rounded-md px-2.5 py-2 text-left transition-colors ${chosen === v.id ? "bg-raised" : "hover:bg-raised/60"}`}
123 >
124 <span className="block text-xs font-medium text-fg" suppressHydrationWarning>
125 {new Date(v.created_at).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}
126 </span>
127 <span className="mt-0.5 block text-[0.6875rem] text-faint">{v.note ?? KIND_LABELS[v.kind]}</span>
128 <span className="mt-1 flex items-center gap-1">
129 {v.authors.slice(0, 4).map((a) => (
130 <Face key={`${a.kind}:${a.id}`} who={a} size={14} />
131 ))}
132 <span className="ml-1 truncate text-[0.6875rem] text-muted">{v.authors.map((a) => a.display_name).join(", ")}</span>
133 </span>
134 </button>
135 </li>
136 ))}
137 </ul>
138 <Card className="min-w-0 overflow-hidden">
139 {detail ? (
140 <div className="max-h-[calc(100dvh-14rem)] overflow-auto py-2 scrollbar-thin">
141 <DiffView lines={detail.diff} />
142 </div>
143 ) : (
144 <p className="p-4 text-xs text-faint">{chosen ? "Loading…" : "Choose a version."}</p>
145 )}
146 </Card>
147 </div>
148 )}
149 </div>
150 );
151}