@gogitcms/design-system 0.16.0-next.3 → 0.16.0-next.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gogitcms/design-system",
3
- "version": "0.16.0-next.3",
3
+ "version": "0.16.0-next.4",
4
4
  "main": "src/index.ts",
5
5
  "types": "src/index.ts",
6
6
  "// exports": "The root entry is the react-native source the SPAs, desktop app and mobile app consume. ./web is the plain-DOM build for server-rendered surfaces (the Astro docs site) that don't run react-native-web, and ./css ships the tokens as custom properties. The trailing wildcard keeps deep paths resolvable — plugin bundling and the Vite aliases reach into src/ directly.",
@@ -0,0 +1,179 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
3
+ import { ThemeProvider } from "../ThemeProvider";
4
+ import { ContentBrowser, type CmsEntry, type CmsNavSection } from "../components/ContentBrowser";
5
+ import type { DocumentVersion, HistoryApi } from "../history";
6
+ import type { DocumentChange } from "../components/ChangeDetail";
7
+
8
+ // Force the desktop layout (jsdom reports width 0 → mobile otherwise).
9
+ jest.mock("../ThemeProvider", () => {
10
+ const actual = jest.requireActual("../ThemeProvider");
11
+ return { ...actual, useResponsive: () => ({ width: 1300, height: 900, isDesktop: true, isMobile: false }) };
12
+ });
13
+
14
+ const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
15
+
16
+ const sections: CmsNavSection[] = [
17
+ { title: "Content", items: [{ key: "posts", label: "Posts", icon: "newspaper" }] },
18
+ ];
19
+
20
+ const entry: CmsEntry = {
21
+ id: "doc-1",
22
+ path: "posts/hello.md",
23
+ title: "Hello",
24
+ body: "",
25
+ fields: [{ name: "title", type: "string", value: "Hello" }],
26
+ };
27
+
28
+ const published: DocumentVersion = {
29
+ sha: "9f2c1abcdef",
30
+ shortSha: "9f2c1ab",
31
+ message: "Publish the post\n\nA body the list has no room for.",
32
+ authorName: "Ada",
33
+ authoredAt: "2026-03-01T12:00:00Z",
34
+ url: "https://github.com/acme/site/commit/9f2c1ab",
35
+ filesChanged: 3,
36
+ };
37
+
38
+ const drafted: DocumentVersion = {
39
+ sha: "3b7de1098877",
40
+ shortSha: "3b7de10",
41
+ message: "Draft the post",
42
+ authorName: "Ada",
43
+ authoredAt: "2026-02-01T12:00:00Z",
44
+ url: "https://github.com/acme/site/commit/3b7de10",
45
+ filesChanged: 1,
46
+ };
47
+
48
+ const draftedDetail: DocumentChange = {
49
+ path: "posts/hello.md",
50
+ status: "M",
51
+ label: "Hello",
52
+ added: 1,
53
+ removed: 1,
54
+ fields: [{ name: "status", kind: "changed", before: "draft", after: "published" }],
55
+ };
56
+
57
+ function makeApi(over: Partial<HistoryApi> = {}): HistoryApi {
58
+ return {
59
+ list: jest.fn(async () => [published, drafted]),
60
+ get: jest.fn(async () => draftedDetail),
61
+ ...over,
62
+ };
63
+ }
64
+
65
+ function renderBrowser(history?: HistoryApi) {
66
+ render(
67
+ wrap(
68
+ <ContentBrowser
69
+ workspace={{ name: "acme/site", initials: "AC", branch: "main", changed: 0 }}
70
+ sections={sections}
71
+ activeNavKey="posts"
72
+ onSelectNav={jest.fn()}
73
+ entries={[entry]}
74
+ selectedEntryId={entry.id}
75
+ onSelectEntry={jest.fn()}
76
+ userInitials="ED"
77
+ history={history}
78
+ />,
79
+ ),
80
+ );
81
+ }
82
+
83
+ // The affordance only exists where history does. A deployment with no provider
84
+ // should show no button at all rather than one that errors when pressed.
85
+ test("no history seam means no history button", () => {
86
+ renderBrowser(undefined);
87
+ expect(screen.queryByTestId("document-history-toggle")).not.toBeInTheDocument();
88
+ });
89
+
90
+ test("the history button gives the document's column over to the version browser", async () => {
91
+ renderBrowser(makeApi());
92
+
93
+ // The editor form is what a column shows until history is asked for.
94
+ expect(screen.getByTestId("column-scroll")).toBeInTheDocument();
95
+ expect(screen.queryByTestId("document-history")).not.toBeInTheDocument();
96
+
97
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
98
+
99
+ expect(await screen.findByTestId("document-history")).toBeInTheDocument();
100
+ // The form is gone — history takes the column's body, not a slice of it.
101
+ expect(screen.queryByTestId("column-scroll")).not.toBeInTheDocument();
102
+ });
103
+
104
+ test("the version list shows each commit's subject, author, sha and file count", async () => {
105
+ renderBrowser(makeApi());
106
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
107
+
108
+ expect(await screen.findByTestId("version-9f2c1ab")).toBeInTheDocument();
109
+ expect(screen.getByTestId("version-3b7de10")).toBeInTheDocument();
110
+
111
+ // The subject only — the message body would push every other row off screen.
112
+ expect(screen.getByText("Publish the post")).toBeInTheDocument();
113
+ expect(screen.queryByText(/A body the list has no room for/)).not.toBeInTheDocument();
114
+
115
+ expect(screen.getByText("3 files")).toBeInTheDocument();
116
+ // Singular, because "1 files" is the kind of thing people notice.
117
+ expect(screen.getByText("1 file")).toBeInTheDocument();
118
+ });
119
+
120
+ // An empty diff beside a full list is a second click for nothing.
121
+ test("opening history selects the newest version and loads its diff", async () => {
122
+ const api = makeApi();
123
+ renderBrowser(api);
124
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
125
+
126
+ await waitFor(() => expect(api.get).toHaveBeenCalledWith({ documentId: "doc-1", sha: published.sha }));
127
+ expect(await screen.findByTestId("change-detail")).toBeInTheDocument();
128
+ });
129
+
130
+ test("selecting a version loads that version's document and diff", async () => {
131
+ const api = makeApi();
132
+ renderBrowser(api);
133
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
134
+
135
+ fireEvent.click(await screen.findByTestId("version-3b7de10"));
136
+
137
+ await waitFor(() => expect(api.get).toHaveBeenCalledWith({ documentId: "doc-1", sha: drafted.sha }));
138
+ // The version's value on the left of the diff, the current one on the right.
139
+ expect(await screen.findByText("draft")).toBeInTheDocument();
140
+ expect(screen.getByText("published")).toBeInTheDocument();
141
+ });
142
+
143
+ // A document with no commits is a real and common state — one created in the
144
+ // CMS and not yet exported. It must not read as a failure.
145
+ test("a document with no commits explains itself", async () => {
146
+ renderBrowser(makeApi({ list: jest.fn(async () => []) }));
147
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
148
+
149
+ expect(await screen.findByTestId("history-empty")).toBeInTheDocument();
150
+ expect(screen.queryByTestId("history-error")).not.toBeInTheDocument();
151
+ });
152
+
153
+ test("a failed history says so rather than showing an empty list", async () => {
154
+ renderBrowser(makeApi({ list: jest.fn(async () => { throw new Error("GitHub is unreachable"); }) }));
155
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
156
+
157
+ expect(await screen.findByText("GitHub is unreachable")).toBeInTheDocument();
158
+ expect(screen.queryByTestId("history-empty")).not.toBeInTheDocument();
159
+ });
160
+
161
+ test("the button toggles back, returning the column to the editor", async () => {
162
+ renderBrowser(makeApi());
163
+ const toggle = screen.getByTestId("document-history-toggle");
164
+
165
+ fireEvent.click(toggle);
166
+ expect(await screen.findByTestId("document-history")).toBeInTheDocument();
167
+
168
+ fireEvent.click(toggle);
169
+ expect(screen.queryByTestId("document-history")).not.toBeInTheDocument();
170
+ expect(screen.getByTestId("column-scroll")).toBeInTheDocument();
171
+ });
172
+
173
+ test("the pane's own close control leaves history too", async () => {
174
+ renderBrowser(makeApi());
175
+ fireEvent.click(screen.getByTestId("document-history-toggle"));
176
+
177
+ fireEvent.click(await screen.findByTestId("history-close"));
178
+ expect(screen.queryByTestId("document-history")).not.toBeInTheDocument();
179
+ });
@@ -38,7 +38,15 @@ export type DocumentChange = {
38
38
  path: string;
39
39
  /** The base branch's path, for a rename. */
40
40
  previousPath?: string;
41
- status: "A" | "M" | "D" | "R";
41
+ /**
42
+ * A/M/D/R are git's letters, so a branch comparison renders them untranslated.
43
+ * "U" (unchanged) has no place in a diff of two branches — an unchanged
44
+ * document is not a change — but it is a real answer for a document version:
45
+ * the newest commit in a document's history is usually still what the document
46
+ * is, and saying "Unchanged" is more useful than an empty diff with no
47
+ * explanation.
48
+ */
49
+ status: "A" | "M" | "D" | "R" | "U";
42
50
  label: string;
43
51
  /** Ordered to match the content model's schema, so it reads like the editor. */
44
52
  fields: FieldChange[];
@@ -86,6 +94,7 @@ const STATUS_LABEL: Record<DocumentChange["status"], string> = {
86
94
  M: "Modified",
87
95
  D: "Deleted",
88
96
  R: "Renamed",
97
+ U: "Unchanged",
89
98
  };
90
99
 
91
100
  /** Renders a field value the way the editor would show it, not as raw JSON. */
@@ -34,6 +34,8 @@ import { MediaField, MediaProvider, DocumentPathProvider } from "./MediaField";
34
34
  import { MediaBrowser } from "./MediaBrowser";
35
35
  import { FormsBrowser } from "./FormsBrowser";
36
36
  import { FORMS_NAV_KEY, formsNavKey, isFormsNavKey, parseFormsNavKey, type FormsApi } from "../forms";
37
+ import { type HistoryApi } from "../history";
38
+ import { DocumentHistory } from "./DocumentHistory";
37
39
  import {
38
40
  MEDIA_NAV_KEY,
39
41
  mediaNavKey,
@@ -340,6 +342,14 @@ export type ContentBrowserProps = {
340
342
  // Forms data seam, the same split (docs/forms.md §10.2). Omitted → the Forms
341
343
  // surface never renders, which is what a config declaring no forms looks like.
342
344
  forms?: FormsApi;
345
+
346
+ // Document-history data seam, the same split again: the DS owns the version
347
+ // browser and its ephemeral state, the host owns fetching. Provided → an open
348
+ // document's header gains a history button, and pressing it gives that
349
+ // column's body over to the version browser. Omitted → no affordance at all,
350
+ // which is what a deployment with no provider (local mode, desktop) gets: a
351
+ // feature the user never sees beats one that errors when clicked.
352
+ history?: HistoryApi;
343
353
  // The selected submission's id, and the reporter for taps — bound to the URL
344
354
  // by the host exactly as document selection is.
345
355
  selectedSubmissionId?: string | null;
@@ -2581,6 +2591,7 @@ function EntryDetail({
2581
2591
  onMove,
2582
2592
  collectionPath,
2583
2593
  folderOptions,
2594
+ history,
2584
2595
  active,
2585
2596
  onActivate,
2586
2597
  onCollapse,
@@ -2603,6 +2614,10 @@ function EntryDetail({
2603
2614
  collectionPath?: string;
2604
2615
  // Existing folders (relative to the glob base) offered by the move autocomplete.
2605
2616
  folderOptions?: string[];
2617
+ // The document-history seam. Provided → a history button appears in this
2618
+ // document's header and its column can show the version browser. Omitted →
2619
+ // the header renders exactly as it did before history existed.
2620
+ history?: HistoryApi;
2606
2621
  // Desktop columns: `active` marks the focused column (accent + header press
2607
2622
  // activates it via onActivate); onCollapse/onClose add the header rail/close
2608
2623
  // controls. All omitted on mobile → unchanged single-detail behavior.
@@ -2623,6 +2638,18 @@ function EntryDetail({
2623
2638
  const canRename = !readOnly && !!onRename;
2624
2639
  const canMove = !readOnly && !!onMove && glob.hierarchical;
2625
2640
  const [prompt, setPrompt] = useState<"rename" | "move" | null>(null);
2641
+ // Whether this column is showing the version browser instead of the form.
2642
+ // Deliberately per-column and ephemeral: history is something you open, read
2643
+ // and close, so it lives and dies with the column rather than in the URL.
2644
+ const [showHistory, setShowHistory] = useState(false);
2645
+ // A column reused for another document (the "preview tab" behavior) must not
2646
+ // carry the previous document's history view onto it — you asked to see that
2647
+ // document, not this one's past.
2648
+ const historyFor = useRef(entry.id);
2649
+ if (historyFor.current !== entry.id) {
2650
+ historyFor.current = entry.id;
2651
+ if (showHistory) setShowHistory(false);
2652
+ }
2626
2653
 
2627
2654
  // Both branches share one value map. The title+body branch is modeled as a
2628
2655
  // single body-source field so autosave logic is uniform.
@@ -2908,6 +2935,20 @@ function EntryDetail({
2908
2935
  document's own actions, while the menu holds the destructive and
2909
2936
  path-changing ones that should stay one level down. */}
2910
2937
  {documentActions?.(entry)}
2938
+ {/* History is a read of this document, not a change to it, so it sits
2939
+ out here with the other non-destructive actions rather than in the
2940
+ menu beside Delete. It toggles: pressing it again returns the column
2941
+ to the form. */}
2942
+ {history ? (
2943
+ <IconButton
2944
+ name="history"
2945
+ size="sm"
2946
+ active={showHistory}
2947
+ label={showHistory ? "Close history" : "Document history"}
2948
+ onPress={() => setShowHistory((v) => !v)}
2949
+ testID="document-history-toggle"
2950
+ />
2951
+ ) : null}
2911
2952
  {canRename || canMove || (canDelete && onDeleteEntry) ? (
2912
2953
  <DetailMenu
2913
2954
  onRename={canRename ? () => setPrompt("rename") : undefined}
@@ -2923,10 +2964,20 @@ function EntryDetail({
2923
2964
  ) : null}
2924
2965
  </View>
2925
2966
 
2926
- {/* Keyed by entry.id so controls (incl. the markdown editor) remount with
2967
+ {/* History takes over the column's body, keeping its header: the header
2968
+ is what says which document you are looking at, and it carries the
2969
+ control that got you here and gets you back. */}
2970
+ {showHistory && history ? (
2971
+ <DocumentHistory
2972
+ documentId={entry.id}
2973
+ documentPath={entry.path}
2974
+ history={history}
2975
+ onClose={() => setShowHistory(false)}
2976
+ />
2977
+ ) : /* Keyed by entry.id so controls (incl. the markdown editor) remount with
2927
2978
  fresh initial values (and the scroll resets to top) when the selected
2928
- document changes. The header above stays put; only the body scrolls. */}
2929
- {hasFields ? (
2979
+ document changes. The header above stays put; only the body scrolls. */
2980
+ hasFields ? (
2930
2981
  // The document's path reaches media fields at any nesting depth through
2931
2982
  // context: a picker inside a group or a list item needs it to interpret
2932
2983
  // relative references, and drilling it through every level would touch
@@ -3970,6 +4021,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
3970
4021
  <EntryDetail
3971
4022
  entry={col.entry}
3972
4023
  documentActions={props.documentActions}
4024
+ history={props.history}
3973
4025
  onEntryDraft={props.onEntryDraft}
3974
4026
  renderField={renderField}
3975
4027
  onSaveEntry={props.onSaveEntry}
@@ -4845,6 +4897,7 @@ function MobileBrowser(props: ContentBrowserProps) {
4845
4897
  <EntryDetail
4846
4898
  entry={entry}
4847
4899
  documentActions={props.documentActions}
4900
+ history={props.history}
4848
4901
  onEntryDraft={props.onEntryDraft}
4849
4902
  renderField={renderField}
4850
4903
  onSaveEntry={props.onSaveEntry}
@@ -0,0 +1,292 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from "react";
2
+ import { View, FlatList, Pressable, ActivityIndicator } from "react-native";
3
+ import { useTheme } from "../ThemeProvider";
4
+ import { Text } from "./Text";
5
+ import { Icon } from "./Icon";
6
+ import { Badge } from "./primitives";
7
+ import { ChangeDetail, type DocumentChange } from "./ChangeDetail";
8
+ import { firstLine, relativeTime, type DocumentVersion, type HistoryApi } from "../history";
9
+
10
+ /** How many versions a page holds. One page is one provider request. */
11
+ const PAGE = 20;
12
+
13
+ export type DocumentHistoryProps = {
14
+ /** The document whose past is being read. */
15
+ documentId: string;
16
+ /** Shown above the list, so a narrow column still says what it is looking at. */
17
+ documentPath: string;
18
+ history: HistoryApi;
19
+ /** Leaves history and returns the column to the editor. */
20
+ onClose: () => void;
21
+ };
22
+
23
+ /**
24
+ * DocumentHistory is the version browser that takes over a document's column:
25
+ * the versions on the right, and on the left the document as it was at the
26
+ * selected one, diffed against what it is now.
27
+ *
28
+ * It owns its own state — which version is selected, the pages loaded so far —
29
+ * because that state is ephemeral by design: history is a thing you open, read
30
+ * and close, not a place the editor navigates to. Nothing here survives closing
31
+ * the pane, and nothing about it is in the URL.
32
+ */
33
+ export function DocumentHistory({ documentId, documentPath, history, onClose }: DocumentHistoryProps) {
34
+ const t = useTheme();
35
+
36
+ const [versions, setVersions] = useState<DocumentVersion[]>([]);
37
+ const [loading, setLoading] = useState(true);
38
+ const [loadingMore, setLoadingMore] = useState(false);
39
+ const [exhausted, setExhausted] = useState(false);
40
+ const [error, setError] = useState<string | null>(null);
41
+
42
+ const [selected, setSelected] = useState<string | null>(null);
43
+ const [detail, setDetail] = useState<DocumentChange | undefined>(undefined);
44
+ const [loadingDetail, setLoadingDetail] = useState(false);
45
+ const [detailError, setDetailError] = useState<string | null>(null);
46
+
47
+ // Guards every async result against a document switch or an unmount: the pane
48
+ // is inside a column whose document can change under it, and a page that
49
+ // arrives after that would otherwise be listed as this document's history.
50
+ const liveFor = useRef(documentId);
51
+ useEffect(() => {
52
+ liveFor.current = documentId;
53
+ return () => {
54
+ liveFor.current = "";
55
+ };
56
+ }, [documentId]);
57
+
58
+ // First page. Selecting its newest version immediately is what makes the pane
59
+ // useful on open — an empty diff beside a full list is a second click for
60
+ // nothing.
61
+ useEffect(() => {
62
+ let cancelled = false;
63
+ setLoading(true);
64
+ setError(null);
65
+ setVersions([]);
66
+ setExhausted(false);
67
+ setSelected(null);
68
+ setDetail(undefined);
69
+ history
70
+ .list({ documentId, limit: PAGE, offset: 0 })
71
+ .then((page) => {
72
+ if (cancelled || liveFor.current !== documentId) return;
73
+ setVersions(page);
74
+ setExhausted(page.length < PAGE);
75
+ if (page.length > 0) setSelected(page[0].sha);
76
+ })
77
+ .catch((e: unknown) => {
78
+ if (cancelled || liveFor.current !== documentId) return;
79
+ setError(messageOf(e));
80
+ })
81
+ .finally(() => {
82
+ if (!cancelled) setLoading(false);
83
+ });
84
+ return () => {
85
+ cancelled = true;
86
+ };
87
+ }, [documentId, history]);
88
+
89
+ const loadMore = useCallback(() => {
90
+ if (loading || loadingMore || exhausted) return;
91
+ setLoadingMore(true);
92
+ const offset = versions.length;
93
+ history
94
+ .list({ documentId, limit: PAGE, offset })
95
+ .then((page) => {
96
+ if (liveFor.current !== documentId) return;
97
+ // Offsets can overlap when the branch moves under a paging read, so
98
+ // fold by sha rather than appending blindly — a repeated version would
99
+ // otherwise be a duplicate React key and a second identical row.
100
+ setVersions((prev) => mergeBySha(prev, page));
101
+ setExhausted(page.length < PAGE);
102
+ })
103
+ .catch(() => {
104
+ // A failed page is not a failed history: what is already listed stays
105
+ // readable, and the end-of-list footer stops offering more.
106
+ setExhausted(true);
107
+ })
108
+ .finally(() => setLoadingMore(false));
109
+ }, [documentId, history, loading, loadingMore, exhausted, versions.length]);
110
+
111
+ // The selected version's document + diff.
112
+ useEffect(() => {
113
+ if (!selected) {
114
+ setDetail(undefined);
115
+ return;
116
+ }
117
+ let cancelled = false;
118
+ setLoadingDetail(true);
119
+ setDetailError(null);
120
+ history
121
+ .get({ documentId, sha: selected })
122
+ .then((d) => {
123
+ if (cancelled || liveFor.current !== documentId) return;
124
+ setDetail(d ?? undefined);
125
+ if (!d) setDetailError("This version could not be read.");
126
+ })
127
+ .catch((e: unknown) => {
128
+ if (cancelled || liveFor.current !== documentId) return;
129
+ setDetail(undefined);
130
+ setDetailError(messageOf(e));
131
+ })
132
+ .finally(() => {
133
+ if (!cancelled) setLoadingDetail(false);
134
+ });
135
+ return () => {
136
+ cancelled = true;
137
+ };
138
+ }, [documentId, history, selected]);
139
+
140
+ return (
141
+ <View testID="document-history" style={{ flex: 1, minHeight: 0, flexDirection: "row" }}>
142
+ {/* Left: the document at the selected version, annotated with what has
143
+ changed since. ChangeDetail is the changes surface's pane, used
144
+ unchanged — a version diff and a branch diff are the same question
145
+ asked of different pairs. */}
146
+ <View style={{ flex: 1, minWidth: 0 }}>
147
+ {detailError && !loadingDetail ? (
148
+ <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
149
+ <Text variant="body" color="tertiary" testID="history-detail-error">{detailError}</Text>
150
+ </View>
151
+ ) : (
152
+ <ChangeDetail
153
+ change={detail}
154
+ loading={loadingDetail}
155
+ emptyMessage={loading ? "Loading history…" : "Select a version"}
156
+ />
157
+ )}
158
+ </View>
159
+
160
+ {/* Right: the versions. */}
161
+ <View
162
+ style={{
163
+ width: 264,
164
+ borderLeftWidth: 1,
165
+ borderLeftColor: t.color.borderSubtle,
166
+ backgroundColor: t.color.surfaceSunken,
167
+ minHeight: 0,
168
+ }}
169
+ >
170
+ <View
171
+ style={{
172
+ height: t.layout.topbar,
173
+ paddingHorizontal: t.space(3),
174
+ flexDirection: "row",
175
+ alignItems: "center",
176
+ gap: t.space(2),
177
+ borderBottomWidth: 1,
178
+ borderBottomColor: t.color.borderSubtle,
179
+ }}
180
+ >
181
+ <Icon name="history" size={15} color={t.color.textSecondary} />
182
+ <Text variant="monoSm" numberOfLines={1} style={{ flex: 1 }}>History</Text>
183
+ <Pressable onPress={onClose} testID="history-close" hitSlop={8} style={{ padding: t.space(1) }}>
184
+ <Icon name="x" size={15} color={t.color.textTertiary} />
185
+ </Pressable>
186
+ </View>
187
+
188
+ {loading ? (
189
+ <View style={{ padding: t.space(4), alignItems: "center" }}>
190
+ <ActivityIndicator testID="history-loading" />
191
+ </View>
192
+ ) : error ? (
193
+ <View style={{ padding: t.space(4), gap: t.space(2) }}>
194
+ <Text variant="monoSm" color={t.color.diffDelFg} testID="history-error">{error}</Text>
195
+ </View>
196
+ ) : versions.length === 0 ? (
197
+ <View style={{ padding: t.space(4) }}>
198
+ <Text variant="monoSm" color="tertiary" testID="history-empty">
199
+ No commits yet for {documentPath}. A document saved in the CMS appears here once it has been pushed to git.
200
+ </Text>
201
+ </View>
202
+ ) : (
203
+ <FlatList
204
+ testID="history-list"
205
+ data={versions}
206
+ keyExtractor={(v) => v.sha}
207
+ style={{ flex: 1, minHeight: 0 }}
208
+ onEndReached={loadMore}
209
+ onEndReachedThreshold={0.4}
210
+ renderItem={({ item }) => (
211
+ <VersionRow
212
+ version={item}
213
+ selected={item.sha === selected}
214
+ onPress={() => setSelected(item.sha)}
215
+ />
216
+ )}
217
+ ListFooterComponent={
218
+ loadingMore ? (
219
+ <View style={{ padding: t.space(3), alignItems: "center" }}>
220
+ <ActivityIndicator testID="history-loading-more" />
221
+ </View>
222
+ ) : exhausted ? (
223
+ // Why the list ends matters here: GitHub's commits API cannot
224
+ // follow a rename, so "that's all" can mean "that is where this
225
+ // path began" rather than "that is where the document began".
226
+ <View style={{ padding: t.space(3) }}>
227
+ <Text variant="monoSm" color="tertiary" testID="history-end">
228
+ End of history for this path. Commits from before a rename are not listed.
229
+ </Text>
230
+ </View>
231
+ ) : null
232
+ }
233
+ />
234
+ )}
235
+ </View>
236
+ </View>
237
+ );
238
+ }
239
+
240
+ function VersionRow({
241
+ version,
242
+ selected,
243
+ onPress,
244
+ }: {
245
+ version: DocumentVersion;
246
+ selected: boolean;
247
+ onPress: () => void;
248
+ }) {
249
+ const t = useTheme();
250
+ return (
251
+ <Pressable
252
+ testID={`version-${version.shortSha}`}
253
+ onPress={onPress}
254
+ style={{
255
+ paddingHorizontal: t.space(3),
256
+ paddingVertical: t.space(3),
257
+ gap: t.space(1),
258
+ borderBottomWidth: 1,
259
+ borderBottomColor: t.color.borderSubtle,
260
+ backgroundColor: selected ? t.color.surfaceRaised : "transparent",
261
+ borderLeftWidth: 2,
262
+ borderLeftColor: selected ? t.color.borderActive : "transparent",
263
+ }}
264
+ >
265
+ <Text variant="sm" numberOfLines={2} weight={selected ? "medium" : undefined}>
266
+ {firstLine(version.message)}
267
+ </Text>
268
+ <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
269
+ <Text variant="monoSm" color="tertiary" numberOfLines={1} style={{ flex: 1 }}>
270
+ {version.authorName} · {relativeTime(version.authoredAt)}
271
+ </Text>
272
+ </View>
273
+ <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
274
+ <Badge label={version.shortSha} tone="neutral" />
275
+ <Text variant="monoSm" color="tertiary">
276
+ {version.filesChanged === 1 ? "1 file" : `${version.filesChanged} files`}
277
+ </Text>
278
+ </View>
279
+ </Pressable>
280
+ );
281
+ }
282
+
283
+ /** mergeBySha appends a page, dropping versions already listed. */
284
+ function mergeBySha(prev: DocumentVersion[], page: DocumentVersion[]): DocumentVersion[] {
285
+ const seen = new Set(prev.map((v) => v.sha));
286
+ return [...prev, ...page.filter((v) => !seen.has(v.sha))];
287
+ }
288
+
289
+ function messageOf(e: unknown): string {
290
+ const err = e as { graphQLErrors?: { message?: string }[]; message?: string } | undefined;
291
+ return err?.graphQLErrors?.[0]?.message || err?.message || "Couldn’t load this document’s history.";
292
+ }
package/src/history.ts ADDED
@@ -0,0 +1,82 @@
1
+ import type { DocumentChange } from "./components/ChangeDetail";
2
+
3
+ /**
4
+ * One commit in a document's history — a row in the version list.
5
+ *
6
+ * This is the provider's history for the document's CURRENT path. GitHub's
7
+ * commits API has no equivalent of `git log --follow`, so a renamed document's
8
+ * history stops at the rename; the list says so at its end rather than letting
9
+ * the absence read as "this is where the document began".
10
+ */
11
+ export type DocumentVersion = {
12
+ sha: string;
13
+ /** First 7 characters — an identity without the noise. */
14
+ shortSha: string;
15
+ /** The full commit message; the list shows its first line. */
16
+ message: string;
17
+ authorName: string;
18
+ authorEmail?: string | null;
19
+ /** ISO 8601, in UTC. */
20
+ authoredAt: string;
21
+ /** The commit's page on the provider. */
22
+ url: string;
23
+ /** How many files the commit touched — this document is one of them. */
24
+ filesChanged: number;
25
+ };
26
+
27
+ /**
28
+ * HistoryApi is the data seam between the design system and its host, the same
29
+ * split MediaApi and FormsApi use: the DS owns the version browser's UI and its
30
+ * (ephemeral) open/selected state, the host owns fetching. Omitting it from
31
+ * ContentBrowser hides the history affordance entirely, which is what a
32
+ * deployment with no provider — local mode, the desktop app — should look like.
33
+ */
34
+ export interface HistoryApi {
35
+ /**
36
+ * One page of a document's versions, newest first. The DS pages as the list
37
+ * scrolls; a short page (fewer than `limit`) ends it.
38
+ */
39
+ list: (params: { documentId: string; limit: number; offset: number }) => Promise<DocumentVersion[]>;
40
+ /**
41
+ * The document as it was at one commit, diffed against what it is now:
42
+ * `before` on each field is the value at that version, `after` its current
43
+ * value. Unchanged fields are included, which is what makes the result a
44
+ * readable preview of the whole document rather than a list of differences.
45
+ *
46
+ * Null when the version can't be read (a commit that has gone away under a
47
+ * force push, say) — the pane says so instead of showing an empty document.
48
+ */
49
+ get: (params: { documentId: string; sha: string }) => Promise<DocumentChange | null>;
50
+ }
51
+
52
+ /**
53
+ * firstLine is the summary a version row shows. A commit message's first line is
54
+ * its subject by convention, and the body below it is prose that would push
55
+ * every other row off the screen.
56
+ */
57
+ export function firstLine(message: string): string {
58
+ const line = message.split("\n", 1)[0].trim();
59
+ return line || "(no commit message)";
60
+ }
61
+
62
+ /**
63
+ * relativeTime renders a commit's age the way a history reads — "3d ago" —
64
+ * falling back to the date once "ago" stops being informative.
65
+ *
66
+ * `now` is injectable so a test can assert a fixed string rather than race the
67
+ * clock.
68
+ */
69
+ export function relativeTime(iso: string, now: Date = new Date()): string {
70
+ const then = new Date(iso);
71
+ const seconds = Math.round((now.getTime() - then.getTime()) / 1000);
72
+ if (!Number.isFinite(seconds)) return "";
73
+ if (seconds < 60) return "just now";
74
+ const minutes = Math.round(seconds / 60);
75
+ if (minutes < 60) return `${minutes}m ago`;
76
+ const hours = Math.round(minutes / 60);
77
+ if (hours < 24) return `${hours}h ago`;
78
+ const days = Math.round(hours / 24);
79
+ if (days < 30) return `${days}d ago`;
80
+ // Past a month, "5 weeks ago" is harder to place than the date itself.
81
+ return then.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
82
+ }
package/src/index.ts CHANGED
@@ -66,6 +66,10 @@ export type {
66
66
 
67
67
  // Changes surface: one document's diff between a branch and its base
68
68
  export { ChangeDetail } from "./components/ChangeDetail";
69
+ export { DocumentHistory } from "./components/DocumentHistory";
70
+ export type { DocumentHistoryProps } from "./components/DocumentHistory";
71
+ export { firstLine, relativeTime } from "./history";
72
+ export type { DocumentVersion, HistoryApi } from "./history";
69
73
  export type {
70
74
  ChangeDetailProps,
71
75
  DocumentChange,