@gogitcms/design-system 0.16.0-next.2 → 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 +1 -1
- package/src/__tests__/ContentBrowser.history.test.tsx +179 -0
- package/src/__tests__/ProtectedBranchModal.test.tsx +68 -0
- package/src/components/ChangeDetail.tsx +10 -1
- package/src/components/ContentBrowser.tsx +103 -17
- package/src/components/DocumentHistory.tsx +292 -0
- package/src/components/ProtectedBranchModal.tsx +136 -0
- package/src/history.ts +82 -0
- package/src/index.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gogitcms/design-system",
|
|
3
|
-
"version": "0.16.0-next.
|
|
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
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
3
|
+
import { ThemeProvider } from "../ThemeProvider";
|
|
4
|
+
import { ProtectedBranchModal } from "../components/ProtectedBranchModal";
|
|
5
|
+
|
|
6
|
+
const wrap = (ui: React.ReactElement) => <ThemeProvider>{ui}</ThemeProvider>;
|
|
7
|
+
|
|
8
|
+
const base = { branch: "main", onCreate: () => {}, onCancel: () => {} };
|
|
9
|
+
|
|
10
|
+
test("it names the branch, the document, and offers the one remedy", () => {
|
|
11
|
+
render(wrap(<ProtectedBranchModal {...base} documentLabel="Hello world" />));
|
|
12
|
+
|
|
13
|
+
const modal = screen.getByTestId("protected-branch-modal");
|
|
14
|
+
expect(modal).toHaveTextContent("This branch is protected");
|
|
15
|
+
expect(modal).toHaveTextContent("main");
|
|
16
|
+
expect(modal).toHaveTextContent("Hello world");
|
|
17
|
+
expect(modal).toHaveTextContent("Branched from main");
|
|
18
|
+
expect(screen.getByTestId("protected-branch-submit")).toHaveTextContent("Create branch and save");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("the suggested name prefills the field and submits", () => {
|
|
22
|
+
const onCreate = jest.fn();
|
|
23
|
+
render(wrap(<ProtectedBranchModal {...base} suggestedName="edit/hello" onCreate={onCreate} />));
|
|
24
|
+
|
|
25
|
+
expect(screen.getByTestId("protected-branch-name")).toHaveValue("edit/hello");
|
|
26
|
+
fireEvent.click(screen.getByTestId("protected-branch-submit"));
|
|
27
|
+
expect(onCreate).toHaveBeenCalledWith("edit/hello");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// The author's own name wins: a suggestion arriving late (the host resolves the
|
|
31
|
+
// document a render after the modal opens) must not overwrite what they typed.
|
|
32
|
+
test("a late suggestion does not overwrite a typed name", () => {
|
|
33
|
+
const { rerender } = render(wrap(<ProtectedBranchModal {...base} />));
|
|
34
|
+
fireEvent.change(screen.getByTestId("protected-branch-name"), { target: { value: "mine" } });
|
|
35
|
+
rerender(wrap(<ProtectedBranchModal {...base} suggestedName="edit/hello" />));
|
|
36
|
+
expect(screen.getByTestId("protected-branch-name")).toHaveValue("mine");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("an empty name cannot be submitted", () => {
|
|
40
|
+
const onCreate = jest.fn();
|
|
41
|
+
render(wrap(<ProtectedBranchModal {...base} onCreate={onCreate} />));
|
|
42
|
+
fireEvent.click(screen.getByTestId("protected-branch-submit"));
|
|
43
|
+
expect(onCreate).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// While the branch is being created there is no way out but forward: cancelling
|
|
47
|
+
// mid-flight would abandon a save whose branch may already exist.
|
|
48
|
+
test("busy disables both actions and hides the close control", () => {
|
|
49
|
+
const onCreate = jest.fn();
|
|
50
|
+
render(wrap(<ProtectedBranchModal {...base} suggestedName="edit/hello" busy onCreate={onCreate} />));
|
|
51
|
+
|
|
52
|
+
expect(screen.getByTestId("protected-branch-submit")).toHaveTextContent("Creating…");
|
|
53
|
+
expect(screen.queryByTestId("protected-branch-close")).not.toBeInTheDocument();
|
|
54
|
+
fireEvent.click(screen.getByTestId("protected-branch-submit"));
|
|
55
|
+
expect(onCreate).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("the server's message is shown so a rejected name can be corrected", () => {
|
|
59
|
+
render(wrap(<ProtectedBranchModal {...base} error={`branch "edit/hello" already exists`} />));
|
|
60
|
+
expect(screen.getByTestId("protected-branch-error")).toHaveTextContent("already exists");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("cancel dismisses", () => {
|
|
64
|
+
const onCancel = jest.fn();
|
|
65
|
+
render(wrap(<ProtectedBranchModal {...base} onCancel={onCancel} />));
|
|
66
|
+
fireEvent.click(screen.getByTestId("protected-branch-cancel"));
|
|
67
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
68
|
+
});
|
|
@@ -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
|
-
|
|
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. */
|
|
@@ -15,6 +15,7 @@ import { BranchMenu, type BranchRef } from "./BranchMenu";
|
|
|
15
15
|
import { ProjectMenu, type ProjectRef } from "./ProjectMenu";
|
|
16
16
|
import { ChangeDetail, type DocumentChange, type FieldConflict, type ConflictChoice } from "./ChangeDetail";
|
|
17
17
|
import { ApplyChangesModal, type MergeProgress } from "./ApplyChangesModal";
|
|
18
|
+
import { ProtectedBranchModal } from "./ProtectedBranchModal";
|
|
18
19
|
import { NotificationBell, type NotificationItem } from "./Notifications";
|
|
19
20
|
import {
|
|
20
21
|
CollabInput,
|
|
@@ -33,6 +34,8 @@ import { MediaField, MediaProvider, DocumentPathProvider } from "./MediaField";
|
|
|
33
34
|
import { MediaBrowser } from "./MediaBrowser";
|
|
34
35
|
import { FormsBrowser } from "./FormsBrowser";
|
|
35
36
|
import { FORMS_NAV_KEY, formsNavKey, isFormsNavKey, parseFormsNavKey, type FormsApi } from "../forms";
|
|
37
|
+
import { type HistoryApi } from "../history";
|
|
38
|
+
import { DocumentHistory } from "./DocumentHistory";
|
|
36
39
|
import {
|
|
37
40
|
MEDIA_NAV_KEY,
|
|
38
41
|
mediaNavKey,
|
|
@@ -339,6 +342,14 @@ export type ContentBrowserProps = {
|
|
|
339
342
|
// Forms data seam, the same split (docs/forms.md §10.2). Omitted → the Forms
|
|
340
343
|
// surface never renders, which is what a config declaring no forms looks like.
|
|
341
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;
|
|
342
353
|
// The selected submission's id, and the reporter for taps — bound to the URL
|
|
343
354
|
// by the host exactly as document selection is.
|
|
344
355
|
selectedSubmissionId?: string | null;
|
|
@@ -478,6 +489,19 @@ export type ContentBrowserProps = {
|
|
|
478
489
|
changeRequestOpen?: boolean;
|
|
479
490
|
changeRequestUrl?: string;
|
|
480
491
|
creatingChangeRequest?: boolean;
|
|
492
|
+
|
|
493
|
+
// Protected-branch save prompt. The server refuses a save to a branch the
|
|
494
|
+
// provider protects; the host catches that, sets protectedBranch to the branch
|
|
495
|
+
// name, and this modal offers the one thing that resolves it — a branch to put
|
|
496
|
+
// the edit on. onCreateBranchAndSave receives the name; the host creates the
|
|
497
|
+
// branch and applies the pending edit to it in one server call.
|
|
498
|
+
protectedBranch?: string;
|
|
499
|
+
protectedBranchDocument?: string;
|
|
500
|
+
protectedBranchSuggestedName?: string;
|
|
501
|
+
creatingProtectedBranch?: boolean;
|
|
502
|
+
protectedBranchError?: string | null;
|
|
503
|
+
onCreateBranchAndSave?: (name: string) => void;
|
|
504
|
+
onCancelProtectedSave?: () => void;
|
|
481
505
|
onCreateChangeRequest?: (explanation: string) => void;
|
|
482
506
|
onViewChangeRequest?: () => void;
|
|
483
507
|
|
|
@@ -2567,6 +2591,7 @@ function EntryDetail({
|
|
|
2567
2591
|
onMove,
|
|
2568
2592
|
collectionPath,
|
|
2569
2593
|
folderOptions,
|
|
2594
|
+
history,
|
|
2570
2595
|
active,
|
|
2571
2596
|
onActivate,
|
|
2572
2597
|
onCollapse,
|
|
@@ -2589,6 +2614,10 @@ function EntryDetail({
|
|
|
2589
2614
|
collectionPath?: string;
|
|
2590
2615
|
// Existing folders (relative to the glob base) offered by the move autocomplete.
|
|
2591
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;
|
|
2592
2621
|
// Desktop columns: `active` marks the focused column (accent + header press
|
|
2593
2622
|
// activates it via onActivate); onCollapse/onClose add the header rail/close
|
|
2594
2623
|
// controls. All omitted on mobile → unchanged single-detail behavior.
|
|
@@ -2609,6 +2638,18 @@ function EntryDetail({
|
|
|
2609
2638
|
const canRename = !readOnly && !!onRename;
|
|
2610
2639
|
const canMove = !readOnly && !!onMove && glob.hierarchical;
|
|
2611
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
|
+
}
|
|
2612
2653
|
|
|
2613
2654
|
// Both branches share one value map. The title+body branch is modeled as a
|
|
2614
2655
|
// single body-source field so autosave logic is uniform.
|
|
@@ -2894,6 +2935,20 @@ function EntryDetail({
|
|
|
2894
2935
|
document's own actions, while the menu holds the destructive and
|
|
2895
2936
|
path-changing ones that should stay one level down. */}
|
|
2896
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}
|
|
2897
2952
|
{canRename || canMove || (canDelete && onDeleteEntry) ? (
|
|
2898
2953
|
<DetailMenu
|
|
2899
2954
|
onRename={canRename ? () => setPrompt("rename") : undefined}
|
|
@@ -2909,10 +2964,20 @@ function EntryDetail({
|
|
|
2909
2964
|
) : null}
|
|
2910
2965
|
</View>
|
|
2911
2966
|
|
|
2912
|
-
{/*
|
|
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
|
|
2913
2978
|
fresh initial values (and the scroll resets to top) when the selected
|
|
2914
|
-
document changes. The header above stays put; only the body scrolls. */
|
|
2915
|
-
|
|
2979
|
+
document changes. The header above stays put; only the body scrolls. */
|
|
2980
|
+
hasFields ? (
|
|
2916
2981
|
// The document's path reaches media fields at any nesting depth through
|
|
2917
2982
|
// context: a picker inside a group or a list item needs it to interpret
|
|
2918
2983
|
// relative references, and drilling it through every level would touch
|
|
@@ -3567,9 +3632,28 @@ function ReadOnlyBanner({ notice, actionLabel, onAction }: { notice?: string; ac
|
|
|
3567
3632
|
);
|
|
3568
3633
|
}
|
|
3569
3634
|
|
|
3570
|
-
//
|
|
3571
|
-
//
|
|
3572
|
-
//
|
|
3635
|
+
// ModalOverlays renders the browser's full-screen modals — apply changes /
|
|
3636
|
+
// change request, and the protected-branch save prompt. Shared by both layouts
|
|
3637
|
+
// so every modal is reachable on mobile too.
|
|
3638
|
+
function ModalOverlays(props: ContentBrowserProps) {
|
|
3639
|
+
return (
|
|
3640
|
+
<>
|
|
3641
|
+
<ApplyModalOverlay {...props} />
|
|
3642
|
+
{props.protectedBranch ? (
|
|
3643
|
+
<ProtectedBranchModal
|
|
3644
|
+
branch={props.protectedBranch}
|
|
3645
|
+
documentLabel={props.protectedBranchDocument}
|
|
3646
|
+
suggestedName={props.protectedBranchSuggestedName}
|
|
3647
|
+
busy={props.creatingProtectedBranch}
|
|
3648
|
+
error={props.protectedBranchError}
|
|
3649
|
+
onCreate={props.onCreateBranchAndSave ?? (() => {})}
|
|
3650
|
+
onCancel={props.onCancelProtectedSave ?? (() => {})}
|
|
3651
|
+
/>
|
|
3652
|
+
) : null}
|
|
3653
|
+
</>
|
|
3654
|
+
);
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3573
3657
|
function ApplyModalOverlay(props: ContentBrowserProps) {
|
|
3574
3658
|
if (!props.applyOpen) return null;
|
|
3575
3659
|
return (
|
|
@@ -3937,6 +4021,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
3937
4021
|
<EntryDetail
|
|
3938
4022
|
entry={col.entry}
|
|
3939
4023
|
documentActions={props.documentActions}
|
|
4024
|
+
history={props.history}
|
|
3940
4025
|
onEntryDraft={props.onEntryDraft}
|
|
3941
4026
|
renderField={renderField}
|
|
3942
4027
|
onSaveEntry={props.onSaveEntry}
|
|
@@ -4066,7 +4151,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4066
4151
|
const readOnlyBanner = (
|
|
4067
4152
|
<ReadOnlyBanner notice={props.readOnlyNotice} actionLabel={props.readOnlyNoticeActionLabel} onAction={props.onReadOnlyNoticeAction} />
|
|
4068
4153
|
);
|
|
4069
|
-
const
|
|
4154
|
+
const modalOverlays = <ModalOverlays {...props} />;
|
|
4070
4155
|
|
|
4071
4156
|
// The changes surface reuses the same three-pane model as Edit, so switching
|
|
4072
4157
|
// between them doesn't relayout the screen — only what each pane contains
|
|
@@ -4119,7 +4204,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4119
4204
|
conflicts={props.selectedConflicts}
|
|
4120
4205
|
onResolveConflict={props.onResolveConflict}
|
|
4121
4206
|
/>
|
|
4122
|
-
{
|
|
4207
|
+
{modalOverlays}
|
|
4123
4208
|
</AppShell>
|
|
4124
4209
|
);
|
|
4125
4210
|
}
|
|
@@ -4134,7 +4219,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4134
4219
|
<Pane flex={1} testID="pane-plugin" scroll={false}>
|
|
4135
4220
|
{props.contentSlot}
|
|
4136
4221
|
</Pane>
|
|
4137
|
-
{
|
|
4222
|
+
{modalOverlays}
|
|
4138
4223
|
</AppShell>
|
|
4139
4224
|
);
|
|
4140
4225
|
}
|
|
@@ -4174,7 +4259,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4174
4259
|
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
|
|
4175
4260
|
<Text variant="body" color="tertiary" testID="forms-empty">Select a form</Text>
|
|
4176
4261
|
</View>
|
|
4177
|
-
{
|
|
4262
|
+
{modalOverlays}
|
|
4178
4263
|
</AppShell>
|
|
4179
4264
|
);
|
|
4180
4265
|
}
|
|
@@ -4192,7 +4277,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4192
4277
|
variant="desktop"
|
|
4193
4278
|
/>
|
|
4194
4279
|
</Pane>
|
|
4195
|
-
{
|
|
4280
|
+
{modalOverlays}
|
|
4196
4281
|
</AppShell>
|
|
4197
4282
|
);
|
|
4198
4283
|
}
|
|
@@ -4219,7 +4304,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4219
4304
|
<Text variant="body" color="tertiary" testID="media-sets-empty">Select a media collection</Text>
|
|
4220
4305
|
</View>
|
|
4221
4306
|
)}
|
|
4222
|
-
{
|
|
4307
|
+
{modalOverlays}
|
|
4223
4308
|
</AppShell>
|
|
4224
4309
|
);
|
|
4225
4310
|
}
|
|
@@ -4420,7 +4505,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4420
4505
|
onClose={() => setMovePrompt(false)}
|
|
4421
4506
|
/>
|
|
4422
4507
|
) : null}
|
|
4423
|
-
{
|
|
4508
|
+
{modalOverlays}
|
|
4424
4509
|
</AppShell>
|
|
4425
4510
|
);
|
|
4426
4511
|
}
|
|
@@ -4496,7 +4581,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4496
4581
|
const readOnlyBanner = (
|
|
4497
4582
|
<ReadOnlyBanner notice={props.readOnlyNotice} actionLabel={props.readOnlyNoticeActionLabel} onAction={props.onReadOnlyNoticeAction} />
|
|
4498
4583
|
);
|
|
4499
|
-
const
|
|
4584
|
+
const modalOverlays = <ModalOverlays {...props} />;
|
|
4500
4585
|
|
|
4501
4586
|
// Media drills the same way a collection does — nav → list → detail — so the
|
|
4502
4587
|
// back arrow means the same thing at every level. Pressing Media lists the
|
|
@@ -4622,7 +4707,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4622
4707
|
testID="mobile-shell"
|
|
4623
4708
|
header={header}
|
|
4624
4709
|
title={props.surface === "changes" ? "Changes" : "Content"}
|
|
4625
|
-
overlay={
|
|
4710
|
+
overlay={modalOverlays}
|
|
4626
4711
|
>
|
|
4627
4712
|
{/* Edit / Changes surface toggle (fits its content) with the Apply button
|
|
4628
4713
|
to its right on the changes surface. The read-only notice sits below. */}
|
|
@@ -4703,7 +4788,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4703
4788
|
testID="mobile-entries"
|
|
4704
4789
|
scroll={false}
|
|
4705
4790
|
banner={readOnlyBanner}
|
|
4706
|
-
overlay={
|
|
4791
|
+
overlay={modalOverlays}
|
|
4707
4792
|
header={
|
|
4708
4793
|
search.open ? (
|
|
4709
4794
|
<SearchHeaderBar search={search} showFilter={facetFields.length > 0} />
|
|
@@ -4784,7 +4869,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4784
4869
|
<MobileScreen
|
|
4785
4870
|
testID="mobile-entry"
|
|
4786
4871
|
banner={readOnlyBanner}
|
|
4787
|
-
overlay={
|
|
4872
|
+
overlay={modalOverlays}
|
|
4788
4873
|
header={
|
|
4789
4874
|
<>
|
|
4790
4875
|
<IconButton name="chevronLeft" onPress={backToEntries} size="md" label="Back" />
|
|
@@ -4812,6 +4897,7 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
4812
4897
|
<EntryDetail
|
|
4813
4898
|
entry={entry}
|
|
4814
4899
|
documentActions={props.documentActions}
|
|
4900
|
+
history={props.history}
|
|
4815
4901
|
onEntryDraft={props.onEntryDraft}
|
|
4816
4902
|
renderField={renderField}
|
|
4817
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
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { View, Pressable } from "react-native";
|
|
3
|
+
import { useTheme } from "../ThemeProvider";
|
|
4
|
+
import { Text } from "./Text";
|
|
5
|
+
import { Icon } from "./Icon";
|
|
6
|
+
import { Input } from "./Input";
|
|
7
|
+
import { Button } from "./Button";
|
|
8
|
+
|
|
9
|
+
export type ProtectedBranchModalProps = {
|
|
10
|
+
/** The protected branch the save was refused on. */
|
|
11
|
+
branch: string;
|
|
12
|
+
/** The document that could not be saved, for naming what is at stake. */
|
|
13
|
+
documentLabel?: string;
|
|
14
|
+
/** Prefills the field — the host derives it from the document. */
|
|
15
|
+
suggestedName?: string;
|
|
16
|
+
/** True while the branch is being created and the save applied. */
|
|
17
|
+
busy?: boolean;
|
|
18
|
+
/** The server's message, when the attempt failed. */
|
|
19
|
+
error?: string | null;
|
|
20
|
+
/** Create `name` from `branch` and save the pending edit onto it. */
|
|
21
|
+
onCreate: (name: string) => void;
|
|
22
|
+
/** Dismiss. The edit stays as an unsaved draft on the protected branch. */
|
|
23
|
+
onCancel: () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Shown when a save is refused because the branch is protected.
|
|
28
|
+
*
|
|
29
|
+
* It is a prompt rather than an error banner because the refusal has exactly one
|
|
30
|
+
* remedy and the editor knows what it is: the work is intact, it just needs
|
|
31
|
+
* somewhere it can land. Presenting that as "here is a message, now go find the
|
|
32
|
+
* branch menu" would make the author reconstruct a plan the product already has.
|
|
33
|
+
*
|
|
34
|
+
* The draft is deliberately NOT discarded on cancel — a protected branch is
|
|
35
|
+
* often noticed mid-thought, and the author may want to keep writing and pick a
|
|
36
|
+
* branch name later. It stays in storage exactly as any other unsaved work does.
|
|
37
|
+
*/
|
|
38
|
+
export function ProtectedBranchModal({
|
|
39
|
+
branch, documentLabel, suggestedName, busy, error, onCreate, onCancel,
|
|
40
|
+
}: ProtectedBranchModalProps) {
|
|
41
|
+
const t = useTheme();
|
|
42
|
+
const [name, setName] = useState(suggestedName ?? "");
|
|
43
|
+
// The suggestion is derived from the document, which the host may resolve a
|
|
44
|
+
// render after the modal opens. Adopted only while the field is untouched.
|
|
45
|
+
const [touched, setTouched] = useState(false);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!touched && suggestedName) setName(suggestedName);
|
|
48
|
+
}, [suggestedName, touched]);
|
|
49
|
+
|
|
50
|
+
const trimmed = name.trim();
|
|
51
|
+
const submit = () => {
|
|
52
|
+
if (!trimmed || busy) return;
|
|
53
|
+
onCreate(trimmed);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<View
|
|
58
|
+
testID="protected-branch-modal"
|
|
59
|
+
style={{
|
|
60
|
+
position: "absolute", top: 0, left: 0, right: 0, bottom: 0, zIndex: 50,
|
|
61
|
+
alignItems: "center", justifyContent: "center", padding: t.space(4),
|
|
62
|
+
backgroundColor: "rgba(0,0,0,0.45)",
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<View
|
|
66
|
+
style={{
|
|
67
|
+
width: 460, maxWidth: "100%", gap: t.space(4), padding: t.space(5),
|
|
68
|
+
borderRadius: t.radius.lg, borderWidth: 1, borderColor: t.color.borderDefault,
|
|
69
|
+
backgroundColor: t.color.surfaceRaised,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
|
|
73
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
74
|
+
<Icon name="lock" size={16} color={t.color.textSecondary} />
|
|
75
|
+
<Text variant="h3" weight="semibold">This branch is protected</Text>
|
|
76
|
+
</View>
|
|
77
|
+
{busy ? null : (
|
|
78
|
+
<Pressable
|
|
79
|
+
testID="protected-branch-close"
|
|
80
|
+
accessibilityRole="button"
|
|
81
|
+
accessibilityLabel="Cancel"
|
|
82
|
+
onPress={onCancel}
|
|
83
|
+
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
|
84
|
+
>
|
|
85
|
+
<Icon name="x" size={18} color={t.color.textSecondary} />
|
|
86
|
+
</Pressable>
|
|
87
|
+
)}
|
|
88
|
+
</View>
|
|
89
|
+
|
|
90
|
+
<Text variant="body" color="secondary">
|
|
91
|
+
{documentLabel
|
|
92
|
+
? `${branch} doesn’t accept changes directly, so “${documentLabel}” can’t be saved to it. Name a branch to save it to instead — your edit lands there.`
|
|
93
|
+
: `${branch} doesn’t accept changes directly. Name a branch to save your edit to instead.`}
|
|
94
|
+
</Text>
|
|
95
|
+
|
|
96
|
+
<Input
|
|
97
|
+
label="New branch"
|
|
98
|
+
value={name}
|
|
99
|
+
onChangeText={(v) => { setTouched(true); setName(v); }}
|
|
100
|
+
onSubmitEditing={submit}
|
|
101
|
+
placeholder="feat/my-change"
|
|
102
|
+
autoCapitalize="none"
|
|
103
|
+
autoFocus
|
|
104
|
+
mono
|
|
105
|
+
editable={!busy}
|
|
106
|
+
error={!!error}
|
|
107
|
+
testID="protected-branch-name"
|
|
108
|
+
/>
|
|
109
|
+
<Text variant="monoSm" color="tertiary">{`Branched from ${branch}`}</Text>
|
|
110
|
+
|
|
111
|
+
{error ? (
|
|
112
|
+
<Text testID="protected-branch-error" variant="monoSm" color={t.color.diffDelFg}>{error}</Text>
|
|
113
|
+
) : null}
|
|
114
|
+
|
|
115
|
+
<View style={{ flexDirection: "row", justifyContent: "flex-end", gap: t.space(2) }}>
|
|
116
|
+
<Button
|
|
117
|
+
title="Cancel"
|
|
118
|
+
variant="ghost"
|
|
119
|
+
size="md"
|
|
120
|
+
disabled={busy}
|
|
121
|
+
onPress={onCancel}
|
|
122
|
+
testID="protected-branch-cancel"
|
|
123
|
+
/>
|
|
124
|
+
<Button
|
|
125
|
+
title={busy ? "Creating…" : "Create branch and save"}
|
|
126
|
+
variant="primary"
|
|
127
|
+
size="md"
|
|
128
|
+
disabled={busy || trimmed === ""}
|
|
129
|
+
onPress={submit}
|
|
130
|
+
testID="protected-branch-submit"
|
|
131
|
+
/>
|
|
132
|
+
</View>
|
|
133
|
+
</View>
|
|
134
|
+
</View>
|
|
135
|
+
);
|
|
136
|
+
}
|
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
|
@@ -55,6 +55,8 @@ export type {
|
|
|
55
55
|
} from "./components/ApplyChangesModal";
|
|
56
56
|
|
|
57
57
|
// Change-request summary surface (developer escalation)
|
|
58
|
+
export { ProtectedBranchModal } from "./components/ProtectedBranchModal";
|
|
59
|
+
export type { ProtectedBranchModalProps } from "./components/ProtectedBranchModal";
|
|
58
60
|
export { ChangeRequestSummary } from "./components/ChangeRequestSummary";
|
|
59
61
|
export type {
|
|
60
62
|
ChangeRequestSummaryProps,
|
|
@@ -64,6 +66,10 @@ export type {
|
|
|
64
66
|
|
|
65
67
|
// Changes surface: one document's diff between a branch and its base
|
|
66
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";
|
|
67
73
|
export type {
|
|
68
74
|
ChangeDetailProps,
|
|
69
75
|
DocumentChange,
|