@gogitcms/editor 0.23.0
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/app/index.html +38 -0
- package/app/src/App.tsx +2665 -0
- package/app/src/apollo.ts +119 -0
- package/app/src/auth.ts +248 -0
- package/app/src/config.ts +88 -0
- package/app/src/main.tsx +18 -0
- package/app/src/media.ts +151 -0
- package/app/src/navigation.tsx +321 -0
- package/app/src/plugins.ts +11 -0
- package/app/src/previewHost.tsx +263 -0
- package/app/src/previewToken.ts +68 -0
- package/app/src/queries.ts +591 -0
- package/app/src/virtual-cms-plugins.d.ts +6 -0
- package/app/vendor/analytics/src/__tests__/config.test.ts +91 -0
- package/app/vendor/analytics/src/config.ts +94 -0
- package/app/vendor/analytics/src/events.ts +56 -0
- package/app/vendor/analytics/src/index.ts +23 -0
- package/app/vendor/analytics/src/provider.tsx +196 -0
- package/app/vendor/design-system/src/ThemeProvider.tsx +86 -0
- package/app/vendor/design-system/src/__tests__/ApplyChangesModal.test.tsx +148 -0
- package/app/vendor/design-system/src/__tests__/BranchImport.test.tsx +46 -0
- package/app/vendor/design-system/src/__tests__/Button.test.tsx +45 -0
- package/app/vendor/design-system/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.media.test.tsx +140 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.search.test.tsx +135 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
- package/app/vendor/design-system/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
- package/app/vendor/design-system/src/__tests__/MediaBrowser.test.tsx +353 -0
- package/app/vendor/design-system/src/__tests__/MediaField.test.tsx +185 -0
- package/app/vendor/design-system/src/__tests__/Notifications.test.tsx +69 -0
- package/app/vendor/design-system/src/__tests__/Onboarding.test.tsx +287 -0
- package/app/vendor/design-system/src/__tests__/cssTokens.test.ts +201 -0
- package/app/vendor/design-system/src/__tests__/fieldComponents.test.ts +43 -0
- package/app/vendor/design-system/src/__tests__/reorder.test.ts +58 -0
- package/app/vendor/design-system/src/components/ApplyChangesModal.tsx +348 -0
- package/app/vendor/design-system/src/components/BranchImport.tsx +157 -0
- package/app/vendor/design-system/src/components/BranchMenu.tsx +192 -0
- package/app/vendor/design-system/src/components/Button.tsx +131 -0
- package/app/vendor/design-system/src/components/ChangeDetail.tsx +472 -0
- package/app/vendor/design-system/src/components/ChangeRequestSummary.tsx +173 -0
- package/app/vendor/design-system/src/components/CollabField.tsx +388 -0
- package/app/vendor/design-system/src/components/ContentBrowser.tsx +5073 -0
- package/app/vendor/design-system/src/components/Icon.tsx +28 -0
- package/app/vendor/design-system/src/components/Icon.web.tsx +31 -0
- package/app/vendor/design-system/src/components/Input.tsx +106 -0
- package/app/vendor/design-system/src/components/MediaBrowser.tsx +766 -0
- package/app/vendor/design-system/src/components/MediaField.tsx +670 -0
- package/app/vendor/design-system/src/components/MediaPreview.tsx +91 -0
- package/app/vendor/design-system/src/components/MediaPreview.web.tsx +169 -0
- package/app/vendor/design-system/src/components/NavRow.tsx +105 -0
- package/app/vendor/design-system/src/components/Notifications.tsx +301 -0
- package/app/vendor/design-system/src/components/Onboarding.tsx +751 -0
- package/app/vendor/design-system/src/components/ProjectMenu.tsx +124 -0
- package/app/vendor/design-system/src/components/Segment.tsx +87 -0
- package/app/vendor/design-system/src/components/Skeleton.tsx +216 -0
- package/app/vendor/design-system/src/components/Spinner.tsx +44 -0
- package/app/vendor/design-system/src/components/Text.tsx +85 -0
- package/app/vendor/design-system/src/components/documentDrafts.ts +213 -0
- package/app/vendor/design-system/src/components/layout.tsx +284 -0
- package/app/vendor/design-system/src/components/primitives.tsx +143 -0
- package/app/vendor/design-system/src/components/reorder.ts +40 -0
- package/app/vendor/design-system/src/fieldComponents.ts +63 -0
- package/app/vendor/design-system/src/icons.ts +102 -0
- package/app/vendor/design-system/src/index.ts +198 -0
- package/app/vendor/design-system/src/media.ts +229 -0
- package/app/vendor/design-system/src/theme.ts +116 -0
- package/app/vendor/design-system/src/web/Button.tsx +110 -0
- package/app/vendor/design-system/src/web/Icon.tsx +52 -0
- package/app/vendor/design-system/src/web/Input.tsx +39 -0
- package/app/vendor/design-system/src/web/index.ts +43 -0
- package/app/vendor/design-system/src/web/primitives.tsx +119 -0
- package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +248 -0
- package/app/vendor/markdown-editor/src/Toolbar.tsx +157 -0
- package/app/vendor/markdown-editor/src/field.tsx +67 -0
- package/app/vendor/markdown-editor/src/flavors/commonmark/index.ts +27 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/index.ts +30 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/parser.ts +73 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/schema.ts +70 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/serializer.ts +82 -0
- package/app/vendor/markdown-editor/src/flavors/gfm/taskList.ts +39 -0
- package/app/vendor/markdown-editor/src/flavors/registry.ts +13 -0
- package/app/vendor/markdown-editor/src/flavors/shared/inputrules.ts +79 -0
- package/app/vendor/markdown-editor/src/flavors/shared/keymap.ts +89 -0
- package/app/vendor/markdown-editor/src/flavors/shared/placeholder.ts +24 -0
- package/app/vendor/markdown-editor/src/flavors/shared/plugins.ts +58 -0
- package/app/vendor/markdown-editor/src/flavors/types.ts +22 -0
- package/app/vendor/markdown-editor/src/formats/registry.ts +45 -0
- package/app/vendor/markdown-editor/src/icons.tsx +120 -0
- package/app/vendor/markdown-editor/src/index.ts +23 -0
- package/app/vendor/markdown-editor/src/theme.ts +249 -0
- package/app/vendor/markdown-editor/src/types.ts +58 -0
- package/app/vendor/plugin-sdk/src/__tests__/documentActions.test.ts +108 -0
- package/app/vendor/plugin-sdk/src/__tests__/loader.test.ts +75 -0
- package/app/vendor/plugin-sdk/src/__tests__/registry.test.ts +148 -0
- package/app/vendor/plugin-sdk/src/index.ts +33 -0
- package/app/vendor/plugin-sdk/src/loader.ts +45 -0
- package/app/vendor/plugin-sdk/src/navkeys.ts +17 -0
- package/app/vendor/plugin-sdk/src/react.tsx +155 -0
- package/app/vendor/plugin-sdk/src/registry.ts +337 -0
- package/app/vendor/plugin-sdk/src/types.ts +230 -0
- package/app/vendor/realtime/src/__tests__/pure.test.ts +58 -0
- package/app/vendor/realtime/src/__tests__/seeded-room.base64 +1 -0
- package/app/vendor/realtime/src/__tests__/seeding.test.ts +70 -0
- package/app/vendor/realtime/src/collab.ts +308 -0
- package/app/vendor/realtime/src/hooks.ts +57 -0
- package/app/vendor/realtime/src/index.ts +12 -0
- package/app/vendor/realtime/src/pure.ts +43 -0
- package/app/vite.config.mjs +59 -0
- package/bin/gogitcms-editor.mjs +168 -0
- package/npm-shrinkwrap.json +5696 -0
- package/package.json +74 -0
- package/src/commands/build.mjs +100 -0
- package/src/commands/dev.mjs +186 -0
- package/src/commands/init.mjs +144 -0
- package/src/commands/login.mjs +116 -0
- package/src/commands/status.mjs +68 -0
- package/src/lib/api.mjs +122 -0
- package/src/lib/appRoot.mjs +51 -0
- package/src/lib/baked.json +3 -0
- package/src/lib/config.mjs +108 -0
- package/src/lib/credentials.mjs +91 -0
- package/src/lib/defaults.mjs +26 -0
- package/src/lib/graphql.mjs +28 -0
- package/src/lib/localServer.mjs +206 -0
- package/src/lib/open.mjs +23 -0
- package/src/lib/plugins.mjs +164 -0
- package/src/lib/tokenBroker.mjs +85 -0
- package/src/tui/prompts.mjs +92 -0
- package/src/tui/screen.mjs +282 -0
- package/src/tui/theme.mjs +40 -0
package/app/src/App.tsx
ADDED
|
@@ -0,0 +1,2665 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Pressable, View } from "react-native";
|
|
3
|
+
import { ApolloProvider, useMutation, useQuery, useSubscription, useApolloClient } from "@apollo/client";
|
|
4
|
+
import {
|
|
5
|
+
ThemeProvider,
|
|
6
|
+
Screen,
|
|
7
|
+
TopBar,
|
|
8
|
+
Text,
|
|
9
|
+
Input,
|
|
10
|
+
Button,
|
|
11
|
+
IconButton,
|
|
12
|
+
Icon,
|
|
13
|
+
Avatar,
|
|
14
|
+
ThemeToggle,
|
|
15
|
+
ContentBrowser,
|
|
16
|
+
ContentBrowserSkeleton,
|
|
17
|
+
BranchImportingScreen,
|
|
18
|
+
BranchImportFailedScreen,
|
|
19
|
+
ChangeRequestSummary,
|
|
20
|
+
NotificationList,
|
|
21
|
+
useTheme,
|
|
22
|
+
useThemeMode,
|
|
23
|
+
type CmsNavSection,
|
|
24
|
+
type CmsEntry,
|
|
25
|
+
type OpenTab,
|
|
26
|
+
type NotificationItem,
|
|
27
|
+
} from "@gogitcms/design-system";
|
|
28
|
+
import { createMarkdownRenderField } from "@gogitcms/markdown-editor";
|
|
29
|
+
import {
|
|
30
|
+
PluginScreenHost,
|
|
31
|
+
createPluginRenderField,
|
|
32
|
+
parsePluginNavKey,
|
|
33
|
+
pluginNavKey,
|
|
34
|
+
usePluginRegistry,
|
|
35
|
+
type PluginDocument,
|
|
36
|
+
type PluginTabSpec,
|
|
37
|
+
} from "@gogitcms/plugin-sdk";
|
|
38
|
+
import { pluginRegistry } from "./plugins";
|
|
39
|
+
import {
|
|
40
|
+
DraftBus,
|
|
41
|
+
PluginDocumentActions,
|
|
42
|
+
PluginTabBody,
|
|
43
|
+
pluginTabId,
|
|
44
|
+
type PluginTabRef,
|
|
45
|
+
} from "./previewHost";
|
|
46
|
+
import { useDocCollab, type DocCollab } from "@gogitcms/realtime";
|
|
47
|
+
import { client, setAuthToken, getAuthToken } from "./apollo";
|
|
48
|
+
import { config, analyticsConfig } from "./config";
|
|
49
|
+
import { AnalyticsEvent, AnalyticsProvider, useAnalytics, useIdentify } from "@gogitcms/analytics";
|
|
50
|
+
import { beginAuthorize, beginLogout, beginGitHub, brokeredToken, handleCallback, requestMagicLink, redeemMagicLink, isSignedOut, clearSignedOut } from "./auth";
|
|
51
|
+
import { useNavigation, useRoute, StackActions } from "@react-navigation/native";
|
|
52
|
+
import { NavigationRoot, RootStack, EMPTY_SELECTION, browserUrl, type BrowserParams, type CmsSelection } from "./navigation";
|
|
53
|
+
import { isMediaNavKey, MEDIA_NAV_KEY } from "@gogitcms/design-system";
|
|
54
|
+
import { LOGIN, WORKSPACES, REPOSITORIES, COLLECTIONS, DOCUMENTS, DOCUMENT_COUNT, DOCUMENT, ME, NOTIFICATIONS, MARK_NOTIFICATIONS_READ, UPDATE_DOCUMENT, CREATE_DOCUMENT, DELETE_DOCUMENTS, RENAME_DOCUMENT, MOVE_DOCUMENTS, BRANCH_CHANGE_SUMMARY, BRANCH_CHANGES, BRANCH_CHANGE, BRANCH_MEDIA_CHANGES, CREATE_BRANCH, APPLY_CHANGES, MERGE_RUN_PROGRESS, RESOLVE_MERGE_CONFLICT, OPEN_CHANGE_REQUEST, CHANGE_REQUEST, CHANGE_REQUEST_FOR_BRANCH, CREATE_CHANGE_REQUEST, BRANCH_IMPORT, BRANCH_IMPORT_PROGRESS } from "./queries";
|
|
55
|
+
import type { EntryField, FacetField, DocFilter, SegmentItem, BranchRef, ProjectRef, DocumentChange, FieldChange, FieldChangeKind, MergeProgress, FieldConflict as FieldConflictT } from "@gogitcms/design-system";
|
|
56
|
+
import { useMediaApi } from "./media";
|
|
57
|
+
|
|
58
|
+
// How many documents to fetch per page of the (server-paginated) entry list.
|
|
59
|
+
const PAGE_SIZE = 50;
|
|
60
|
+
|
|
61
|
+
type Branch = {
|
|
62
|
+
id: string;
|
|
63
|
+
name: string;
|
|
64
|
+
protected: boolean;
|
|
65
|
+
// True when this branch carries projects the viewer can edit AND projects they
|
|
66
|
+
// cannot: merging it would publish content they were never allowed to touch,
|
|
67
|
+
// so it has to go through review. See docs/projects.md §9.2.
|
|
68
|
+
requiresChangeRequest?: boolean;
|
|
69
|
+
// The projects on this branch the viewer may edit.
|
|
70
|
+
writableProjects?: string[];
|
|
71
|
+
};
|
|
72
|
+
type Workspace = { id: string; name: string; slug: string };
|
|
73
|
+
type Project = { name: string; label: string; orphaned: boolean };
|
|
74
|
+
type Repository = { id: string; owner: string; name: string; branches: Branch[]; defaultBranch: string; projects?: Project[] };
|
|
75
|
+
|
|
76
|
+
// Resolve which project to open, and whether the choice is the user's to make.
|
|
77
|
+
//
|
|
78
|
+
// Three inputs, in precedence order:
|
|
79
|
+
// 1. config.project — a self-hosted editor pinned to one project. It wins over
|
|
80
|
+
// the URL: a locked deployment must not be steerable by editing an address.
|
|
81
|
+
// 2. the URL's `projects/{name}` segment.
|
|
82
|
+
// 3. the repository's only project, which is every repository without a
|
|
83
|
+
// manifest — and is why an editor that has never seen a manifest shows no
|
|
84
|
+
// project affordance at all.
|
|
85
|
+
//
|
|
86
|
+
// An orphaned project (on no branch) is still openable by name: it has content
|
|
87
|
+
// in the database, and hiding the only route to it would strand that content.
|
|
88
|
+
function resolveProject(repo: Repository | undefined, urlName?: string) {
|
|
89
|
+
const projects = (repo?.projects ?? []).filter((p) => !p.orphaned);
|
|
90
|
+
const sole = projects.length === 1 ? projects[0] : undefined;
|
|
91
|
+
const locked = config.project;
|
|
92
|
+
|
|
93
|
+
if (locked !== undefined) {
|
|
94
|
+
const match = (repo?.projects ?? []).find((p) => p.name === locked);
|
|
95
|
+
// A lock naming a project this repository doesn't have is a configuration
|
|
96
|
+
// error, not a reason to silently show another project's content.
|
|
97
|
+
return { name: locked, project: match, projects, sole, locked: true, missing: !match };
|
|
98
|
+
}
|
|
99
|
+
const name = urlName ?? sole?.name;
|
|
100
|
+
const match = name === undefined ? undefined : (repo?.projects ?? []).find((p) => p.name === name);
|
|
101
|
+
return { name, project: match, projects, sole, locked: false, missing: name !== undefined && !match };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// One changed document, from the branchChanges query.
|
|
105
|
+
type BranchChange = {
|
|
106
|
+
status: "A" | "M" | "D" | "R";
|
|
107
|
+
collection: string;
|
|
108
|
+
path: string;
|
|
109
|
+
previousPath?: string | null;
|
|
110
|
+
label: string;
|
|
111
|
+
added: number;
|
|
112
|
+
removed: number;
|
|
113
|
+
documentId?: string | null;
|
|
114
|
+
};
|
|
115
|
+
// One field conflict on a merge run, from the merge API.
|
|
116
|
+
type MergeConflictT = {
|
|
117
|
+
id: string;
|
|
118
|
+
path: string;
|
|
119
|
+
collection: string;
|
|
120
|
+
field: string;
|
|
121
|
+
isBody: boolean;
|
|
122
|
+
before?: unknown;
|
|
123
|
+
after?: unknown;
|
|
124
|
+
resolution?: string | null;
|
|
125
|
+
};
|
|
126
|
+
type ChangeSummary = {
|
|
127
|
+
// The project this collection belongs to. Two projects on one branch may both
|
|
128
|
+
// declare a "pages" collection, so a row is only identified by the pair — and
|
|
129
|
+
// the changes sidebar groups on this when a branch spans more than one.
|
|
130
|
+
project: string;
|
|
131
|
+
collection: string;
|
|
132
|
+
label?: string | null;
|
|
133
|
+
singleton: boolean;
|
|
134
|
+
count: number;
|
|
135
|
+
// "content" | "media". Media is not a content model, so it is keyed off this
|
|
136
|
+
// rather than off the collection name, which a real model could collide with.
|
|
137
|
+
kind: string;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// One branch's version of a changed media file; null on the side it doesn't
|
|
141
|
+
// exist (an addition has no before, a deletion no after).
|
|
142
|
+
type MediaSide = {
|
|
143
|
+
size: number;
|
|
144
|
+
mimeType: string;
|
|
145
|
+
kind: string;
|
|
146
|
+
width?: number | null;
|
|
147
|
+
height?: number | null;
|
|
148
|
+
pending: boolean;
|
|
149
|
+
// Short-lived authorized read URL; null when this deployment has no object
|
|
150
|
+
// store. A deleted file has one only on its `before` side.
|
|
151
|
+
url?: string | null;
|
|
152
|
+
};
|
|
153
|
+
type MediaChange = {
|
|
154
|
+
status: "A" | "M" | "D" | "R";
|
|
155
|
+
set: string;
|
|
156
|
+
path: string;
|
|
157
|
+
previousPath?: string | null;
|
|
158
|
+
before?: MediaSide | null;
|
|
159
|
+
after?: MediaSide | null;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Human-readable file size, for the media change detail.
|
|
163
|
+
const formatSize = (bytes: number): string => {
|
|
164
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
165
|
+
const units = ["KB", "MB", "GB"];
|
|
166
|
+
let n = bytes / 1024;
|
|
167
|
+
let i = 0;
|
|
168
|
+
while (n >= 1024 && i < units.length - 1) {
|
|
169
|
+
n /= 1024;
|
|
170
|
+
i++;
|
|
171
|
+
}
|
|
172
|
+
return `${n < 10 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// A media file has no fields to diff, so its "before/after" is the file's own
|
|
176
|
+
// properties. Presenting them through the same DocumentChange shape lets the
|
|
177
|
+
// changes detail pane render an asset without a second component — and these
|
|
178
|
+
// really are what changed about the file.
|
|
179
|
+
const mediaChangeToDocument = (c: MediaChange): DocumentChange => {
|
|
180
|
+
// A side that doesn't exist is not a side that changed. An added file has no
|
|
181
|
+
// "before", so every property is `added` — rendering it as a change would put
|
|
182
|
+
// a red removed row against a value that never existed.
|
|
183
|
+
const kindFor = (before: string | undefined, after: string | undefined): FieldChangeKind => {
|
|
184
|
+
if (!c.before) return "added";
|
|
185
|
+
if (!c.after) return "removed";
|
|
186
|
+
return before === after ? "unchanged" : "changed";
|
|
187
|
+
};
|
|
188
|
+
const row = (name: string, label: string, pick: (s: MediaSide) => string): FieldChange => {
|
|
189
|
+
const before = c.before ? pick(c.before) : undefined;
|
|
190
|
+
const after = c.after ? pick(c.after) : undefined;
|
|
191
|
+
return { name, label, kind: kindFor(before, after), before, after };
|
|
192
|
+
};
|
|
193
|
+
return {
|
|
194
|
+
path: c.path,
|
|
195
|
+
previousPath: c.previousPath ?? undefined,
|
|
196
|
+
status: c.status,
|
|
197
|
+
label: c.path.split("/").pop() ?? c.path,
|
|
198
|
+
// A binary file has no line diff; the preview and metadata are the story.
|
|
199
|
+
added: 0,
|
|
200
|
+
removed: 0,
|
|
201
|
+
// The asset itself. `afterUrl` is absent for a deletion, which is what stops
|
|
202
|
+
// the pane previewing a file this branch removed.
|
|
203
|
+
preview: {
|
|
204
|
+
kind: c.after?.kind ?? c.before?.kind,
|
|
205
|
+
beforeUrl: c.before?.url,
|
|
206
|
+
afterUrl: c.after?.url,
|
|
207
|
+
},
|
|
208
|
+
fields: [
|
|
209
|
+
row("type", "Type", (s) => s.mimeType),
|
|
210
|
+
row("size", "Size", (s) => formatSize(s.size)),
|
|
211
|
+
row("dimensions", "Dimensions", (s) => (s.width && s.height ? `${s.width} × ${s.height}` : "—")),
|
|
212
|
+
],
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
type FieldVariantDef = { name: string; label?: string | null; fields: FieldDef[] };
|
|
216
|
+
type FieldDef = {
|
|
217
|
+
name: string; label?: string | null; type: string; required: boolean;
|
|
218
|
+
component?: string | null; source?: string | null; format?: string | null; flavor?: string | null;
|
|
219
|
+
media?: string | null; storeAs?: string | null;
|
|
220
|
+
of?: string | null; fields?: FieldDef[] | null; variants?: FieldVariantDef[] | null;
|
|
221
|
+
pattern?: string | null; enumValues?: string[] | null; discriminator?: string | null;
|
|
222
|
+
min?: number | null; max?: number | null; minItems?: number | null; maxItems?: number | null;
|
|
223
|
+
};
|
|
224
|
+
type Collection = {
|
|
225
|
+
name: string; label?: string | null; path: string; singleton: boolean; documentCount: number;
|
|
226
|
+
filename?: string | null; canCreate: boolean; canUpdate: boolean; canDelete: boolean; fields: FieldDef[];
|
|
227
|
+
};
|
|
228
|
+
type DocumentT = { id: string; path: string; label: string; fields: Record<string, unknown> | null; body?: string | null };
|
|
229
|
+
// An open desktop tab: a document id + the collection it belongs to.
|
|
230
|
+
// An open desktop column. `plugin` set → the column renders a plugin route
|
|
231
|
+
// instead of a document, and `id` is the synthetic plugin tab id rather than a
|
|
232
|
+
// document id. Both kinds share one list so ordering, drag-reorder and close
|
|
233
|
+
// stay a single implementation.
|
|
234
|
+
type TabRef = { id: string; collection: string; plugin?: PluginTabRef };
|
|
235
|
+
|
|
236
|
+
// The collection `label` is a Handlebars template ({{title}}) for document
|
|
237
|
+
// titles, not a display name — the sidebar/headers use a capitalized name.
|
|
238
|
+
const titleCase = (s: string) =>
|
|
239
|
+
s.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
240
|
+
|
|
241
|
+
// globBase returns a collection glob's wildcard-free base directory (e.g.
|
|
242
|
+
// "pages/**/*.md" → "pages"), the folder-tree root the content browser strips
|
|
243
|
+
// from each document path when drilling into a hierarchical collection.
|
|
244
|
+
const globBase = (glob: string): string => {
|
|
245
|
+
const base: string[] = [];
|
|
246
|
+
for (const seg of glob.split("/")) {
|
|
247
|
+
if (/[*?[{]/.test(seg)) break;
|
|
248
|
+
base.push(seg);
|
|
249
|
+
}
|
|
250
|
+
return base.join("/");
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// A document's `path` is its full repo path, which begins with the collection
|
|
254
|
+
// glob's base directory (e.g. "pages/foo.yaml" for "pages/**/*.yaml"). The URL
|
|
255
|
+
// already carries the collection as its own segment, so the item path is stored
|
|
256
|
+
// relative to that base — otherwise the base repeats (".../pages/pages/foo.yaml").
|
|
257
|
+
// toRelItemPath strips the base for the URL; toFullPath rejoins it for matching
|
|
258
|
+
// against a document's full path. (globBase is "" when the glob has no static
|
|
259
|
+
// base, in which case both are no-ops.)
|
|
260
|
+
const toRelItemPath = (collectionGlob: string, fullPath: string): string => {
|
|
261
|
+
const base = globBase(collectionGlob);
|
|
262
|
+
return base && fullPath.startsWith(base + "/") ? fullPath.slice(base.length + 1) : fullPath;
|
|
263
|
+
};
|
|
264
|
+
const toFullPath = (collectionGlob: string, relPath: string): string => {
|
|
265
|
+
const base = globBase(collectionGlob);
|
|
266
|
+
return base && relPath ? `${base}/${relPath}` : relPath;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// toEntryField maps a collection FieldDef + a value into the design-system's
|
|
270
|
+
// EntryField, carrying every validation constraint. Both the edit form (entries)
|
|
271
|
+
// and the create form (createFields) use it, so their rules can't drift apart.
|
|
272
|
+
const toEntryField = (f: FieldDef, value: unknown): EntryField => ({
|
|
273
|
+
name: f.name,
|
|
274
|
+
label: f.label ?? undefined,
|
|
275
|
+
type: f.type,
|
|
276
|
+
component: f.component ?? undefined,
|
|
277
|
+
source: f.source ?? undefined,
|
|
278
|
+
format: f.format ?? undefined,
|
|
279
|
+
flavor: f.flavor ?? undefined,
|
|
280
|
+
media: f.media ?? undefined,
|
|
281
|
+
storeAs: f.storeAs ?? undefined,
|
|
282
|
+
of: f.of ?? undefined,
|
|
283
|
+
discriminator: f.discriminator ?? undefined,
|
|
284
|
+
// Nested object/array children and mixed-list variants carry no value of their
|
|
285
|
+
// own — the parent's value holds the nested data; children only supply defs.
|
|
286
|
+
fields: f.fields ? f.fields.map((c) => toEntryField(c, undefined)) : undefined,
|
|
287
|
+
variants: f.variants
|
|
288
|
+
? f.variants.map((v) => ({ name: v.name, label: v.label ?? undefined, fields: v.fields.map((c) => toEntryField(c, undefined)) }))
|
|
289
|
+
: undefined,
|
|
290
|
+
value,
|
|
291
|
+
required: f.required,
|
|
292
|
+
pattern: f.pattern ?? undefined,
|
|
293
|
+
enumValues: f.enumValues ?? undefined,
|
|
294
|
+
min: f.min ?? undefined,
|
|
295
|
+
max: f.max ?? undefined,
|
|
296
|
+
minItems: f.minItems ?? undefined,
|
|
297
|
+
maxItems: f.maxItems ?? undefined,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Choices-first sign-in matching the dashboard: the landing shows magic-link,
|
|
301
|
+
// GitHub, and "Continue with Email"; the latter reveals the email/password form
|
|
302
|
+
// with a Back button. Magic-link reveals an email field, then a code field.
|
|
303
|
+
function Login({ onDone }: { onDone: (token: string) => void }) {
|
|
304
|
+
const t = useTheme();
|
|
305
|
+
const [mode, setMode] = useState<"choices" | "email" | "magic-link">("choices");
|
|
306
|
+
// "" rather than undefined: these back controlled <Input>s, and an undefined
|
|
307
|
+
// value makes React treat the field as uncontrolled until the first keystroke.
|
|
308
|
+
// The `if (!email)` guards below read an empty string the same way.
|
|
309
|
+
const [email, setEmail] = useState("");
|
|
310
|
+
const [password, setPassword] = useState("");
|
|
311
|
+
const [code, setCode] = useState("");
|
|
312
|
+
const [sent, setSent] = useState(false);
|
|
313
|
+
const [busy, setBusy] = useState(false);
|
|
314
|
+
const [error, setError] = useState("");
|
|
315
|
+
const [login, { loading }] = useMutation(LOGIN, {
|
|
316
|
+
onCompleted: (data) => onDone(data.login.accessToken),
|
|
317
|
+
onError: () => setError("Login failed — check your credentials."),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
function backToChoices() {
|
|
321
|
+
setMode("choices"); setSent(false); setError("");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function onSendMagicLink() {
|
|
325
|
+
if (!email) return;
|
|
326
|
+
setError(""); setBusy(true);
|
|
327
|
+
try {
|
|
328
|
+
await requestMagicLink(email);
|
|
329
|
+
setSent(true);
|
|
330
|
+
} catch (e) {
|
|
331
|
+
setError(e instanceof Error ? e.message : "Could not send the sign-in link.");
|
|
332
|
+
} finally {
|
|
333
|
+
setBusy(false);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function onVerifyCode() {
|
|
338
|
+
if (!code) return;
|
|
339
|
+
setError(""); setBusy(true);
|
|
340
|
+
try {
|
|
341
|
+
onDone(await redeemMagicLink(code.trim()));
|
|
342
|
+
} catch (e) {
|
|
343
|
+
setError(e instanceof Error ? e.message : "Invalid or expired code.");
|
|
344
|
+
} finally {
|
|
345
|
+
setBusy(false);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return (
|
|
350
|
+
<Screen center testID="login-screen">
|
|
351
|
+
<View style={{ width: "100%", maxWidth: 360, alignSelf: "center", gap: t.space(4) }}>
|
|
352
|
+
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
|
|
353
|
+
<Text variant="mono" weight="semibold">Go·Git CMS</Text>
|
|
354
|
+
<ThemeToggle size="sm" />
|
|
355
|
+
</View>
|
|
356
|
+
<Text variant="h2">Sign in</Text>
|
|
357
|
+
|
|
358
|
+
{mode === "choices" ? (
|
|
359
|
+
<>
|
|
360
|
+
<Button title="Email me a sign-in link" variant="primary" fullWidth onPress={() => setMode("magic-link")} testID="login-magic-link" />
|
|
361
|
+
<Button title="Continue with GitHub" variant="default" fullWidth onPress={beginGitHub} testID="login-github" />
|
|
362
|
+
<Button title="Continue with Email" variant="default" fullWidth onPress={() => setMode("email")} testID="login-continue-email" />
|
|
363
|
+
</>
|
|
364
|
+
) : mode === "email" ? (
|
|
365
|
+
<>
|
|
366
|
+
<Input label="Email" value={email} onChangeText={setEmail} placeholder="Enter your email" testID="login-email" />
|
|
367
|
+
<Input label="Password" value={password} onChangeText={setPassword} placeholder="Enter your password" secureTextEntry testID="login-password" />
|
|
368
|
+
{error ? <Text variant="sm" color={t.color.diffDelFg} testID="login-error">{error}</Text> : null}
|
|
369
|
+
<Button
|
|
370
|
+
title={loading ? "Signing in…" : "Sign in"}
|
|
371
|
+
variant="primary"
|
|
372
|
+
fullWidth
|
|
373
|
+
onPress={() => { setError(""); login({ variables: { email, password } }); }}
|
|
374
|
+
testID="login-submit"
|
|
375
|
+
/>
|
|
376
|
+
<Button title="Back" variant="ghost" fullWidth onPress={backToChoices} testID="login-back" />
|
|
377
|
+
</>
|
|
378
|
+
) : (
|
|
379
|
+
<>
|
|
380
|
+
{!sent ? (
|
|
381
|
+
<>
|
|
382
|
+
<Input label="Email" value={email} onChangeText={setEmail} placeholder="Enter your email" testID="login-email" />
|
|
383
|
+
<Button title={busy ? "Sending…" : "Email me a sign-in link"} variant="primary" fullWidth onPress={onSendMagicLink} testID="login-send-magic-link" />
|
|
384
|
+
</>
|
|
385
|
+
) : (
|
|
386
|
+
<>
|
|
387
|
+
<Text variant="sm" color="secondary">We emailed a code to {email}. Enter it below.</Text>
|
|
388
|
+
<Input label="Sign-in code" value={code} onChangeText={setCode} placeholder="Enter the code" testID="login-magic-code" />
|
|
389
|
+
<Button title={busy ? "Verifying…" : "Verify code"} variant="primary" fullWidth onPress={onVerifyCode} testID="login-verify-code" />
|
|
390
|
+
</>
|
|
391
|
+
)}
|
|
392
|
+
{error ? <Text variant="sm" color={t.color.diffDelFg} testID="login-error">{error}</Text> : null}
|
|
393
|
+
<Button title="Back" variant="ghost" fullWidth onPress={backToChoices} testID="login-back" />
|
|
394
|
+
</>
|
|
395
|
+
)}
|
|
396
|
+
</View>
|
|
397
|
+
</Screen>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// initialsOf builds a 1–2 letter monogram for the workspace avatar in the topbar.
|
|
402
|
+
function initialsOf(name: string): string {
|
|
403
|
+
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
404
|
+
if (parts.length === 0) return "?";
|
|
405
|
+
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
406
|
+
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// WorkspaceSlot is the topbar's left affordance. It renders inside TopBar's dark
|
|
410
|
+
// ThemeScope, so useTheme here resolves to the dark chrome palette (keeping the
|
|
411
|
+
// chevron legible). With a workspace it shows the monogram + name and, when
|
|
412
|
+
// onChangeWorkspace is given, is tappable to switch; without one it shows the
|
|
413
|
+
// "no workspace selected" state at the root of the selection flow.
|
|
414
|
+
function WorkspaceSlot({
|
|
415
|
+
workspaceName,
|
|
416
|
+
onChangeWorkspace,
|
|
417
|
+
}: {
|
|
418
|
+
workspaceName?: string;
|
|
419
|
+
onChangeWorkspace?: () => void;
|
|
420
|
+
}) {
|
|
421
|
+
const t = useTheme();
|
|
422
|
+
if (!workspaceName) {
|
|
423
|
+
return <Text variant="body" color="tertiary" testID="topbar-no-workspace">No workspace selected</Text>;
|
|
424
|
+
}
|
|
425
|
+
return (
|
|
426
|
+
<Pressable
|
|
427
|
+
onPress={onChangeWorkspace}
|
|
428
|
+
disabled={!onChangeWorkspace}
|
|
429
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
|
430
|
+
testID="topbar-workspace"
|
|
431
|
+
>
|
|
432
|
+
<Avatar initials={initialsOf(workspaceName)} size={24} />
|
|
433
|
+
<Text variant="body" weight="semibold" numberOfLines={1}>{workspaceName}</Text>
|
|
434
|
+
{onChangeWorkspace ? <Icon name="chevronDown" size={12} color={t.color.textTertiary} /> : null}
|
|
435
|
+
</Pressable>
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// EditorTopBar is the persistent editor chrome for the workspace → repository →
|
|
440
|
+
// branch selection screens, so the current workspace and sign-out are always
|
|
441
|
+
// reachable — not only once a repository is open (where ContentBrowser renders
|
|
442
|
+
// its own equivalent bar). It reuses the shared dark TopBar for visual parity.
|
|
443
|
+
function EditorTopBar({
|
|
444
|
+
workspaceName,
|
|
445
|
+
onChangeWorkspace,
|
|
446
|
+
onSignOut,
|
|
447
|
+
}: {
|
|
448
|
+
workspaceName?: string;
|
|
449
|
+
onChangeWorkspace?: () => void;
|
|
450
|
+
onSignOut: () => void;
|
|
451
|
+
}) {
|
|
452
|
+
return (
|
|
453
|
+
<TopBar
|
|
454
|
+
left={<WorkspaceSlot workspaceName={workspaceName} onChangeWorkspace={onChangeWorkspace} />}
|
|
455
|
+
right={
|
|
456
|
+
<>
|
|
457
|
+
<ThemeToggle size="sm" />
|
|
458
|
+
<IconButton name="logOut" onPress={onSignOut} size="sm" label="Sign out" testID="topbar-signout" />
|
|
459
|
+
<Avatar initials="ED" size={28} />
|
|
460
|
+
</>
|
|
461
|
+
}
|
|
462
|
+
/>
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function WorkspacePicker({
|
|
467
|
+
workspaces,
|
|
468
|
+
loading,
|
|
469
|
+
error,
|
|
470
|
+
onPick,
|
|
471
|
+
onSignOut,
|
|
472
|
+
}: {
|
|
473
|
+
workspaces: Workspace[];
|
|
474
|
+
loading: boolean;
|
|
475
|
+
error: boolean;
|
|
476
|
+
onPick: (workspace: Workspace) => void;
|
|
477
|
+
onSignOut: () => void;
|
|
478
|
+
}) {
|
|
479
|
+
const t = useTheme();
|
|
480
|
+
return (
|
|
481
|
+
<View style={{ flex: 1 }}>
|
|
482
|
+
<EditorTopBar onSignOut={onSignOut} />
|
|
483
|
+
<Screen center testID="workspace-picker">
|
|
484
|
+
<View style={{ width: "100%", maxWidth: 520, alignSelf: "center", gap: t.space(4) }}>
|
|
485
|
+
<Text variant="h2">Choose a workspace</Text>
|
|
486
|
+
{loading ? <Text color="secondary">Loading workspaces…</Text> : null}
|
|
487
|
+
{error ? <Text color={t.color.diffDelFg}>Could not load workspaces.</Text> : null}
|
|
488
|
+
{!loading && workspaces.length === 0 ? (
|
|
489
|
+
<Text color="secondary">You don’t belong to any workspaces yet.</Text>
|
|
490
|
+
) : null}
|
|
491
|
+
{workspaces.map((w) => (
|
|
492
|
+
<Button
|
|
493
|
+
key={w.id}
|
|
494
|
+
title={w.name}
|
|
495
|
+
fullWidth
|
|
496
|
+
onPress={() => onPick(w)}
|
|
497
|
+
testID={`workspace-${w.slug}`}
|
|
498
|
+
/>
|
|
499
|
+
))}
|
|
500
|
+
</View>
|
|
501
|
+
</Screen>
|
|
502
|
+
</View>
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function RepoPicker({
|
|
507
|
+
repos,
|
|
508
|
+
loading,
|
|
509
|
+
error,
|
|
510
|
+
onPick,
|
|
511
|
+
onSignOut,
|
|
512
|
+
workspaceName,
|
|
513
|
+
onChangeWorkspace,
|
|
514
|
+
}: {
|
|
515
|
+
repos: Repository[];
|
|
516
|
+
loading: boolean;
|
|
517
|
+
error: boolean;
|
|
518
|
+
onPick: (repo: Repository, branch: Branch) => void;
|
|
519
|
+
onSignOut: () => void;
|
|
520
|
+
workspaceName?: string;
|
|
521
|
+
onChangeWorkspace?: () => void;
|
|
522
|
+
}) {
|
|
523
|
+
const t = useTheme();
|
|
524
|
+
// Two-step: pick a repository, then a branch within it. The selected repo is
|
|
525
|
+
// local state — the branch is never serialized into the URL.
|
|
526
|
+
const [selected, setSelected] = useState<Repository | null>(null);
|
|
527
|
+
|
|
528
|
+
// Keep the selection valid as repos load/refresh (match by id).
|
|
529
|
+
const current = selected ? repos.find((r) => r.id === selected.id) ?? null : null;
|
|
530
|
+
|
|
531
|
+
return (
|
|
532
|
+
<View style={{ flex: 1 }}>
|
|
533
|
+
<EditorTopBar workspaceName={workspaceName} onChangeWorkspace={onChangeWorkspace} onSignOut={onSignOut} />
|
|
534
|
+
{current ? (
|
|
535
|
+
<Screen center testID="repo-picker">
|
|
536
|
+
<View style={{ width: "100%", maxWidth: 520, alignSelf: "center", gap: t.space(4) }}>
|
|
537
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
538
|
+
<IconButton name="chevronLeft" onPress={() => setSelected(null)} label="Back to repositories" />
|
|
539
|
+
<Text variant="h2" style={{ flex: 1 }}>Choose a branch</Text>
|
|
540
|
+
</View>
|
|
541
|
+
<Text variant="mono" color="secondary">{current.owner}/{current.name}</Text>
|
|
542
|
+
{current.branches.length === 0 ? (
|
|
543
|
+
<Text variant="sm" color="tertiary">No branches imported for this repository.</Text>
|
|
544
|
+
) : (
|
|
545
|
+
current.branches.map((b) => (
|
|
546
|
+
<Button key={b.id} title={b.name} fullWidth onPress={() => onPick(current, b)} testID={`branch-${b.id}`} />
|
|
547
|
+
))
|
|
548
|
+
)}
|
|
549
|
+
</View>
|
|
550
|
+
</Screen>
|
|
551
|
+
) : (
|
|
552
|
+
<Screen center testID="repo-picker">
|
|
553
|
+
<View style={{ width: "100%", maxWidth: 520, alignSelf: "center", gap: t.space(4) }}>
|
|
554
|
+
<Text variant="h2">Choose a repository</Text>
|
|
555
|
+
{loading ? <Text color="secondary">Loading repositories…</Text> : null}
|
|
556
|
+
{error ? <Text color={t.color.diffDelFg}>Could not load repositories.</Text> : null}
|
|
557
|
+
{!loading && repos.length === 0 ? (
|
|
558
|
+
<Text color="secondary">No repositories connected to your workspaces yet.</Text>
|
|
559
|
+
) : null}
|
|
560
|
+
{repos.map((r) => (
|
|
561
|
+
<Button
|
|
562
|
+
key={r.id}
|
|
563
|
+
title={`${r.owner}/${r.name}`}
|
|
564
|
+
fullWidth
|
|
565
|
+
onPress={() => setSelected(r)}
|
|
566
|
+
testID={`repo-${r.id}`}
|
|
567
|
+
/>
|
|
568
|
+
))}
|
|
569
|
+
</View>
|
|
570
|
+
</Screen>
|
|
571
|
+
)}
|
|
572
|
+
</View>
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Maps a GraphQL notification row to the design-system NotificationItem shape.
|
|
577
|
+
type GqlNotification = { id: string; title: string; body?: string | null; level: string; read: boolean; createdAt: string; authorName?: string | null; authorEmail?: string | null };
|
|
578
|
+
const toNotificationItem = (n: GqlNotification): NotificationItem => ({
|
|
579
|
+
id: n.id,
|
|
580
|
+
title: n.title,
|
|
581
|
+
body: n.body ?? undefined,
|
|
582
|
+
level: n.level === "error" ? "error" : "info",
|
|
583
|
+
read: n.read,
|
|
584
|
+
createdAt: n.createdAt,
|
|
585
|
+
author: n.authorName || n.authorEmail || undefined,
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// NotificationsScreen shows every notification for the repository context, opened
|
|
589
|
+
// from the bell's "Read more".
|
|
590
|
+
function NotificationsScreen({ items, onBack }: { items: NotificationItem[]; onBack: () => void }) {
|
|
591
|
+
const t = useTheme();
|
|
592
|
+
return (
|
|
593
|
+
<Screen padded={false} testID="notifications-screen">
|
|
594
|
+
<View
|
|
595
|
+
style={{
|
|
596
|
+
height: 48, flexDirection: "row", alignItems: "center", gap: t.space(2),
|
|
597
|
+
paddingHorizontal: t.space(3), borderBottomWidth: 1, borderBottomColor: t.color.borderSubtle,
|
|
598
|
+
}}
|
|
599
|
+
>
|
|
600
|
+
<IconButton name="chevronLeft" onPress={onBack} size="sm" label="Back" />
|
|
601
|
+
<Text variant="h3" weight="semibold">Notifications</Text>
|
|
602
|
+
</View>
|
|
603
|
+
<NotificationList notifications={items} />
|
|
604
|
+
</Screen>
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// TabDoc lazily loads one open tab's full document by id — its own loading cycle,
|
|
609
|
+
// independent of the active collection's list — and lifts the result to CmsView.
|
|
610
|
+
// It renders nothing; one is mounted per open tab.
|
|
611
|
+
// collabWsBase resolves the collaboration WebSocket endpoint, e.g.
|
|
612
|
+
// "wss://host/collab/ws". A deployment that runs the `collab` command as its own
|
|
613
|
+
// process sets COLLAB_URL to that process's origin; otherwise the socket lives
|
|
614
|
+
// on the API origin (or this page's, when the API is same-origin), which is what
|
|
615
|
+
// the single-process server serves.
|
|
616
|
+
function collabWsBase(): string {
|
|
617
|
+
// Local mode is single-user and mounts no collab endpoint. Returning "" makes
|
|
618
|
+
// useDocCollab's `enabled` false, so no provider is constructed and no socket
|
|
619
|
+
// is opened — a connection that could only ever fail is worse than none.
|
|
620
|
+
if (config.local) return "";
|
|
621
|
+
const base = config.collabUrl || config.apiUrl;
|
|
622
|
+
if (base) return base.replace(/^http/i, "ws").replace(/\/$/, "") + "/collab/ws";
|
|
623
|
+
if (typeof window === "undefined") return "";
|
|
624
|
+
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
|
625
|
+
return `${proto}://${window.location.host}/collab/ws`;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
type CollabUser = { id: string; name: string };
|
|
629
|
+
|
|
630
|
+
// TabDoc lazily loads one open tab's full document by id and, when a user
|
|
631
|
+
// identity is known, owns that document's live collaboration session — both are
|
|
632
|
+
// lifted to CmsView. It renders nothing; one is mounted per open tab.
|
|
633
|
+
function TabDoc({
|
|
634
|
+
repositoryId,
|
|
635
|
+
id,
|
|
636
|
+
user,
|
|
637
|
+
onLoaded,
|
|
638
|
+
onCollab,
|
|
639
|
+
}: {
|
|
640
|
+
repositoryId: string;
|
|
641
|
+
id: string;
|
|
642
|
+
user: CollabUser | null;
|
|
643
|
+
onLoaded: (id: string, doc: DocumentT) => void;
|
|
644
|
+
onCollab: (id: string, collab: DocCollab | null) => void;
|
|
645
|
+
}) {
|
|
646
|
+
const { data } = useQuery(DOCUMENT, { variables: { repositoryId, id } });
|
|
647
|
+
const doc = data?.document as DocumentT | null | undefined;
|
|
648
|
+
useEffect(() => {
|
|
649
|
+
if (doc) onLoaded(id, doc);
|
|
650
|
+
}, [doc, id, onLoaded]);
|
|
651
|
+
|
|
652
|
+
const collab = useDocCollab({
|
|
653
|
+
wsBase: collabWsBase(),
|
|
654
|
+
room: `${repositoryId}_${id}`,
|
|
655
|
+
user,
|
|
656
|
+
getToken: getAuthToken,
|
|
657
|
+
});
|
|
658
|
+
useEffect(() => {
|
|
659
|
+
onCollab(id, collab);
|
|
660
|
+
return () => onCollab(id, null);
|
|
661
|
+
}, [id, collab, onCollab]);
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function CmsView({
|
|
666
|
+
repo,
|
|
667
|
+
branch,
|
|
668
|
+
defaultBranchId,
|
|
669
|
+
projectName,
|
|
670
|
+
projects,
|
|
671
|
+
soleProjectName,
|
|
672
|
+
projectLocked,
|
|
673
|
+
importing,
|
|
674
|
+
onSignOut,
|
|
675
|
+
}: {
|
|
676
|
+
repo: Repository;
|
|
677
|
+
branch: Branch;
|
|
678
|
+
// The branch every other branch's changes are compared against. It is also
|
|
679
|
+
// what keeps the URL clean: navigation omits the branch segment when the
|
|
680
|
+
// current branch is this one.
|
|
681
|
+
defaultBranchId?: string;
|
|
682
|
+
// Which project's content this view shows. Undefined for a repository whose
|
|
683
|
+
// projects have not loaded, and for local mode; every content query passes it
|
|
684
|
+
// through, and the server applies the same defaulting rule when it is absent.
|
|
685
|
+
projectName?: string;
|
|
686
|
+
// The repository's non-orphaned projects, for the picker. One (or none) hides
|
|
687
|
+
// the picker entirely — a repository without a manifest should look exactly as
|
|
688
|
+
// it did before projects existed.
|
|
689
|
+
projects?: Project[];
|
|
690
|
+
// The sole project's name, which navigation uses to omit the URL segment.
|
|
691
|
+
soleProjectName?: string;
|
|
692
|
+
// True when cms.config.js pins this deployment to one project: the picker is
|
|
693
|
+
// not rendered and the choice is not the user's.
|
|
694
|
+
projectLocked?: boolean;
|
|
695
|
+
// An import is running over this branch's already-imported content. The bell
|
|
696
|
+
// spins for the duration; nothing else changes, because what is on screen is
|
|
697
|
+
// real until the import replaces it. (A branch's FIRST import never reaches
|
|
698
|
+
// here — BrowserScreen holds a full-screen state instead of mounting this.)
|
|
699
|
+
importing?: boolean;
|
|
700
|
+
onSignOut: () => void;
|
|
701
|
+
}) {
|
|
702
|
+
const { mode } = useThemeMode();
|
|
703
|
+
// Re-renders as plugins finish loading, which re-evaluates the render-field
|
|
704
|
+
// chain (and, below, the plugin sidebar/user-menu contributions).
|
|
705
|
+
const pluginVersion = usePluginRegistry(pluginRegistry);
|
|
706
|
+
// plugin:* components resolve from the registry; everything else falls
|
|
707
|
+
// through to the markdown body editor and then the built-in controls.
|
|
708
|
+
// pluginVersion is a dependency because the chain reads registry state that
|
|
709
|
+
// the version invalidates, not because it appears in the factory.
|
|
710
|
+
const renderField = useMemo(
|
|
711
|
+
() => createPluginRenderField(pluginRegistry, createMarkdownRenderField({ theme: mode })),
|
|
712
|
+
[mode, pluginVersion]
|
|
713
|
+
);
|
|
714
|
+
// The media data seam handed to the design system. Undefined when the server
|
|
715
|
+
// has no object store configured, which renders media fields read-only.
|
|
716
|
+
const media = useMediaApi(repo.id, branch.id, projectName);
|
|
717
|
+
const navigation = useNavigation<any>();
|
|
718
|
+
const route = useRoute();
|
|
719
|
+
const params = route.params as BrowserParams;
|
|
720
|
+
// Selection changes keep the current repo/branch fixed and only vary the CMS
|
|
721
|
+
// portion of the route. Callers may pass a partial selection; the rest resets.
|
|
722
|
+
// The surface is deliberately carried forward rather than reset — selecting a
|
|
723
|
+
// collection while looking at Changes should stay on Changes.
|
|
724
|
+
// The focused editor column as of the last committed render. focusedTabId is
|
|
725
|
+
// computed far below (it needs the open-tab state), so it is mirrored here to
|
|
726
|
+
// be readable from a navigation handler.
|
|
727
|
+
const focusedTabIdRef = useRef<string | null>(null);
|
|
728
|
+
// The column a URL-driven open should take over. Snapshotted when the
|
|
729
|
+
// navigation is issued, because a document opened by URL only becomes a tab
|
|
730
|
+
// once its id resolves — several renders later, by which point the focus has
|
|
731
|
+
// already moved onto that very document and can no longer name what it
|
|
732
|
+
// replaced. Consumed (and cleared) where the tab materializes.
|
|
733
|
+
const reuseTabIdRef = useRef<string | null>(null);
|
|
734
|
+
const go = (sel: Partial<CmsSelection>) => {
|
|
735
|
+
reuseTabIdRef.current = focusedTabIdRef.current;
|
|
736
|
+
navigation.navigate("Browser", {
|
|
737
|
+
workspaceId: params.workspaceId, repositoryId: repo.id, branchId: branch.id, defaultBranchId, projectName, soleProjectName,
|
|
738
|
+
// The comparison target rides along with the surface — selecting a change
|
|
739
|
+
// must not silently retarget the comparison back to the default branch.
|
|
740
|
+
targetBranchId: params.targetBranchId,
|
|
741
|
+
...EMPTY_SELECTION, surface: params.surface, ...sel,
|
|
742
|
+
});
|
|
743
|
+
};
|
|
744
|
+
// The changes surface: a read-only comparison of this branch against the
|
|
745
|
+
// repository's default branch. It shares the collection/path route shape with
|
|
746
|
+
// the editor, so a document is addressable on both.
|
|
747
|
+
const changesMode = params.surface === "changes";
|
|
748
|
+
// The comparison target: the URL's target when set, else the repository
|
|
749
|
+
// default. Kept as an id (undefined means "default", which the server also
|
|
750
|
+
// interprets), and validated below only for display.
|
|
751
|
+
const targetBranchId = params.targetBranchId ?? defaultBranchId;
|
|
752
|
+
// On the default branch there is nothing to compare against, so the segment is
|
|
753
|
+
// offered but disabled rather than leading to a permanently empty screen. When
|
|
754
|
+
// a non-default target is chosen, "source == target" is the empty case instead.
|
|
755
|
+
// Locally the comparison is the working tree against HEAD rather than one
|
|
756
|
+
// branch against another, so the single-branch case is a real comparison, not
|
|
757
|
+
// an empty one. (Apply stays hidden — see onApplyChanges below.)
|
|
758
|
+
const onDefaultBranch = config.local ? false : !targetBranchId || branch.id === targetBranchId;
|
|
759
|
+
const [showAllNotifications, setShowAllNotifications] = useState(false);
|
|
760
|
+
|
|
761
|
+
// Notifications for this repository context. Polls so the bell reflects new
|
|
762
|
+
// import/export outcomes without a manual refresh.
|
|
763
|
+
const { data: notifData, refetch: refetchNotifs } = useQuery(NOTIFICATIONS, {
|
|
764
|
+
variables: { repositoryId: repo.id, limit: 50 },
|
|
765
|
+
pollInterval: 30000,
|
|
766
|
+
});
|
|
767
|
+
const [markAllRead] = useMutation(MARK_NOTIFICATIONS_READ, {
|
|
768
|
+
variables: { repositoryId: repo.id },
|
|
769
|
+
});
|
|
770
|
+
const notifications: NotificationItem[] = (notifData?.notifications ?? []).map(toNotificationItem);
|
|
771
|
+
const unreadNotifications: number = notifData?.unreadNotificationCount ?? 0;
|
|
772
|
+
const { data: colData } = useQuery(COLLECTIONS, {
|
|
773
|
+
// `project` is undefined for a single-project repository, where the server
|
|
774
|
+
// applies the same defaulting rule — so the request is byte-identical to
|
|
775
|
+
// what it was before projects existed.
|
|
776
|
+
variables: { repositoryId: repo.id, branchId: branch.id, project: projectName ?? null },
|
|
777
|
+
});
|
|
778
|
+
const collections: Collection[] = colData?.collections ?? [];
|
|
779
|
+
|
|
780
|
+
// ---- changes surface data ------------------------------------------------
|
|
781
|
+
// All three are skipped entirely on the Edit surface and on the default
|
|
782
|
+
// branch, so the editor's request profile is unchanged by this feature.
|
|
783
|
+
// A null target lets the server fall back to the default branch, so an
|
|
784
|
+
// unretargeted comparison sends exactly what it always did.
|
|
785
|
+
const changesVars = { repositoryId: repo.id, branchId: branch.id, targetBranchId: params.targetBranchId ?? null };
|
|
786
|
+
const skipChanges = !changesMode || onDefaultBranch;
|
|
787
|
+
const { data: summaryData } = useQuery(BRANCH_CHANGE_SUMMARY, {
|
|
788
|
+
variables: changesVars,
|
|
789
|
+
skip: skipChanges,
|
|
790
|
+
});
|
|
791
|
+
const changeSummary: ChangeSummary[] = summaryData?.branchChangeSummary ?? [];
|
|
792
|
+
// Media changes come from their own query: an asset has no fields to diff, so
|
|
793
|
+
// folding it into branchChanges would mean rows where half the type is null.
|
|
794
|
+
const { data: mediaChangeData } = useQuery(BRANCH_MEDIA_CHANGES, {
|
|
795
|
+
variables: changesVars,
|
|
796
|
+
skip: skipChanges,
|
|
797
|
+
});
|
|
798
|
+
const mediaChanges: MediaChange[] = mediaChangeData?.branchMediaChanges ?? [];
|
|
799
|
+
|
|
800
|
+
// Resolve the active collection + selected document path from the URL params.
|
|
801
|
+
// A configure route carries only the singleton's file path, so we resolve its
|
|
802
|
+
// collection by matching that path once collections have loaded.
|
|
803
|
+
let activeNavKey = "";
|
|
804
|
+
let selectedPath = "";
|
|
805
|
+
if (params.plugin) {
|
|
806
|
+
// A plugin screen occupies the nav slot under its sidebar link's key; no
|
|
807
|
+
// collection resolves from it, so every document query below is skipped.
|
|
808
|
+
activeNavKey = pluginNavKey(params.plugin.id, params.plugin.path);
|
|
809
|
+
} else if (params.configure) {
|
|
810
|
+
selectedPath = params.itemPath;
|
|
811
|
+
activeNavKey = collections.find((c) => c.singleton && c.path === selectedPath)?.name ?? "";
|
|
812
|
+
} else if (params.collection) {
|
|
813
|
+
activeNavKey = params.collection;
|
|
814
|
+
if (isMediaNavKey(activeNavKey)) {
|
|
815
|
+
// A media path is a plain repository path — there is no collection glob to
|
|
816
|
+
// rejoin it against, so it is carried in the URL whole.
|
|
817
|
+
selectedPath = params.itemPath;
|
|
818
|
+
} else {
|
|
819
|
+
// params.itemPath is stored relative to the collection's glob base; rejoin
|
|
820
|
+
// it so it matches the document's full repo path below.
|
|
821
|
+
const col = collections.find((c) => c.name === activeNavKey) ?? null;
|
|
822
|
+
selectedPath = col && params.itemPath ? toFullPath(col.path, params.itemPath) : "";
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
const active = collections.find((c) => c.name === activeNavKey) ?? null;
|
|
826
|
+
// Media occupies the same nav slot (and URL segment) as a collection but has
|
|
827
|
+
// no documents, so every document query is skipped for it.
|
|
828
|
+
const mediaShowing = isMediaNavKey(activeNavKey);
|
|
829
|
+
// On Changes that nav key lists the changed assets instead of opening the
|
|
830
|
+
// media browser, so a media URL has one shape on both surfaces.
|
|
831
|
+
const mediaChangesShowing = changesMode && mediaShowing;
|
|
832
|
+
|
|
833
|
+
// The changed documents in the selected collection, and the selected one's
|
|
834
|
+
// field-level diff. Both are keyed by path, not document id: a deletion has no
|
|
835
|
+
// document on this branch, so a path is the only identity every change has.
|
|
836
|
+
const { data: changesData } = useQuery(BRANCH_CHANGES, {
|
|
837
|
+
variables: { ...changesVars, collection: activeNavKey || null },
|
|
838
|
+
skip: skipChanges || !activeNavKey || mediaChangesShowing,
|
|
839
|
+
});
|
|
840
|
+
const branchChanges: BranchChange[] = changesData?.branchChanges ?? [];
|
|
841
|
+
const { data: changeData, loading: loadingChange } = useQuery(BRANCH_CHANGE, {
|
|
842
|
+
variables: { ...changesVars, path: selectedPath },
|
|
843
|
+
// A media path is not a document; its diff comes from the media change list.
|
|
844
|
+
skip: skipChanges || !selectedPath || mediaChangesShowing,
|
|
845
|
+
});
|
|
846
|
+
// A selected media asset resolves from the already-loaded media change list —
|
|
847
|
+
// it needs no detail query, because a file's whole diff is its metadata.
|
|
848
|
+
const selectedMediaChange = mediaChangesShowing
|
|
849
|
+
? mediaChanges.find((c) => c.path === selectedPath)
|
|
850
|
+
: undefined;
|
|
851
|
+
const selectedChange: DocumentChange | undefined = selectedMediaChange
|
|
852
|
+
? mediaChangeToDocument(selectedMediaChange)
|
|
853
|
+
: changeData?.branchChange
|
|
854
|
+
? {
|
|
855
|
+
...changeData.branchChange,
|
|
856
|
+
previousPath: changeData.branchChange.previousPath ?? undefined,
|
|
857
|
+
fields: (changeData.branchChange.fields ?? []).map(
|
|
858
|
+
(f: { name: string; label?: string | null; kind: string; before?: unknown; after?: unknown }) => ({
|
|
859
|
+
name: f.name,
|
|
860
|
+
label: f.label ?? undefined,
|
|
861
|
+
kind: f.kind as DocumentChange["fields"][number]["kind"],
|
|
862
|
+
before: f.before,
|
|
863
|
+
after: f.after,
|
|
864
|
+
}),
|
|
865
|
+
),
|
|
866
|
+
}
|
|
867
|
+
: undefined;
|
|
868
|
+
|
|
869
|
+
// ---- apply changes (merge source → target) -------------------------------
|
|
870
|
+
// The modal opens on a confirmation step (applyOpen, no run yet), then — once
|
|
871
|
+
// the user confirms — on the run's live progress. Merging pushes to origin and
|
|
872
|
+
// deletes the source branch, so the confirmation is not skippable.
|
|
873
|
+
const [applyOpen, setApplyOpen] = useState(false);
|
|
874
|
+
// Whether a merge attempt has been started for the current modal opening. The
|
|
875
|
+
// modal shows its confirmation step until this is true, then the run's live
|
|
876
|
+
// progress. It resets to false each time the modal opens, so re-opening after
|
|
877
|
+
// resolving conflicts starts at "confirm" rather than showing the previous
|
|
878
|
+
// attempt's stale conflicted state.
|
|
879
|
+
const [applyStarted, setApplyStarted] = useState(false);
|
|
880
|
+
const [applyRunId, setApplyRunId] = useState<string | null>(null);
|
|
881
|
+
// The authoritative run for display. Updated from three sources so they can't
|
|
882
|
+
// disagree: the apply mutation (initial), the progress subscription (step +
|
|
883
|
+
// status transitions), and the resolve mutation (a conflict's resolution,
|
|
884
|
+
// which changes no status/step so the subscription never re-emits for it).
|
|
885
|
+
const [applyRun, setApplyRun] = useState<any>(null);
|
|
886
|
+
const [applyChangesMut] = useMutation(APPLY_CHANGES);
|
|
887
|
+
useSubscription(MERGE_RUN_PROGRESS, {
|
|
888
|
+
variables: { id: applyRunId },
|
|
889
|
+
skip: !applyRunId,
|
|
890
|
+
onData: ({ data }) => {
|
|
891
|
+
const run = data.data?.mergeRunProgress;
|
|
892
|
+
if (run) setApplyRun(run);
|
|
893
|
+
},
|
|
894
|
+
});
|
|
895
|
+
const liveRun = applyRun;
|
|
896
|
+
|
|
897
|
+
// Conflicts belong to a conflicted run for THIS source→target pair, whether the
|
|
898
|
+
// run is live (just applied) or was left conflicted from a prior attempt. They
|
|
899
|
+
// key the red per-field treatment in the detail and gate the Apply button.
|
|
900
|
+
const conflictRun = liveRun && liveRun.status === "conflicted" ? liveRun : null;
|
|
901
|
+
const conflicts: MergeConflictT[] = conflictRun?.conflicts ?? [];
|
|
902
|
+
const hasUnresolvedConflicts = conflicts.some((c) => !c.resolution);
|
|
903
|
+
// Unresolved conflicts drive the sidebar counts and the list-row highlight, so
|
|
904
|
+
// the user can see which collections and which documents still need attention.
|
|
905
|
+
const unresolvedConflicts = conflicts.filter((c) => !c.resolution);
|
|
906
|
+
const conflictedPaths = new Set(unresolvedConflicts.map((c) => c.path));
|
|
907
|
+
const conflictsByCollection = new Map<string, number>();
|
|
908
|
+
for (const c of unresolvedConflicts) {
|
|
909
|
+
conflictsByCollection.set(c.collection, (conflictsByCollection.get(c.collection) ?? 0) + 1);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// The modal reflects the run's live state, including "conflicted" (the modal
|
|
913
|
+
// has its own conflicted panel with a "Review conflicts" action). Undefined
|
|
914
|
+
// only before a run exists — which the modal renders as its confirmation step.
|
|
915
|
+
const applyProgress: MergeProgress | undefined = applyStarted && liveRun
|
|
916
|
+
? {
|
|
917
|
+
status: liveRun.status,
|
|
918
|
+
step: liveRun.step,
|
|
919
|
+
sourceBranch: liveRun.sourceBranch,
|
|
920
|
+
targetBranch: liveRun.targetBranch,
|
|
921
|
+
commit: liveRun.commit,
|
|
922
|
+
filesChanged: liveRun.filesChanged,
|
|
923
|
+
added: liveRun.added,
|
|
924
|
+
removed: liveRun.removed,
|
|
925
|
+
error: liveRun.error,
|
|
926
|
+
escalationFiles: liveRun.escalationFiles ?? [],
|
|
927
|
+
}
|
|
928
|
+
: undefined;
|
|
929
|
+
|
|
930
|
+
// The Apply button opens the confirmation step; the actual merge doesn't start
|
|
931
|
+
// until the user confirms. Reset applyStarted so re-opening after resolving
|
|
932
|
+
// conflicts shows the confirm step, not the previous attempt's conflicted panel.
|
|
933
|
+
const onApplyChanges = () => { setApplyStarted(false); setApplyOpen(true); };
|
|
934
|
+
const onConfirmApply = async () => {
|
|
935
|
+
if (!targetBranchId) return;
|
|
936
|
+
setApplyStarted(true);
|
|
937
|
+
try {
|
|
938
|
+
const res = await applyChangesMut({
|
|
939
|
+
variables: { repositoryId: repo.id, sourceBranchId: branch.id, targetBranchId },
|
|
940
|
+
});
|
|
941
|
+
const run = res.data?.applyChanges;
|
|
942
|
+
if (run) {
|
|
943
|
+
// Seed the modal as running so it shows the checklist immediately. The
|
|
944
|
+
// mutation returns the run's state at creation time — which, for a
|
|
945
|
+
// resumed conflicted run, is still "conflicted" until the background job
|
|
946
|
+
// flips it to running. The subscription then delivers the real
|
|
947
|
+
// transitions (comparing → merging → pushing → succeeded/conflicted).
|
|
948
|
+
setApplyRun({ ...run, status: "running", step: "comparing" });
|
|
949
|
+
setApplyRunId(run.id);
|
|
950
|
+
}
|
|
951
|
+
} catch (e: any) {
|
|
952
|
+
// Surface as a failed-looking modal so the user isn't left guessing.
|
|
953
|
+
setApplyRun({
|
|
954
|
+
id: "error", status: "failed", step: "comparing",
|
|
955
|
+
sourceBranch: branch.name, targetBranch: repo.branches.find((b) => b.id === targetBranchId)?.name ?? "",
|
|
956
|
+
error: e?.graphQLErrors?.[0]?.message || e?.message || "Couldn’t apply changes.",
|
|
957
|
+
conflicts: [],
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
// Cancel/dismiss. Keeps the run (and its conflicts) so the inline red field
|
|
962
|
+
// treatment persists after the modal closes; a fresh apply or success clears it.
|
|
963
|
+
const closeApply = () => { setApplyOpen(false); setApplyStarted(false); };
|
|
964
|
+
// On success the source branch is gone and the target holds the merge; drop
|
|
965
|
+
// into Edit on the target and refresh the branch list.
|
|
966
|
+
const doneApply = () => {
|
|
967
|
+
const targetId = liveRun?.targetBranch;
|
|
968
|
+
// The merge is done — tear the run down (source is gone) before leaving.
|
|
969
|
+
setApplyOpen(false);
|
|
970
|
+
setApplyStarted(false);
|
|
971
|
+
setApplyRunId(null);
|
|
972
|
+
setApplyRun(null);
|
|
973
|
+
void apollo.refetchQueries({ include: [REPOSITORIES] });
|
|
974
|
+
navigation.navigate("Browser", {
|
|
975
|
+
workspaceId: params.workspaceId, repositoryId: repo.id, branchId: targetBranchId, defaultBranchId, projectName, soleProjectName,
|
|
976
|
+
...EMPTY_SELECTION,
|
|
977
|
+
});
|
|
978
|
+
void targetId;
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
// Review conflicts: close the modal and let the inline red field treatment in
|
|
982
|
+
// the detail take over. The run id is kept so its conflicts stay live.
|
|
983
|
+
const reviewConflicts = () => setApplyOpen(false);
|
|
984
|
+
// Conflicts for the currently-selected document, mapped to the design system's
|
|
985
|
+
// shape (before = target/HEAD, after = source).
|
|
986
|
+
const selectedConflicts: FieldConflictT[] = conflicts
|
|
987
|
+
.filter((c) => c.path === selectedPath)
|
|
988
|
+
.map((c) => ({
|
|
989
|
+
id: c.id,
|
|
990
|
+
field: c.field,
|
|
991
|
+
isBody: c.isBody,
|
|
992
|
+
before: c.before,
|
|
993
|
+
after: c.after,
|
|
994
|
+
resolution: (c.resolution as FieldConflictT["resolution"]) ?? null,
|
|
995
|
+
}));
|
|
996
|
+
|
|
997
|
+
const [resolveConflictMut] = useMutation(RESOLVE_MERGE_CONFLICT);
|
|
998
|
+
const onResolveConflict = async (conflict: FieldConflictT, choice: string, override?: unknown) => {
|
|
999
|
+
if (!conflictRun) return;
|
|
1000
|
+
const res = await resolveConflictMut({
|
|
1001
|
+
variables: { runId: conflictRun.id, conflictId: conflict.id, choice, override: override ?? null },
|
|
1002
|
+
});
|
|
1003
|
+
// The subscription won't re-emit (status/step unchanged), so apply the
|
|
1004
|
+
// returned run — which carries the updated conflict resolutions — directly.
|
|
1005
|
+
if (res.data?.resolveMergeConflict) setApplyRun(res.data.resolveMergeConflict);
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
// ---- change requests (developer escalation) ------------------------------
|
|
1009
|
+
const changeRequestMode = params.surface === "changeRequest";
|
|
1010
|
+
// Is a change request already open for this source→target? Gates the Apply
|
|
1011
|
+
// button's "view change request" behavior. Skipped off the changes surfaces or
|
|
1012
|
+
// on the default branch (where merging isn't offered).
|
|
1013
|
+
const { data: openCRData } = useQuery(OPEN_CHANGE_REQUEST, {
|
|
1014
|
+
variables: { repositoryId: repo.id, sourceBranchId: branch.id, targetBranchId },
|
|
1015
|
+
// Change requests need a remote; local mode has none, so this never runs.
|
|
1016
|
+
skip: config.local || (!changesMode && !changeRequestMode) || onDefaultBranch || !targetBranchId,
|
|
1017
|
+
fetchPolicy: "cache-and-network",
|
|
1018
|
+
});
|
|
1019
|
+
const openCR = openCRData?.openChangeRequest ?? null;
|
|
1020
|
+
const changeRequestOpen = !!openCR;
|
|
1021
|
+
// The shareable link is the CMS change-request summary page (not the raw GitHub
|
|
1022
|
+
// PR): it carries the developer instructions and links out to GitHub itself.
|
|
1023
|
+
// Always a valid route; the modal only surfaces it when a change request is open.
|
|
1024
|
+
const changeRequestSummaryUrl = browserUrl({
|
|
1025
|
+
workspaceId: params.workspaceId, repositoryId: repo.id, branchId: branch.id, defaultBranchId, projectName, soleProjectName,
|
|
1026
|
+
targetBranchId: params.targetBranchId,
|
|
1027
|
+
...EMPTY_SELECTION,
|
|
1028
|
+
surface: "changeRequest",
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
// The escalation (needs_review) → change request flow. Navigating to the
|
|
1032
|
+
// summary surface after creation drops the developer on the PR details.
|
|
1033
|
+
const [creatingChangeRequest, setCreatingChangeRequest] = useState(false);
|
|
1034
|
+
const [createChangeRequestMut] = useMutation(CREATE_CHANGE_REQUEST);
|
|
1035
|
+
const goToChangeRequest = () => {
|
|
1036
|
+
setApplyOpen(false);
|
|
1037
|
+
navigation.navigate("Browser", {
|
|
1038
|
+
workspaceId: params.workspaceId, repositoryId: repo.id, branchId: branch.id, defaultBranchId, projectName, soleProjectName,
|
|
1039
|
+
targetBranchId: params.targetBranchId,
|
|
1040
|
+
...EMPTY_SELECTION,
|
|
1041
|
+
surface: "changeRequest",
|
|
1042
|
+
});
|
|
1043
|
+
};
|
|
1044
|
+
const onCreateChangeRequest = async (explanation: string) => {
|
|
1045
|
+
if (!applyRunId) return;
|
|
1046
|
+
setCreatingChangeRequest(true);
|
|
1047
|
+
try {
|
|
1048
|
+
// Refetch the exact open-CR queries (by variables) and wait for them, so the
|
|
1049
|
+
// branch immediately reads as frozen/locked and the shareable link is ready
|
|
1050
|
+
// — without this the lock only appeared after a full reload.
|
|
1051
|
+
await createChangeRequestMut({
|
|
1052
|
+
variables: { runId: applyRunId, explanation },
|
|
1053
|
+
awaitRefetchQueries: true,
|
|
1054
|
+
refetchQueries: [
|
|
1055
|
+
{ query: OPEN_CHANGE_REQUEST, variables: { repositoryId: repo.id, sourceBranchId: branch.id, targetBranchId } },
|
|
1056
|
+
{ query: CHANGE_REQUEST_FOR_BRANCH, variables: { repositoryId: repo.id, branchId: branch.id } },
|
|
1057
|
+
],
|
|
1058
|
+
});
|
|
1059
|
+
// Rather than navigating away, drop the needs_review progress so the modal
|
|
1060
|
+
// falls back to its "a change request is already open" panel with the link.
|
|
1061
|
+
setApplyStarted(false);
|
|
1062
|
+
} catch (e: any) {
|
|
1063
|
+
setApplyRun((prev: any) => ({
|
|
1064
|
+
...(prev ?? {}), status: "failed", step: "comparing",
|
|
1065
|
+
error: e?.graphQLErrors?.[0]?.message || e?.message || "Couldn’t open the change request.",
|
|
1066
|
+
}));
|
|
1067
|
+
} finally {
|
|
1068
|
+
setCreatingChangeRequest(false);
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
// Freeze: is the current branch the source of an open change request (any
|
|
1073
|
+
// target)? If so the CMS blocks edits to it, so the reset on a rejected PR
|
|
1074
|
+
// never loses work. Skipped on the default branch, which is never a CR source.
|
|
1075
|
+
const { data: frozenData } = useQuery(CHANGE_REQUEST_FOR_BRANCH, {
|
|
1076
|
+
variables: { repositoryId: repo.id, branchId: branch.id },
|
|
1077
|
+
skip: branch.id === defaultBranchId,
|
|
1078
|
+
fetchPolicy: "cache-and-network",
|
|
1079
|
+
});
|
|
1080
|
+
const frozenCR = frozenData?.changeRequestForBranch ?? null;
|
|
1081
|
+
const branchFrozen = !!frozenCR;
|
|
1082
|
+
// The frozen-branch banner's "Learn more" opens the apply modal, which — with a
|
|
1083
|
+
// change request open — shows its explanatory panel and the shareable link.
|
|
1084
|
+
const openChangeRequestModal = () => { setApplyStarted(false); setApplyOpen(true); };
|
|
1085
|
+
|
|
1086
|
+
// The summary page resolves its change request from the branch (frozenCR),
|
|
1087
|
+
// which works from any surface; openCR (target-specific) is the fallback.
|
|
1088
|
+
const summaryCR = frozenCR ?? openCR;
|
|
1089
|
+
// Live change-request detail for the summary page (description + comments).
|
|
1090
|
+
const { data: crDetailData } = useQuery(CHANGE_REQUEST, {
|
|
1091
|
+
variables: { id: summaryCR?.id },
|
|
1092
|
+
skip: !changeRequestMode || !summaryCR?.id,
|
|
1093
|
+
fetchPolicy: "cache-and-network",
|
|
1094
|
+
});
|
|
1095
|
+
const changeRequestDetail = crDetailData?.changeRequest ?? summaryCR;
|
|
1096
|
+
|
|
1097
|
+
const analytics = useAnalytics();
|
|
1098
|
+
|
|
1099
|
+
// View events are derived from the resolved route rather than from click
|
|
1100
|
+
// handlers, so a deep link, a browser back/forward and a tab activation all
|
|
1101
|
+
// count the same as a click — they are the same thing to the user.
|
|
1102
|
+
//
|
|
1103
|
+
// Nothing fires until collections have loaded: a configure route resolves its
|
|
1104
|
+
// collection by matching the path against them, so firing earlier would emit
|
|
1105
|
+
// one event with an unresolved collection and a second once it resolved. The
|
|
1106
|
+
// ref then collapses re-renders of the same view into a single event.
|
|
1107
|
+
const lastViewRef = useRef("");
|
|
1108
|
+
useEffect(() => {
|
|
1109
|
+
if (!analytics.enabled || collections.length === 0) return;
|
|
1110
|
+
|
|
1111
|
+
let event: typeof AnalyticsEvent.CollectionViewed
|
|
1112
|
+
| typeof AnalyticsEvent.CollectionItemViewed
|
|
1113
|
+
| typeof AnalyticsEvent.ConfigurationItemViewed;
|
|
1114
|
+
if (params.configure) {
|
|
1115
|
+
if (!selectedPath) return;
|
|
1116
|
+
event = AnalyticsEvent.ConfigurationItemViewed;
|
|
1117
|
+
} else if (!activeNavKey) {
|
|
1118
|
+
return; // no collection selected — nothing is being viewed
|
|
1119
|
+
} else {
|
|
1120
|
+
event = selectedPath ? AnalyticsEvent.CollectionItemViewed : AnalyticsEvent.CollectionViewed;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// The document path is the customer's content (file names routinely say
|
|
1124
|
+
// what the content is), so it identifies the view here but is never sent.
|
|
1125
|
+
const key = `${event}|${activeNavKey}|${selectedPath}`;
|
|
1126
|
+
if (key === lastViewRef.current) return;
|
|
1127
|
+
lastViewRef.current = key;
|
|
1128
|
+
|
|
1129
|
+
analytics.capture(event, {
|
|
1130
|
+
collection: activeNavKey || null,
|
|
1131
|
+
singleton: !!active?.singleton,
|
|
1132
|
+
});
|
|
1133
|
+
}, [analytics, collections.length, params.configure, activeNavKey, selectedPath, active?.singleton]);
|
|
1134
|
+
|
|
1135
|
+
// A `**`-globbed collection is browsed as a folder tree (built client-side from
|
|
1136
|
+
// the whole set), so it can't be server-paginated — load it in full. A flat
|
|
1137
|
+
// collection loads one page at a time and fetches more on scroll.
|
|
1138
|
+
const hierarchicalActive = !!active && !active.singleton && active.path.includes("**");
|
|
1139
|
+
|
|
1140
|
+
// ---- collection search + faceted filters ---------------------------------
|
|
1141
|
+
// `search` matches the title; `appliedFilters` are the faceted conditions the
|
|
1142
|
+
// list is currently narrowed by. Both reset when the collection changes.
|
|
1143
|
+
const [search, setSearch] = useState("");
|
|
1144
|
+
const [appliedFilters, setAppliedFilters] = useState<DocFilter[]>([]);
|
|
1145
|
+
useEffect(() => { setSearch(""); setAppliedFilters([]); }, [activeNavKey]);
|
|
1146
|
+
const trimmedSearch = search.trim();
|
|
1147
|
+
const searchActive = trimmedSearch !== "" || appliedFilters.length > 0;
|
|
1148
|
+
const searchVars = {
|
|
1149
|
+
search: trimmedSearch || null,
|
|
1150
|
+
filters: appliedFilters.length ? appliedFilters : null,
|
|
1151
|
+
};
|
|
1152
|
+
// A search flattens results across folders, so a hierarchical collection is
|
|
1153
|
+
// paginated (not tree-browsed) while a search is active.
|
|
1154
|
+
const paginate = !hierarchicalActive || searchActive;
|
|
1155
|
+
|
|
1156
|
+
const { data: docData, refetch: refetchDocs, fetchMore: fetchMoreDocs } = useQuery(DOCUMENTS, {
|
|
1157
|
+
variables: {
|
|
1158
|
+
repositoryId: repo.id,
|
|
1159
|
+
branchId: branch.id,
|
|
1160
|
+
project: projectName ?? null,
|
|
1161
|
+
collection: activeNavKey,
|
|
1162
|
+
// null → the server returns the whole (unsearched) collection for the tree.
|
|
1163
|
+
limit: paginate ? PAGE_SIZE : null,
|
|
1164
|
+
offset: 0,
|
|
1165
|
+
...searchVars,
|
|
1166
|
+
},
|
|
1167
|
+
// The changes surface never lists documents — its list comes from
|
|
1168
|
+
// branchChanges — so the editor's paging queries stay off there entirely.
|
|
1169
|
+
// A plugin screen's nav key names no collection, so it never queries either.
|
|
1170
|
+
skip: !activeNavKey || mediaShowing || changesMode || !!params.plugin,
|
|
1171
|
+
});
|
|
1172
|
+
const documents: DocumentT[] = docData?.documents ?? [];
|
|
1173
|
+
|
|
1174
|
+
// When a search/filter is active the matching total comes from documentCount
|
|
1175
|
+
// (not the collection's full count), so pagination stops at the right place.
|
|
1176
|
+
const { data: countData } = useQuery(DOCUMENT_COUNT, {
|
|
1177
|
+
variables: { repositoryId: repo.id, branchId: branch.id, project: projectName ?? null, collection: activeNavKey, ...searchVars },
|
|
1178
|
+
skip: !activeNavKey || mediaShowing || changesMode || !searchActive || !!params.plugin,
|
|
1179
|
+
});
|
|
1180
|
+
const total = searchActive ? (countData?.documentCount ?? 0) : (active?.documentCount ?? 0);
|
|
1181
|
+
|
|
1182
|
+
// Server-side pagination for the flat entry list: more pages exist while we've
|
|
1183
|
+
// loaded fewer than the matching total. fetchMore appends the next offset
|
|
1184
|
+
// window; the Apollo `documents` field policy concatenates it (per search).
|
|
1185
|
+
const hasMoreEntries = !!active && paginate && documents.length < total;
|
|
1186
|
+
const [loadingMoreEntries, setLoadingMoreEntries] = useState(false);
|
|
1187
|
+
const onLoadMoreEntries = useCallback(() => {
|
|
1188
|
+
if (loadingMoreEntries || !hasMoreEntries) return;
|
|
1189
|
+
setLoadingMoreEntries(true);
|
|
1190
|
+
void fetchMoreDocs({ variables: { offset: documents.length, limit: PAGE_SIZE } })
|
|
1191
|
+
.finally(() => setLoadingMoreEntries(false));
|
|
1192
|
+
}, [loadingMoreEntries, hasMoreEntries, documents.length, fetchMoreDocs]);
|
|
1193
|
+
|
|
1194
|
+
// After a mutation changes the set (create/delete/rename/move) the accumulated
|
|
1195
|
+
// pages are stale — drop every cached `documents` window and refetch the first
|
|
1196
|
+
// page fresh so the list can't show deleted rows or a wrong order.
|
|
1197
|
+
const apollo = useApolloClient();
|
|
1198
|
+
const reloadDocs = useCallback(async () => {
|
|
1199
|
+
apollo.cache.evict({ id: "ROOT_QUERY", fieldName: "documents" });
|
|
1200
|
+
apollo.cache.gc();
|
|
1201
|
+
await refetchDocs();
|
|
1202
|
+
}, [apollo, refetchDocs]);
|
|
1203
|
+
|
|
1204
|
+
// Deep-link resolution vs. pagination: the selected doc's id (and thus its open
|
|
1205
|
+
// tab) is looked up by matching selectedPath in the loaded pages. If a cold deep
|
|
1206
|
+
// link points past the first page, keep pulling pages until it appears (bounded
|
|
1207
|
+
// by the collection size, and only while a selection is still unresolved).
|
|
1208
|
+
useEffect(() => {
|
|
1209
|
+
if (!activeNavKey || !selectedPath || searchActive) return;
|
|
1210
|
+
if (documents.some((d) => d.path === selectedPath)) return; // resolved
|
|
1211
|
+
if (hasMoreEntries && !loadingMoreEntries) onLoadMoreEntries();
|
|
1212
|
+
}, [activeNavKey, selectedPath, documents, hasMoreEntries, loadingMoreEntries, onLoadMoreEntries, searchActive]);
|
|
1213
|
+
|
|
1214
|
+
// ---- search callbacks handed to ContentBrowser ---------------------------
|
|
1215
|
+
// The header search input pushes its (debounced) text here; the advanced panel
|
|
1216
|
+
// pushes/clears the applied filter set; onCountMatches powers its optimistic
|
|
1217
|
+
// "Show N results" for a draft (not-yet-applied) filter set.
|
|
1218
|
+
const onSearchChange = useCallback((q: string) => setSearch(q), []);
|
|
1219
|
+
const onApplyFilters = useCallback(
|
|
1220
|
+
(f: DocFilter[]) => {
|
|
1221
|
+
setAppliedFilters(f);
|
|
1222
|
+
// Only an applied, non-empty filter set is a search; clearing is not.
|
|
1223
|
+
// Field names are schema metadata and safe to send — the values the user
|
|
1224
|
+
// typed are their content and are not.
|
|
1225
|
+
if (f.length) {
|
|
1226
|
+
analytics.capture(AnalyticsEvent.AdvancedSearchExecuted, {
|
|
1227
|
+
collection: activeNavKey || null,
|
|
1228
|
+
filter_count: f.length,
|
|
1229
|
+
fields: f.map((x) => x.field).sort().join(","),
|
|
1230
|
+
ops: f.map((x) => x.op).sort().join(","),
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
},
|
|
1234
|
+
[analytics, activeNavKey],
|
|
1235
|
+
);
|
|
1236
|
+
|
|
1237
|
+
// A settled query, not a keystroke: the header input is already debounced, but
|
|
1238
|
+
// it still emits per character, and one event per letter typed would be both
|
|
1239
|
+
// noise and a way to reconstruct the query. Only the length is reported.
|
|
1240
|
+
//
|
|
1241
|
+
// The ref keeps one search to one event. Applying a filter re-runs this effect
|
|
1242
|
+
// (with_filters changes) while the query is untouched, which would otherwise
|
|
1243
|
+
// report the same search a second time and inflate the count. Clearing the
|
|
1244
|
+
// query resets it, so searching the same term again does count again.
|
|
1245
|
+
const lastSearchRef = useRef("");
|
|
1246
|
+
useEffect(() => {
|
|
1247
|
+
if (!analytics.enabled) return;
|
|
1248
|
+
if (!trimmedSearch) {
|
|
1249
|
+
lastSearchRef.current = "";
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
const key = `${activeNavKey}|${trimmedSearch}`;
|
|
1253
|
+
if (key === lastSearchRef.current) return;
|
|
1254
|
+
const id = setTimeout(() => {
|
|
1255
|
+
lastSearchRef.current = key;
|
|
1256
|
+
analytics.capture(AnalyticsEvent.BasicSearchExecuted, {
|
|
1257
|
+
collection: activeNavKey || null,
|
|
1258
|
+
query_length: trimmedSearch.length,
|
|
1259
|
+
with_filters: appliedFilters.length > 0,
|
|
1260
|
+
});
|
|
1261
|
+
}, 800);
|
|
1262
|
+
return () => clearTimeout(id);
|
|
1263
|
+
}, [analytics, trimmedSearch, activeNavKey, appliedFilters.length]);
|
|
1264
|
+
const onClearSearch = useCallback(() => { setSearch(""); setAppliedFilters([]); }, []);
|
|
1265
|
+
const onCountMatches = useCallback(
|
|
1266
|
+
async (params: { query: string; filters: DocFilter[] }) => {
|
|
1267
|
+
const res = await apollo.query({
|
|
1268
|
+
query: DOCUMENT_COUNT,
|
|
1269
|
+
variables: {
|
|
1270
|
+
repositoryId: repo.id,
|
|
1271
|
+
branchId: branch.id,
|
|
1272
|
+
collection: activeNavKey,
|
|
1273
|
+
search: params.query.trim() || null,
|
|
1274
|
+
filters: params.filters.length ? params.filters : null,
|
|
1275
|
+
},
|
|
1276
|
+
fetchPolicy: "network-only",
|
|
1277
|
+
});
|
|
1278
|
+
return (res.data?.documentCount ?? 0) as number;
|
|
1279
|
+
},
|
|
1280
|
+
[apollo, repo.id, branch.id, activeNavKey],
|
|
1281
|
+
);
|
|
1282
|
+
// Filterable fields for the faceted panel: the collection's schema fields minus
|
|
1283
|
+
// the body (a large text blob, not a facet). The DS renders a control per type.
|
|
1284
|
+
const facetFields: FacetField[] = active
|
|
1285
|
+
? active.fields
|
|
1286
|
+
.filter((f) => f.component !== "body" && f.source !== "body")
|
|
1287
|
+
.map((f) => ({
|
|
1288
|
+
name: f.name,
|
|
1289
|
+
label: f.label ?? undefined,
|
|
1290
|
+
type: f.type,
|
|
1291
|
+
enumValues: f.enumValues ?? undefined,
|
|
1292
|
+
component: f.component ?? undefined,
|
|
1293
|
+
}))
|
|
1294
|
+
: [];
|
|
1295
|
+
|
|
1296
|
+
// ---- open editor tabs (host-owned; each loads its own document) ----------
|
|
1297
|
+
// Ephemeral set of open desktop columns as {id, collection}. Persists across
|
|
1298
|
+
// collection switches — a tab owns its data by id, not via the active list.
|
|
1299
|
+
const [openTabs, setOpenTabs] = useState<TabRef[]>([]);
|
|
1300
|
+
// Full documents loaded per open tab (lazy, keyed by id), lifted from TabDoc.
|
|
1301
|
+
const [loaded, setLoaded] = useState<Record<string, DocumentT>>({});
|
|
1302
|
+
|
|
1303
|
+
// The selected document id for the detail pane: match the route's file path
|
|
1304
|
+
// against the active collection's loaded pages, then against any open tab's
|
|
1305
|
+
// loaded document — a tab from another collection resolves only here, since
|
|
1306
|
+
// its path never appears in the active list — or fall back to a singleton's
|
|
1307
|
+
// sole document.
|
|
1308
|
+
// On the changes surface an entry IS a path, so the route's path is the id
|
|
1309
|
+
// directly — there is no document to resolve it against.
|
|
1310
|
+
const selectedId: string | null = changesMode
|
|
1311
|
+
? selectedPath || null
|
|
1312
|
+
: documents.find((d) => d.path === selectedPath)?.id ??
|
|
1313
|
+
(selectedPath
|
|
1314
|
+
? openTabs.find((t: TabRef) => !t.plugin && loaded[t.id]?.path === selectedPath)?.id ?? null
|
|
1315
|
+
: null) ??
|
|
1316
|
+
(active?.singleton && documents.length === 1 ? documents[0].id : null);
|
|
1317
|
+
|
|
1318
|
+
// The focused column. The URL names it whenever it points at a document; when
|
|
1319
|
+
// a route change drops the item segment (switching collections, opening media
|
|
1320
|
+
// or a plugin screen, toggling surfaces), the last focused column keeps its
|
|
1321
|
+
// focus — those navigations leave the column set unchanged, so they must
|
|
1322
|
+
// leave its focus unchanged too. The create pane is the exception: it takes
|
|
1323
|
+
// the focus itself, so no document column should read as active under it.
|
|
1324
|
+
const [lastFocusedTabId, setLastFocusedTabId] = useState<string | null>(null);
|
|
1325
|
+
useEffect(() => {
|
|
1326
|
+
if (!changesMode && selectedId) setLastFocusedTabId(selectedId);
|
|
1327
|
+
}, [selectedId, changesMode]);
|
|
1328
|
+
const focusedTabId: string | null =
|
|
1329
|
+
!changesMode && !selectedId && !params.create && openTabs.some((t: TabRef) => t.id === lastFocusedTabId)
|
|
1330
|
+
? lastFocusedTabId
|
|
1331
|
+
: selectedId;
|
|
1332
|
+
useEffect(() => {
|
|
1333
|
+
focusedTabIdRef.current = focusedTabId;
|
|
1334
|
+
}, [focusedTabId]);
|
|
1335
|
+
const handleLoaded = useCallback(
|
|
1336
|
+
(id: string, doc: DocumentT) =>
|
|
1337
|
+
setLoaded((prev: Record<string, DocumentT>) => (prev[id] === doc ? prev : { ...prev, [id]: doc })),
|
|
1338
|
+
[],
|
|
1339
|
+
);
|
|
1340
|
+
// Fans in-flight edits out to whichever plugin tabs are watching a document.
|
|
1341
|
+
// One per browser instance, never recreated — a new bus would silently drop
|
|
1342
|
+
// every existing subscription.
|
|
1343
|
+
const draftBus = useRef(new DraftBus()).current;
|
|
1344
|
+
|
|
1345
|
+
// Current user identity for collaborative presence (email local-part as label).
|
|
1346
|
+
const { data: meData } = useQuery(ME);
|
|
1347
|
+
const collabUser: CollabUser | null = useMemo(() => {
|
|
1348
|
+
const me = meData?.me as { id: string; email: string } | undefined;
|
|
1349
|
+
if (!me) return null;
|
|
1350
|
+
return { id: me.id, name: me.email.split("@")[0] || me.email };
|
|
1351
|
+
}, [meData]);
|
|
1352
|
+
// Live collaboration session per open tab (keyed by document id), lifted from
|
|
1353
|
+
// TabDoc so the built CmsEntry can bind its text fields to the shared CRDT.
|
|
1354
|
+
const [collabById, setCollabById] = useState<Record<string, DocCollab>>({});
|
|
1355
|
+
const handleCollab = useCallback((id: string, collab: DocCollab | null) => {
|
|
1356
|
+
setCollabById((prev: Record<string, DocCollab>) => {
|
|
1357
|
+
if (collab) {
|
|
1358
|
+
if (prev[id] === collab) return prev;
|
|
1359
|
+
return { ...prev, [id]: collab };
|
|
1360
|
+
}
|
|
1361
|
+
if (!(id in prev)) return prev;
|
|
1362
|
+
const next = { ...prev };
|
|
1363
|
+
delete next[id];
|
|
1364
|
+
return next;
|
|
1365
|
+
});
|
|
1366
|
+
}, []);
|
|
1367
|
+
// The URL-selected document always materializes as a tab.
|
|
1368
|
+
useEffect(() => {
|
|
1369
|
+
// The changes surface has no editor tabs — its selection is a diff, not an
|
|
1370
|
+
// open document — so a selection there must not materialize a column.
|
|
1371
|
+
if (!selectedId || !activeNavKey || changesMode) return;
|
|
1372
|
+
// Read before the updater runs (it runs on the *next* render) and clear, so
|
|
1373
|
+
// a selection that arrives without a navigation — a deep link, a restored
|
|
1374
|
+
// route — never takes over a column on a stale snapshot.
|
|
1375
|
+
const reuse = reuseTabIdRef.current;
|
|
1376
|
+
reuseTabIdRef.current = null;
|
|
1377
|
+
setOpenTabs((prev: TabRef[]) => {
|
|
1378
|
+
if (prev.some((t) => t.id === selectedId)) return prev;
|
|
1379
|
+
const tab: TabRef = { id: selectedId, collection: activeNavKey };
|
|
1380
|
+
// Take over the column the navigation moved away from, exactly as a plain
|
|
1381
|
+
// list click does. Without this a singleton — which is opened straight
|
|
1382
|
+
// from the sidebar, with no list click to route through onOpenEntry —
|
|
1383
|
+
// stacks a fresh column on every open. A plugin column is never taken
|
|
1384
|
+
// over, and with nothing focused the tab is appended.
|
|
1385
|
+
const idx = prev.findIndex((t) => t.id === reuse && !t.plugin);
|
|
1386
|
+
if (idx === -1) return [...prev, tab];
|
|
1387
|
+
const next = [...prev];
|
|
1388
|
+
next[idx] = tab;
|
|
1389
|
+
return next;
|
|
1390
|
+
});
|
|
1391
|
+
}, [selectedId, activeNavKey, changesMode]);
|
|
1392
|
+
// buildEntry joins a loaded document with its collection's field defs into the
|
|
1393
|
+
// design-system CmsEntry (schema fields + body), matching the old list mapping.
|
|
1394
|
+
const buildEntry = (doc: DocumentT, collectionName: string): CmsEntry => {
|
|
1395
|
+
const col = collections.find((c) => c.name === collectionName);
|
|
1396
|
+
return {
|
|
1397
|
+
id: doc.id,
|
|
1398
|
+
path: doc.path,
|
|
1399
|
+
title: doc.label,
|
|
1400
|
+
body: doc.body ?? "",
|
|
1401
|
+
fields: col
|
|
1402
|
+
? col.fields.map((f) => toEntryField(f, f.source === "body" ? (doc.body ?? "") : doc.fields?.[f.name]))
|
|
1403
|
+
: undefined,
|
|
1404
|
+
collab: collabById[doc.id],
|
|
1405
|
+
};
|
|
1406
|
+
};
|
|
1407
|
+
// The plugin-facing view of a loaded document: the full frontmatter map plus
|
|
1408
|
+
// the body kept separate, and the name of the field the body is exposed as so
|
|
1409
|
+
// a consumer can patch the right key without re-deriving it from the schema.
|
|
1410
|
+
const toPluginDoc = useCallback(
|
|
1411
|
+
(doc: DocumentT, collectionName: string): PluginDocument => {
|
|
1412
|
+
const col = collections.find((c) => c.name === collectionName);
|
|
1413
|
+
return {
|
|
1414
|
+
id: doc.id,
|
|
1415
|
+
collection: collectionName,
|
|
1416
|
+
path: doc.path,
|
|
1417
|
+
fields: doc.fields ?? {},
|
|
1418
|
+
body: doc.body ?? undefined,
|
|
1419
|
+
bodyField: col?.fields.find((f) => f.source === "body")?.name,
|
|
1420
|
+
};
|
|
1421
|
+
},
|
|
1422
|
+
[collections],
|
|
1423
|
+
);
|
|
1424
|
+
|
|
1425
|
+
// Open one of a plugin's routes as a column. Re-opening the same tab id
|
|
1426
|
+
// activates the existing column instead of stacking a second one.
|
|
1427
|
+
const openPluginTab = useCallback(
|
|
1428
|
+
(pluginId: string, spec: PluginTabSpec, documentId: string | null) => {
|
|
1429
|
+
const id = pluginTabId(pluginId, spec);
|
|
1430
|
+
setOpenTabs((prev: TabRef[]) => {
|
|
1431
|
+
const plugin: PluginTabRef = {
|
|
1432
|
+
pluginId,
|
|
1433
|
+
route: spec.route,
|
|
1434
|
+
title: spec.title,
|
|
1435
|
+
params: spec.params,
|
|
1436
|
+
documentId,
|
|
1437
|
+
};
|
|
1438
|
+
const at = prev.findIndex((t) => t.id === id);
|
|
1439
|
+
if (at !== -1) {
|
|
1440
|
+
const next = [...prev];
|
|
1441
|
+
next[at] = { ...next[at], plugin };
|
|
1442
|
+
return next;
|
|
1443
|
+
}
|
|
1444
|
+
// Placed immediately after the document it was opened from, so a
|
|
1445
|
+
// preview appears beside its document rather than at the far right of a
|
|
1446
|
+
// wide column set where the user would have to go looking for it.
|
|
1447
|
+
const anchor = documentId ? prev.findIndex((t) => t.id === documentId) : -1;
|
|
1448
|
+
const tab: TabRef = { id, collection: "", plugin };
|
|
1449
|
+
if (anchor === -1) return [...prev, tab];
|
|
1450
|
+
return [...prev.slice(0, anchor + 1), tab, ...prev.slice(anchor + 1)];
|
|
1451
|
+
});
|
|
1452
|
+
},
|
|
1453
|
+
[],
|
|
1454
|
+
);
|
|
1455
|
+
const closePluginTab = useCallback(
|
|
1456
|
+
(id: string) => setOpenTabs((prev: TabRef[]) => prev.filter((t) => t.id !== id)),
|
|
1457
|
+
[],
|
|
1458
|
+
);
|
|
1459
|
+
|
|
1460
|
+
// Open a document in the editor by id — the inverse direction, for a plugin
|
|
1461
|
+
// reporting a click in whatever it renders (click-to-edit from a preview).
|
|
1462
|
+
//
|
|
1463
|
+
// The implementation reads a lot of per-render state, but the exposed callback
|
|
1464
|
+
// must keep a stable identity: it is a memo dependency of every plugin tab
|
|
1465
|
+
// body, and a fresh function each render would re-run the plugin's effects
|
|
1466
|
+
// (for a preview plugin, reloading its iframe) on every keystroke.
|
|
1467
|
+
const openDocumentImpl = useRef<(id: string, field?: string) => void>(() => {});
|
|
1468
|
+
openDocumentImpl.current = (id: string) => {
|
|
1469
|
+
const doc = loaded[id] ?? documents.find((d) => d.id === id);
|
|
1470
|
+
if (!doc) return;
|
|
1471
|
+
const collectionName =
|
|
1472
|
+
openTabs.find((t: TabRef) => t.id === id && !t.plugin)?.collection ?? activeNavKey;
|
|
1473
|
+
const col = collections.find((c) => c.name === collectionName) ?? null;
|
|
1474
|
+
if (!col) return;
|
|
1475
|
+
if (col.singleton) go({ configure: true, collection: "", itemPath: doc.path });
|
|
1476
|
+
else go({ configure: false, collection: collectionName, itemPath: toRelItemPath(col.path, doc.path) });
|
|
1477
|
+
};
|
|
1478
|
+
const openDocumentById = useCallback((id: string, field?: string) => {
|
|
1479
|
+
openDocumentImpl.current(id, field);
|
|
1480
|
+
}, []);
|
|
1481
|
+
|
|
1482
|
+
// goToTab points the URL (and thus the active collection) at a tab's document.
|
|
1483
|
+
const goToTab = (tab: TabRef) => {
|
|
1484
|
+
// A plugin column has no document, so there is no URL to point at — the
|
|
1485
|
+
// selection stays where it was and the click is just a focus change.
|
|
1486
|
+
if (tab.plugin) return;
|
|
1487
|
+
const col = collections.find((c) => c.name === tab.collection) ?? null;
|
|
1488
|
+
const path = loaded[tab.id]?.path;
|
|
1489
|
+
if (col?.singleton) go({ configure: true, collection: "", itemPath: path ?? col.path });
|
|
1490
|
+
else go({ configure: false, collection: tab.collection, itemPath: col && path ? toRelItemPath(col.path, path) : "" });
|
|
1491
|
+
};
|
|
1492
|
+
|
|
1493
|
+
// URL-writing selection handlers handed to ContentBrowser.
|
|
1494
|
+
const onSelectNav = (key: string) => {
|
|
1495
|
+
// `go({})` rather than go(EMPTY_SELECTION): the latter's surface:"edit" would
|
|
1496
|
+
// win the spread and silently kick you off the Changes surface.
|
|
1497
|
+
if (!key) return go({});
|
|
1498
|
+
// A plugin sidebar link: plugin screens live on the edit surface only, so
|
|
1499
|
+
// the surface is written rather than carried.
|
|
1500
|
+
const pluginKey = parsePluginNavKey(key);
|
|
1501
|
+
if (pluginKey) return go({ surface: "edit", plugin: { id: pluginKey.id, path: pluginKey.path } });
|
|
1502
|
+
const c = collections.find((col) => col.name === key);
|
|
1503
|
+
if (c?.singleton) go({ configure: true, collection: "", itemPath: c.path });
|
|
1504
|
+
else go({ configure: false, collection: key, itemPath: "" });
|
|
1505
|
+
};
|
|
1506
|
+
const onSelectEntry = (entry: CmsEntry | null) => {
|
|
1507
|
+
if (changesMode) {
|
|
1508
|
+
// A change's id IS its full repo path. For a document the URL stores it
|
|
1509
|
+
// relative to the collection's glob base, exactly as the editor does; a
|
|
1510
|
+
// media path has no glob to strip, so it goes in whole.
|
|
1511
|
+
go({
|
|
1512
|
+
collection: activeNavKey,
|
|
1513
|
+
itemPath: mediaChangesShowing
|
|
1514
|
+
? entry?.path ?? ""
|
|
1515
|
+
: entry && active
|
|
1516
|
+
? toRelItemPath(active.path, entry.path)
|
|
1517
|
+
: "",
|
|
1518
|
+
});
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
if (active?.singleton) {
|
|
1522
|
+
// A singleton is its item; back and select both address the same path.
|
|
1523
|
+
go({ configure: true, collection: "", itemPath: entry?.path ?? active.path });
|
|
1524
|
+
} else {
|
|
1525
|
+
go({ configure: false, collection: activeNavKey, itemPath: entry && active ? toRelItemPath(active.path, entry.path) : "" });
|
|
1526
|
+
}
|
|
1527
|
+
};
|
|
1528
|
+
|
|
1529
|
+
// Open a document from the list. Plain click reuses the active column (a preview
|
|
1530
|
+
// tab); shift/⌘-click (newColumn) opens a new one. An already-open doc is just
|
|
1531
|
+
// re-selected. Either way the URL follows the opened document.
|
|
1532
|
+
const onOpenEntry = (entry: CmsEntry, opts: { newColumn: boolean }) => {
|
|
1533
|
+
// Decided before the state update, not inside it: the updater is called
|
|
1534
|
+
// again on a StrictMode double-invoke, which would double-count.
|
|
1535
|
+
if (!openTabs.some((t: TabRef) => t.id === entry.id)) {
|
|
1536
|
+
analytics.capture(AnalyticsEvent.TabOpened, {
|
|
1537
|
+
collection: activeNavKey || null,
|
|
1538
|
+
new_column: opts.newColumn,
|
|
1539
|
+
open_tabs: openTabs.length + 1,
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
setOpenTabs((prev: TabRef[]) => {
|
|
1543
|
+
if (prev.some((t) => t.id === entry.id)) return prev; // already open → just select
|
|
1544
|
+
const tab: TabRef = { id: entry.id, collection: activeNavKey };
|
|
1545
|
+
if (opts.newColumn) return [...prev, tab];
|
|
1546
|
+
// Reuse the focused column only when it holds a document. Replacing a
|
|
1547
|
+
// plugin column here would close a preview the user opened, from a click
|
|
1548
|
+
// in the content list that has nothing to do with it.
|
|
1549
|
+
const idx = prev.findIndex((t) => t.id === focusedTabId && !t.plugin);
|
|
1550
|
+
if (idx === -1) return [...prev, tab];
|
|
1551
|
+
const next = [...prev];
|
|
1552
|
+
next[idx] = tab; // replace the active column's slot (preview tab)
|
|
1553
|
+
return next;
|
|
1554
|
+
});
|
|
1555
|
+
onSelectEntry(entry);
|
|
1556
|
+
};
|
|
1557
|
+
const onActivateTab = (tab: OpenTab) => goToTab(tab);
|
|
1558
|
+
// Reorder the open columns to match the drag result (orderedIds is the full
|
|
1559
|
+
// open-tab id set in its new left-to-right order).
|
|
1560
|
+
const onReorderTabs = (orderedIds: string[]) =>
|
|
1561
|
+
setOpenTabs((prev: TabRef[]) => {
|
|
1562
|
+
const byId = new Map(prev.map((t) => [t.id, t] as const));
|
|
1563
|
+
const next = orderedIds.map((id) => byId.get(id)).filter((t): t is TabRef => !!t);
|
|
1564
|
+
return next.length === prev.length ? next : prev;
|
|
1565
|
+
});
|
|
1566
|
+
// Close a column; if it was active, follow a neighbor (or clear the selection).
|
|
1567
|
+
const onCloseTab = (id: string) => {
|
|
1568
|
+
const idx = openTabs.findIndex((t: TabRef) => t.id === id);
|
|
1569
|
+
const next = openTabs.filter((t: TabRef) => t.id !== id);
|
|
1570
|
+
if (idx !== -1) {
|
|
1571
|
+
analytics.capture(AnalyticsEvent.TabClosed, {
|
|
1572
|
+
collection: openTabs[idx].collection || null,
|
|
1573
|
+
open_tabs: next.length,
|
|
1574
|
+
was_active: id === focusedTabId,
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
setOpenTabs(next);
|
|
1578
|
+
if (id === selectedId) {
|
|
1579
|
+
const nb = next[idx] ?? next[idx - 1] ?? null;
|
|
1580
|
+
if (nb) goToTab(nb);
|
|
1581
|
+
else go({ configure: false, collection: activeNavKey, itemPath: "" });
|
|
1582
|
+
} else if (id === focusedTabId) {
|
|
1583
|
+
// The focused column closed while the URL points elsewhere: focus follows
|
|
1584
|
+
// a neighbor without navigating — there is no selection to move.
|
|
1585
|
+
const nb = next[idx] ?? next[idx - 1] ?? null;
|
|
1586
|
+
setLastFocusedTabId(nb ? nb.id : null);
|
|
1587
|
+
}
|
|
1588
|
+
};
|
|
1589
|
+
|
|
1590
|
+
// Save: merge the edited (schema) fields into the document's full field map —
|
|
1591
|
+
// preserving frontmatter keys the schema doesn't surface — and persist via the
|
|
1592
|
+
// updateDocument mutation. Apollo normalizes the returned Document by id, so
|
|
1593
|
+
// the list/detail reflect the save without a refetch.
|
|
1594
|
+
//
|
|
1595
|
+
// Called when the author presses Save, not on every keystroke: edits live as a
|
|
1596
|
+
// local draft in the editor until then (see useDocumentDraft in the design
|
|
1597
|
+
// system). This handler is unchanged by that — it was always "persist this
|
|
1598
|
+
// change" — but the cadence it runs at is now one save per deliberate action
|
|
1599
|
+
// rather than one per typing pause, which is what keeps a git-backed history
|
|
1600
|
+
// readable.
|
|
1601
|
+
const [updateDocument] = useMutation(UPDATE_DOCUMENT);
|
|
1602
|
+
const onSaveEntry = async (change: { id: string; fields: Record<string, unknown>; body: string | null }) => {
|
|
1603
|
+
// Merge against the tab's loaded full field map (not the active-collection
|
|
1604
|
+
// list, which is now display-only) so cross-collection saves preserve keys.
|
|
1605
|
+
const doc = loaded[change.id];
|
|
1606
|
+
const merged = { ...(doc?.fields ?? {}), ...change.fields };
|
|
1607
|
+
try {
|
|
1608
|
+
await updateDocument({
|
|
1609
|
+
variables: { repositoryId: repo.id, id: change.id, fields: merged, body: change.body },
|
|
1610
|
+
});
|
|
1611
|
+
} catch (e: any) {
|
|
1612
|
+
// Rethrow the server's message so the editor's autosave surfaces it.
|
|
1613
|
+
throw new Error(e?.graphQLErrors?.[0]?.message || e?.message || "Couldn’t save your changes.");
|
|
1614
|
+
}
|
|
1615
|
+
};
|
|
1616
|
+
|
|
1617
|
+
// Create: split the form values into a body-source field (if any) + frontmatter
|
|
1618
|
+
// fields, persist, then route to the new document's edit URL.
|
|
1619
|
+
const [createDocument] = useMutation(CREATE_DOCUMENT);
|
|
1620
|
+
const onSubmitCreate = async (values: Record<string, unknown>) => {
|
|
1621
|
+
if (!active) return;
|
|
1622
|
+
const fields: Record<string, unknown> = {};
|
|
1623
|
+
let body: string | null = null;
|
|
1624
|
+
for (const f of active.fields) {
|
|
1625
|
+
if (f.source === "body") body = values[f.name] == null ? null : String(values[f.name]);
|
|
1626
|
+
else fields[f.name] = values[f.name];
|
|
1627
|
+
}
|
|
1628
|
+
let created: DocumentT | undefined;
|
|
1629
|
+
try {
|
|
1630
|
+
const res = await createDocument({
|
|
1631
|
+
variables: { repositoryId: repo.id, branchId: branch.id, project: projectName ?? null, collection: active.name, fields, body },
|
|
1632
|
+
});
|
|
1633
|
+
created = res.data?.createDocument as DocumentT | undefined;
|
|
1634
|
+
} catch (e: any) {
|
|
1635
|
+
// Surface the server's message so CreateForm can show it (throwing here
|
|
1636
|
+
// lets the form render a dismissable alert instead of failing silently).
|
|
1637
|
+
const message = e?.graphQLErrors?.[0]?.message || e?.message || "Couldn’t create the document.";
|
|
1638
|
+
throw new Error(message);
|
|
1639
|
+
}
|
|
1640
|
+
await reloadDocs();
|
|
1641
|
+
// Seed the loaded cache with the created doc so its new tab opens without a
|
|
1642
|
+
// loading flash, then redirect to its edit route (which appends the tab).
|
|
1643
|
+
if (created) setLoaded((prev: Record<string, DocumentT>) => ({ ...prev, [created!.id]: created! }));
|
|
1644
|
+
go({ configure: false, collection: active.name, itemPath: created?.path ? toRelItemPath(active.path, created.path) : "" });
|
|
1645
|
+
};
|
|
1646
|
+
|
|
1647
|
+
// Delete: remove documents, refresh the list, and drop any now-stale selection.
|
|
1648
|
+
const [deleteDocuments] = useMutation(DELETE_DOCUMENTS);
|
|
1649
|
+
const removeDocuments = async (ids: string[]) => {
|
|
1650
|
+
if (ids.length === 0) return;
|
|
1651
|
+
await deleteDocuments({ variables: { repositoryId: repo.id, ids } });
|
|
1652
|
+
// Close any open tabs for the deleted docs and drop their loaded data.
|
|
1653
|
+
setOpenTabs((prev: TabRef[]) => prev.filter((t) => !ids.includes(t.id)));
|
|
1654
|
+
setLoaded((prev: Record<string, DocumentT>) => {
|
|
1655
|
+
const n = { ...prev };
|
|
1656
|
+
for (const id of ids) delete n[id];
|
|
1657
|
+
return n;
|
|
1658
|
+
});
|
|
1659
|
+
await reloadDocs();
|
|
1660
|
+
if (selectedId && ids.includes(selectedId)) {
|
|
1661
|
+
go({ configure: false, collection: activeNavKey, itemPath: "" });
|
|
1662
|
+
}
|
|
1663
|
+
};
|
|
1664
|
+
const onDeleteEntry = (entry: CmsEntry) => removeDocuments([entry.id]);
|
|
1665
|
+
const onBulkDelete = (ids: string[]) => removeDocuments(ids);
|
|
1666
|
+
|
|
1667
|
+
// Rename/move: change a document's path, then follow it to its new URL. The
|
|
1668
|
+
// server derives the new path from the collection's glob. Errors are rethrown
|
|
1669
|
+
// (clean message) so the prompt dialog surfaces them.
|
|
1670
|
+
const [renameDocument] = useMutation(RENAME_DOCUMENT);
|
|
1671
|
+
const relocate = async (id: string, vars: { filename?: string; folder?: string }) => {
|
|
1672
|
+
// Resolve the document's own collection (it may be a tab from another one).
|
|
1673
|
+
const tab = openTabs.find((t: TabRef) => t.id === id);
|
|
1674
|
+
const col = collections.find((c) => c.name === (tab?.collection ?? activeNavKey)) ?? active;
|
|
1675
|
+
let newPath: string | undefined;
|
|
1676
|
+
try {
|
|
1677
|
+
const res = await renameDocument({ variables: { repositoryId: repo.id, id, ...vars } });
|
|
1678
|
+
newPath = (res.data?.renameDocument as DocumentT | undefined)?.path;
|
|
1679
|
+
} catch (e: any) {
|
|
1680
|
+
throw new Error(e?.graphQLErrors?.[0]?.message || e?.message || "Couldn’t rename the document.");
|
|
1681
|
+
}
|
|
1682
|
+
await reloadDocs();
|
|
1683
|
+
go({ configure: false, collection: col?.name ?? activeNavKey, itemPath: newPath && col ? toRelItemPath(col.path, newPath) : "" });
|
|
1684
|
+
};
|
|
1685
|
+
const onRename = (entry: CmsEntry, filename: string) => relocate(entry.id, { filename });
|
|
1686
|
+
const onMove = (entry: CmsEntry, folder: string) => relocate(entry.id, { folder });
|
|
1687
|
+
|
|
1688
|
+
// Bulk move: the DS supplies the affected document ids (folders expanded), the
|
|
1689
|
+
// folder they currently sit under (sourceFolder), and the target. The server
|
|
1690
|
+
// re-parents each, preserving structure. Errors surface in the DS prompt.
|
|
1691
|
+
const [moveDocuments] = useMutation(MOVE_DOCUMENTS);
|
|
1692
|
+
const onBulkMove = async (ids: string[], sourceFolder: string, targetFolder: string) => {
|
|
1693
|
+
try {
|
|
1694
|
+
await moveDocuments({ variables: { repositoryId: repo.id, ids, sourceFolder, targetFolder } });
|
|
1695
|
+
} catch (e: any) {
|
|
1696
|
+
throw new Error(e?.graphQLErrors?.[0]?.message || e?.message || "Couldn’t move the documents.");
|
|
1697
|
+
}
|
|
1698
|
+
await reloadDocs();
|
|
1699
|
+
};
|
|
1700
|
+
|
|
1701
|
+
// Empty field set for the create form (values default per control in the DS).
|
|
1702
|
+
const createFields: EntryField[] = active
|
|
1703
|
+
? active.fields.map((f) => toEntryField(f, f.source === "body" ? "" : undefined))
|
|
1704
|
+
: [];
|
|
1705
|
+
|
|
1706
|
+
// ── The changes sidebar ────────────────────────────────────────────────────
|
|
1707
|
+
//
|
|
1708
|
+
// A branch usually carries one project, and then this renders exactly what it
|
|
1709
|
+
// always did: Changed content, Changed configuration, Media.
|
|
1710
|
+
//
|
|
1711
|
+
// When it carries SEVERAL, the list groups by project first. That is not
|
|
1712
|
+
// decoration — a merge takes the whole branch, so the reviewer is publishing
|
|
1713
|
+
// every project on it whether or not they were looking at all of them, and a
|
|
1714
|
+
// flat list actively hides that. It also disambiguates: two projects may both
|
|
1715
|
+
// have a "pages" collection, and one undifferentiated "Pages 3" row would be
|
|
1716
|
+
// describing content the reader cannot see.
|
|
1717
|
+
const changedProjects = Array.from(new Set(changeSummary.map((s) => s.project)));
|
|
1718
|
+
const branchSpansProjects = changedProjects.length > 1;
|
|
1719
|
+
|
|
1720
|
+
const changeItem = (s: ChangeSummary) => ({
|
|
1721
|
+
key: s.collection,
|
|
1722
|
+
label: titleCase(s.collection),
|
|
1723
|
+
icon: s.kind === "media" ? ("image" as const) : s.singleton ? ("settings" as const) : ("newspaper" as const),
|
|
1724
|
+
count: s.count,
|
|
1725
|
+
conflicts: s.kind === "media" ? undefined : conflictsByCollection.get(s.collection),
|
|
1726
|
+
});
|
|
1727
|
+
const mediaItem = (s: ChangeSummary) => ({
|
|
1728
|
+
key: MEDIA_NAV_KEY, label: "Media", icon: "image" as const, count: s.count,
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
const projectLabelFor = (name: string) =>
|
|
1732
|
+
projects?.find((p) => p.name === name)?.label || name || repo.name;
|
|
1733
|
+
|
|
1734
|
+
const changeSections: CmsNavSection[] = branchSpansProjects
|
|
1735
|
+
? changedProjects.map((name) => ({
|
|
1736
|
+
title: projectLabelFor(name),
|
|
1737
|
+
items: changeSummary
|
|
1738
|
+
.filter((s) => s.project === name)
|
|
1739
|
+
.map((s) => (s.kind === "media" ? mediaItem(s) : changeItem(s))),
|
|
1740
|
+
})).filter((s) => s.items.length > 0)
|
|
1741
|
+
: [
|
|
1742
|
+
{
|
|
1743
|
+
title: "Changed content",
|
|
1744
|
+
items: changeSummary.filter((s) => !s.singleton && s.kind !== "media").map(changeItem),
|
|
1745
|
+
},
|
|
1746
|
+
{
|
|
1747
|
+
title: "Changed configuration",
|
|
1748
|
+
items: changeSummary.filter((s) => s.singleton && s.kind !== "media").map(changeItem),
|
|
1749
|
+
},
|
|
1750
|
+
{
|
|
1751
|
+
// Untitled, so it renders as a standalone button — the same treatment
|
|
1752
|
+
// the editor gives Media. Present only when assets actually changed.
|
|
1753
|
+
title: "",
|
|
1754
|
+
items: changeSummary.filter((s) => s.kind === "media").map(mediaItem),
|
|
1755
|
+
},
|
|
1756
|
+
].filter((s) => s.items.length > 0);
|
|
1757
|
+
|
|
1758
|
+
// The confirm step's one-line summary.
|
|
1759
|
+
//
|
|
1760
|
+
// When the branch spans projects it NAMES them. This is the §9.1 disclosure,
|
|
1761
|
+
// and the confirm step is where it belongs: merging takes the whole branch, so
|
|
1762
|
+
// the moment someone is about to publish another project's work is the moment
|
|
1763
|
+
// to say so — not a banner they scrolled past on the way here.
|
|
1764
|
+
const totalChanges = changeSummary.reduce((n, s) => n + s.count, 0);
|
|
1765
|
+
const applySummaryText = changeSummary.length
|
|
1766
|
+
? `${totalChanges} change${totalChanges === 1 ? "" : "s"} across ` +
|
|
1767
|
+
`${changeSummary.length} ${changeSummary.length === 1 ? "collection" : "collections"}` +
|
|
1768
|
+
(branchSpansProjects
|
|
1769
|
+
? ` in ${changedProjects.length} projects — ${changedProjects.map(projectLabelFor).join(", ")}. ` +
|
|
1770
|
+
(branch.requiresChangeRequest
|
|
1771
|
+
// They cannot publish all of it, so say what pressing Apply actually
|
|
1772
|
+
// does rather than letting them expect a merge that will not happen.
|
|
1773
|
+
? `You can't publish every project here, so this opens a change request for review.`
|
|
1774
|
+
: `Merging publishes all of them.`)
|
|
1775
|
+
: "")
|
|
1776
|
+
: undefined;
|
|
1777
|
+
|
|
1778
|
+
// Collections (globbed paths) are Content; singletons (fixed paths) are Configure.
|
|
1779
|
+
// The changes surface splits the same way over only what actually changed, and
|
|
1780
|
+
// counts changes rather than documents.
|
|
1781
|
+
const sections: CmsNavSection[] = changesMode
|
|
1782
|
+
? changeSections
|
|
1783
|
+
: [
|
|
1784
|
+
{
|
|
1785
|
+
title: "Content",
|
|
1786
|
+
items: collections.filter((c) => !c.singleton).map((c) => ({
|
|
1787
|
+
key: c.name,
|
|
1788
|
+
label: titleCase(c.name),
|
|
1789
|
+
icon: "newspaper" as const,
|
|
1790
|
+
count: c.documentCount,
|
|
1791
|
+
})),
|
|
1792
|
+
},
|
|
1793
|
+
{
|
|
1794
|
+
title: "Configure",
|
|
1795
|
+
items: collections.filter((c) => c.singleton).map((c) => ({
|
|
1796
|
+
key: c.name,
|
|
1797
|
+
label: titleCase(c.name),
|
|
1798
|
+
icon: "settings" as const,
|
|
1799
|
+
})),
|
|
1800
|
+
},
|
|
1801
|
+
// Plugin-contributed links, grouped by their declared section title
|
|
1802
|
+
// (default "Plugins"), keyed so onSelectNav can route them.
|
|
1803
|
+
...pluginRegistry.sidebar().reduce<CmsNavSection[]>((acc, link) => {
|
|
1804
|
+
const item = {
|
|
1805
|
+
key: pluginNavKey(link.pluginId, link.to),
|
|
1806
|
+
label: link.label,
|
|
1807
|
+
icon: link.icon ?? ("braces" as const),
|
|
1808
|
+
};
|
|
1809
|
+
const section = acc.find((s) => s.title === link.section);
|
|
1810
|
+
if (section) section.items.push(item);
|
|
1811
|
+
else acc.push({ title: link.section, items: [item] });
|
|
1812
|
+
return acc;
|
|
1813
|
+
}, []),
|
|
1814
|
+
].filter((s) => s.items.length > 0);
|
|
1815
|
+
|
|
1816
|
+
// The content list needs only display fields; a document's full fields/body are
|
|
1817
|
+
// loaded per open tab. An entry whose document is loaded carries the full data
|
|
1818
|
+
// (so the mobile detail + any active-collection column render it); otherwise it
|
|
1819
|
+
// is marked `loading` until its document arrives.
|
|
1820
|
+
//
|
|
1821
|
+
// On the changes surface the rows are changes, not documents: identified by
|
|
1822
|
+
// path (a deletion has no document on this branch) and carrying the git status
|
|
1823
|
+
// + line counts the design system's entry row already knows how to render.
|
|
1824
|
+
const entries: CmsEntry[] = mediaChangesShowing
|
|
1825
|
+
? // A binary file has no line counts, so the row carries only its status —
|
|
1826
|
+
// DiffStat renders nothing for 0/0, which is the honest result.
|
|
1827
|
+
mediaChanges.map((c) => ({
|
|
1828
|
+
id: c.path,
|
|
1829
|
+
path: c.path,
|
|
1830
|
+
title: c.path.split("/").pop() ?? c.path,
|
|
1831
|
+
body: "",
|
|
1832
|
+
status: c.status,
|
|
1833
|
+
}))
|
|
1834
|
+
: changesMode
|
|
1835
|
+
? branchChanges.map((c) => ({
|
|
1836
|
+
id: c.path,
|
|
1837
|
+
path: c.path,
|
|
1838
|
+
title: c.label,
|
|
1839
|
+
body: "",
|
|
1840
|
+
status: c.status,
|
|
1841
|
+
added: c.added,
|
|
1842
|
+
removed: c.removed,
|
|
1843
|
+
conflicted: conflictedPaths.has(c.path),
|
|
1844
|
+
}))
|
|
1845
|
+
: active
|
|
1846
|
+
? documents.map((d) => {
|
|
1847
|
+
const full = loaded[d.id];
|
|
1848
|
+
return full
|
|
1849
|
+
? buildEntry(full, activeNavKey)
|
|
1850
|
+
: { id: d.id, path: d.path, title: d.label, body: "", loading: true };
|
|
1851
|
+
})
|
|
1852
|
+
: [];
|
|
1853
|
+
|
|
1854
|
+
// Desktop columns: the open tabs with their loaded data + per-tab collection
|
|
1855
|
+
// context (a tab may belong to a collection other than the active one).
|
|
1856
|
+
const dsTabs: OpenTab[] = openTabs.map((t: TabRef) => {
|
|
1857
|
+
if (t.plugin) {
|
|
1858
|
+
const p = t.plugin;
|
|
1859
|
+
const anchor = p.documentId ? loaded[p.documentId] : undefined;
|
|
1860
|
+
const anchorCollection = p.documentId
|
|
1861
|
+
? openTabs.find((x: TabRef) => x.id === p.documentId)?.collection ?? ""
|
|
1862
|
+
: "";
|
|
1863
|
+
return {
|
|
1864
|
+
id: t.id,
|
|
1865
|
+
collection: "",
|
|
1866
|
+
title: p.title,
|
|
1867
|
+
content: (
|
|
1868
|
+
<PluginTabBody
|
|
1869
|
+
registry={pluginRegistry}
|
|
1870
|
+
tabId={t.id}
|
|
1871
|
+
tab={p}
|
|
1872
|
+
bus={draftBus}
|
|
1873
|
+
initialDocument={anchor ? toPluginDoc(anchor, anchorCollection) : null}
|
|
1874
|
+
workspaceId={params.workspaceId}
|
|
1875
|
+
repositoryId={repo.id}
|
|
1876
|
+
branchId={branch.id}
|
|
1877
|
+
gitRef={branch.name}
|
|
1878
|
+
apiUrl={config.apiUrl}
|
|
1879
|
+
navigate={(to) => go({ surface: "edit", plugin: { id: p.pluginId, path: to } })}
|
|
1880
|
+
openDocument={openDocumentById}
|
|
1881
|
+
close={() => closePluginTab(t.id)}
|
|
1882
|
+
/>
|
|
1883
|
+
),
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
const col = collections.find((c) => c.name === t.collection) ?? null;
|
|
1887
|
+
const doc = loaded[t.id];
|
|
1888
|
+
return {
|
|
1889
|
+
id: t.id,
|
|
1890
|
+
collection: t.collection,
|
|
1891
|
+
entry: doc ? buildEntry(doc, t.collection) : undefined,
|
|
1892
|
+
loading: !doc,
|
|
1893
|
+
readOnly: col ? !col.canUpdate : undefined,
|
|
1894
|
+
canDelete: col ? col.canDelete : undefined,
|
|
1895
|
+
canRename: col ? col.canUpdate && !col.singleton : false,
|
|
1896
|
+
canMove: col ? col.canUpdate && !col.singleton : false,
|
|
1897
|
+
collectionPath: col?.path,
|
|
1898
|
+
};
|
|
1899
|
+
});
|
|
1900
|
+
|
|
1901
|
+
// Surface switcher. Structure is deliberately absent for now — content-model
|
|
1902
|
+
// editing doesn't exist yet, and an empty tab is worse than no tab.
|
|
1903
|
+
const segments: SegmentItem[] = [
|
|
1904
|
+
{ key: "edit", label: "Edit" },
|
|
1905
|
+
{ key: "changes", label: "Changes", disabled: onDefaultBranch },
|
|
1906
|
+
];
|
|
1907
|
+
const onSelectSegment = (key: string) => {
|
|
1908
|
+
// Switching surfaces keeps the collection but drops the document: the same
|
|
1909
|
+
// path is addressable on both, but a document open for editing is rarely the
|
|
1910
|
+
// one you want to review, and a stale path would land on an empty pane.
|
|
1911
|
+
navigation.navigate("Browser", {
|
|
1912
|
+
workspaceId: params.workspaceId, repositoryId: repo.id, branchId: branch.id, defaultBranchId, projectName, soleProjectName,
|
|
1913
|
+
...EMPTY_SELECTION,
|
|
1914
|
+
surface: key === "changes" ? "changes" : "edit",
|
|
1915
|
+
// Keep the chosen comparison target when entering Changes.
|
|
1916
|
+
targetBranchId: params.targetBranchId,
|
|
1917
|
+
collection: activeNavKey,
|
|
1918
|
+
});
|
|
1919
|
+
};
|
|
1920
|
+
|
|
1921
|
+
const branches: BranchRef[] = repo.branches.map((b) => ({ id: b.id, name: b.name, protected: b.protected }));
|
|
1922
|
+
const onSelectBranch = (next: BranchRef) => {
|
|
1923
|
+
// A branch switch resets the selection: collections, documents and changes
|
|
1924
|
+
// are all per-branch, so carrying a path across would usually 404. The
|
|
1925
|
+
// target is dropped too — a target only means something against a specific
|
|
1926
|
+
// source, and keeping a now-unrelated one could make source==target.
|
|
1927
|
+
navigation.navigate("Browser", {
|
|
1928
|
+
workspaceId: params.workspaceId, repositoryId: repo.id, branchId: next.id, defaultBranchId, projectName, soleProjectName,
|
|
1929
|
+
...EMPTY_SELECTION,
|
|
1930
|
+
// Changes on the default branch is empty by definition — land on Edit.
|
|
1931
|
+
surface: next.id === defaultBranchId ? "edit" : params.surface,
|
|
1932
|
+
});
|
|
1933
|
+
};
|
|
1934
|
+
// Switch project. The whole selection resets: collections, documents and open
|
|
1935
|
+
// tabs all belong to the project being left, and carrying a path across would
|
|
1936
|
+
// address content that does not exist in the one being entered.
|
|
1937
|
+
//
|
|
1938
|
+
// The branch is kept. Projects share the repository's branches, so staying on
|
|
1939
|
+
// the same one is what the user means — and if the new project has no config
|
|
1940
|
+
// there, the collection list is empty, which is the honest answer rather than
|
|
1941
|
+
// an error.
|
|
1942
|
+
const onSelectProject = (next: ProjectRef) => {
|
|
1943
|
+
navigation.navigate("Browser", {
|
|
1944
|
+
workspaceId: params.workspaceId, repositoryId: repo.id, branchId: branch.id, defaultBranchId,
|
|
1945
|
+
projectName: next.name, soleProjectName,
|
|
1946
|
+
...EMPTY_SELECTION,
|
|
1947
|
+
surface: params.surface,
|
|
1948
|
+
});
|
|
1949
|
+
};
|
|
1950
|
+
// Retarget the comparison, keeping the source branch and the Changes surface.
|
|
1951
|
+
// The selection resets: the whole change set is recomputed against the new
|
|
1952
|
+
// base, so a previously-selected path may no longer be a change.
|
|
1953
|
+
const onSelectTarget = (next: BranchRef) => {
|
|
1954
|
+
navigation.navigate("Browser", {
|
|
1955
|
+
workspaceId: params.workspaceId, repositoryId: repo.id, branchId: branch.id, defaultBranchId, projectName, soleProjectName,
|
|
1956
|
+
...EMPTY_SELECTION,
|
|
1957
|
+
surface: "changes",
|
|
1958
|
+
targetBranchId: next.id,
|
|
1959
|
+
});
|
|
1960
|
+
};
|
|
1961
|
+
|
|
1962
|
+
const [createBranch] = useMutation(CREATE_BRANCH);
|
|
1963
|
+
const onCreateBranch = async (name: string) => {
|
|
1964
|
+
let created: Branch | undefined;
|
|
1965
|
+
try {
|
|
1966
|
+
const res = await createBranch({
|
|
1967
|
+
variables: { repositoryId: repo.id, fromBranchId: branch.id, name },
|
|
1968
|
+
});
|
|
1969
|
+
created = res.data?.createBranch as Branch | undefined;
|
|
1970
|
+
} catch (e: any) {
|
|
1971
|
+
// Rethrow the server's message so the branch menu's inline form shows it.
|
|
1972
|
+
throw new Error(e?.graphQLErrors?.[0]?.message || e?.message || "Couldn’t create the branch.");
|
|
1973
|
+
}
|
|
1974
|
+
// The repository list caches its branches; refetch so the new one appears in
|
|
1975
|
+
// the switcher, then navigate onto it.
|
|
1976
|
+
await apollo.refetchQueries({ include: [REPOSITORIES] });
|
|
1977
|
+
if (created) {
|
|
1978
|
+
navigation.navigate("Browser", {
|
|
1979
|
+
workspaceId: params.workspaceId, repositoryId: repo.id, branchId: created.id, defaultBranchId, projectName, soleProjectName,
|
|
1980
|
+
...EMPTY_SELECTION,
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
};
|
|
1984
|
+
|
|
1985
|
+
if (showAllNotifications) {
|
|
1986
|
+
return <NotificationsScreen items={notifications} onBack={() => setShowAllNotifications(false)} />;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// The change-request summary is its own page, not part of the three-pane
|
|
1990
|
+
// browser. Back returns to the changes view for this source→target.
|
|
1991
|
+
//
|
|
1992
|
+
// Tested against params.surface rather than the changeRequestMode alias so the
|
|
1993
|
+
// early return also narrows it to what ContentBrowser accepts below.
|
|
1994
|
+
if (params.surface === "changeRequest") {
|
|
1995
|
+
const backToChanges = () =>
|
|
1996
|
+
navigation.navigate("Browser", {
|
|
1997
|
+
workspaceId: params.workspaceId, repositoryId: repo.id, branchId: branch.id, defaultBranchId, projectName, soleProjectName,
|
|
1998
|
+
targetBranchId: params.targetBranchId,
|
|
1999
|
+
...EMPTY_SELECTION,
|
|
2000
|
+
surface: "changes",
|
|
2001
|
+
});
|
|
2002
|
+
return (
|
|
2003
|
+
<Screen>
|
|
2004
|
+
{changeRequestDetail ? (
|
|
2005
|
+
<ChangeRequestSummary
|
|
2006
|
+
data={changeRequestDetail}
|
|
2007
|
+
cloneUrl={`https://github.com/${repo.owner}/${repo.name}.git`}
|
|
2008
|
+
onBack={backToChanges}
|
|
2009
|
+
/>
|
|
2010
|
+
) : (
|
|
2011
|
+
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }}>
|
|
2012
|
+
<Text variant="body" color="tertiary">No open change request for this branch.</Text>
|
|
2013
|
+
</View>
|
|
2014
|
+
)}
|
|
2015
|
+
</Screen>
|
|
2016
|
+
);
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
return (
|
|
2020
|
+
<>
|
|
2021
|
+
{/* One invisible loader per open tab — each fetches its own document.
|
|
2022
|
+
Suppressed on the changes surface, which opens no documents. */}
|
|
2023
|
+
{(changesMode ? [] : openTabs).filter((t: TabRef) => !t.plugin).map((tab: TabRef) => (
|
|
2024
|
+
<TabDoc
|
|
2025
|
+
key={tab.id}
|
|
2026
|
+
repositoryId={repo.id}
|
|
2027
|
+
id={tab.id}
|
|
2028
|
+
user={collabUser}
|
|
2029
|
+
onLoaded={handleLoaded}
|
|
2030
|
+
onCollab={handleCollab}
|
|
2031
|
+
/>
|
|
2032
|
+
))}
|
|
2033
|
+
<ContentBrowser
|
|
2034
|
+
workspace={{
|
|
2035
|
+
name: `${repo.owner}/${repo.name}`,
|
|
2036
|
+
initials: repo.owner.slice(0, 2).toUpperCase(),
|
|
2037
|
+
branch: branch.name,
|
|
2038
|
+
changed: 0,
|
|
2039
|
+
}}
|
|
2040
|
+
// Surface switching + branch selection, both in the top bar.
|
|
2041
|
+
surface={params.surface}
|
|
2042
|
+
segments={segments}
|
|
2043
|
+
onSelectSegment={onSelectSegment}
|
|
2044
|
+
branches={branches}
|
|
2045
|
+
defaultBranch={repo.defaultBranch}
|
|
2046
|
+
// The picker renders nothing below two projects, so a repository without a
|
|
2047
|
+
// manifest shows no new affordance at all. Hidden in local mode, which
|
|
2048
|
+
// serves exactly one project by design.
|
|
2049
|
+
projects={config.local ? undefined : projects?.map((p) => ({ name: p.name, label: p.label }))}
|
|
2050
|
+
currentProject={projectName}
|
|
2051
|
+
onSelectProject={config.local || projectLocked ? undefined : onSelectProject}
|
|
2052
|
+
projectLocked={projectLocked}
|
|
2053
|
+
onSelectBranch={config.local ? undefined : onSelectBranch}
|
|
2054
|
+
onCreateBranch={config.local ? undefined : onCreateBranch}
|
|
2055
|
+
// Source → target selector: only on the changes surface, where a target is
|
|
2056
|
+
// meaningful. The name is resolved from the id (default when unset).
|
|
2057
|
+
// Undefined in local mode: the comparison is against the last commit, not
|
|
2058
|
+
// against another branch, so naming a branch here would claim something
|
|
2059
|
+
// untrue. There is likewise no target to pick.
|
|
2060
|
+
targetBranch={config.local ? undefined : changesMode ? repo.branches.find((b) => b.id === targetBranchId)?.name : (branchFrozen ? frozenCR?.targetBranch : undefined)}
|
|
2061
|
+
onSelectTarget={config.local ? undefined : onSelectTarget}
|
|
2062
|
+
// Apply changes (merge). Enabled when there are changes and no unresolved
|
|
2063
|
+
// conflicts; the modal is driven by the run's live state.
|
|
2064
|
+
onApplyChanges={changesMode && !onDefaultBranch && !config.local ? onApplyChanges : undefined}
|
|
2065
|
+
// Left enabled even when the branch spans projects the user cannot all
|
|
2066
|
+
// edit. Applying WORKS for them: the run stops before writing anything and
|
|
2067
|
+
// lands in needs_review, which is where the existing change-request flow
|
|
2068
|
+
// takes over — the same destination a code conflict reaches. Disabling it
|
|
2069
|
+
// was the earlier design and was a dead end, since opening a change request
|
|
2070
|
+
// requires a run to open it from. What changes is only the expectation,
|
|
2071
|
+
// which applySummary states before they commit.
|
|
2072
|
+
canApplyChanges={changeSummary.length > 0 && !hasUnresolvedConflicts}
|
|
2073
|
+
applyOpen={applyOpen}
|
|
2074
|
+
applySummary={applySummaryText}
|
|
2075
|
+
applyProgress={applyProgress}
|
|
2076
|
+
applyConflictCount={conflicts.filter((c) => !c.resolution).length}
|
|
2077
|
+
onConfirmApply={onConfirmApply}
|
|
2078
|
+
onReviewConflicts={reviewConflicts}
|
|
2079
|
+
onCloseApply={closeApply}
|
|
2080
|
+
onDoneApply={doneApply}
|
|
2081
|
+
// Developer escalation: an open change request swaps the confirm step for a
|
|
2082
|
+
// "view change request" link; a needs_review run collects an explanation.
|
|
2083
|
+
changeRequestOpen={changeRequestOpen || branchFrozen}
|
|
2084
|
+
changeRequestUrl={changeRequestSummaryUrl}
|
|
2085
|
+
creatingChangeRequest={creatingChangeRequest}
|
|
2086
|
+
onCreateChangeRequest={onCreateChangeRequest}
|
|
2087
|
+
onViewChangeRequest={goToChangeRequest}
|
|
2088
|
+
selectedChange={selectedChange}
|
|
2089
|
+
selectedConflicts={selectedConflicts}
|
|
2090
|
+
onResolveConflict={onResolveConflict}
|
|
2091
|
+
loadingChange={loadingChange}
|
|
2092
|
+
sections={sections}
|
|
2093
|
+
activeNavKey={activeNavKey}
|
|
2094
|
+
onSelectNav={onSelectNav}
|
|
2095
|
+
entries={entries}
|
|
2096
|
+
// Server-side pagination of the flat entry list: the FlatList requests the
|
|
2097
|
+
// next page as it nears the end; totalEntries drives the header count.
|
|
2098
|
+
totalEntries={total}
|
|
2099
|
+
hasMoreEntries={hasMoreEntries}
|
|
2100
|
+
loadingMoreEntries={loadingMoreEntries}
|
|
2101
|
+
onLoadMoreEntries={onLoadMoreEntries}
|
|
2102
|
+
// Title search + faceted filters (server-side). Enabled for real collections
|
|
2103
|
+
// (not singletons). onCountMatches powers the panel's optimistic count.
|
|
2104
|
+
searchable={!changesMode && !!active && !active.singleton}
|
|
2105
|
+
facetFields={facetFields}
|
|
2106
|
+
onSearchChange={onSearchChange}
|
|
2107
|
+
onApplyFilters={onApplyFilters}
|
|
2108
|
+
onClearSearch={onClearSearch}
|
|
2109
|
+
onCountMatches={onCountMatches}
|
|
2110
|
+
selectedEntryId={selectedId}
|
|
2111
|
+
onSelectEntry={onSelectEntry}
|
|
2112
|
+
// Host-controlled desktop tabs: each column loads its own document, and the
|
|
2113
|
+
// set persists across collection switches.
|
|
2114
|
+
openTabs={dsTabs}
|
|
2115
|
+
activeTabId={focusedTabId}
|
|
2116
|
+
onOpenEntry={onOpenEntry}
|
|
2117
|
+
onActivateTab={onActivateTab}
|
|
2118
|
+
onCloseTab={onCloseTab}
|
|
2119
|
+
onReorderTabs={onReorderTabs}
|
|
2120
|
+
userInitials="ED"
|
|
2121
|
+
onSignOut={onSignOut}
|
|
2122
|
+
// Plugin-contributed user-menu items: a `to` link navigates into the
|
|
2123
|
+
// plugin's screen; an action link runs the plugin's own handler.
|
|
2124
|
+
userMenuItems={pluginRegistry.userMenu().map((link) => ({
|
|
2125
|
+
label: link.label,
|
|
2126
|
+
icon: link.icon,
|
|
2127
|
+
onSelect:
|
|
2128
|
+
link.onSelect ??
|
|
2129
|
+
(() => go({ surface: "edit", plugin: { id: link.pluginId, path: link.to ?? "" } })),
|
|
2130
|
+
}))}
|
|
2131
|
+
// A plugin route replaces the content panes with the plugin's screen; the
|
|
2132
|
+
// sidebar (and its highlight, via the plugin nav key) stays.
|
|
2133
|
+
contentSlot={
|
|
2134
|
+
params.plugin ? (
|
|
2135
|
+
<PluginScreenHost
|
|
2136
|
+
registry={pluginRegistry}
|
|
2137
|
+
pluginId={params.plugin.id}
|
|
2138
|
+
path={params.plugin.path}
|
|
2139
|
+
workspaceId={params.workspaceId}
|
|
2140
|
+
repositoryId={repo.id}
|
|
2141
|
+
branchId={branch.id}
|
|
2142
|
+
apiUrl={config.apiUrl}
|
|
2143
|
+
navigate={(to) => go({ surface: "edit", plugin: { id: params.plugin!.id, path: to } })}
|
|
2144
|
+
/>
|
|
2145
|
+
) : undefined
|
|
2146
|
+
}
|
|
2147
|
+
contentTitle={params.plugin ? params.plugin.id : undefined}
|
|
2148
|
+
entriesEmpty={changesMode ? "No changes on this branch" : "Choose a collection"}
|
|
2149
|
+
// Plugin toolbar buttons on an open document, and the in-flight draft
|
|
2150
|
+
// channel those buttons' tabs subscribe to. Both are inert on the changes
|
|
2151
|
+
// surface, where a "document" is a diff rather than something editable.
|
|
2152
|
+
documentActions={
|
|
2153
|
+
changesMode
|
|
2154
|
+
? undefined
|
|
2155
|
+
: (entry) => {
|
|
2156
|
+
const tab = openTabs.find((t: TabRef) => t.id === entry.id && !t.plugin);
|
|
2157
|
+
const doc = loaded[entry.id];
|
|
2158
|
+
if (!tab || !doc) return null;
|
|
2159
|
+
return (
|
|
2160
|
+
<PluginDocumentActions
|
|
2161
|
+
registry={pluginRegistry}
|
|
2162
|
+
document={toPluginDoc(doc, tab.collection)}
|
|
2163
|
+
context={{
|
|
2164
|
+
workspaceId: params.workspaceId,
|
|
2165
|
+
repositoryId: repo.id,
|
|
2166
|
+
branchId: branch.id,
|
|
2167
|
+
ref: branch.name,
|
|
2168
|
+
apiUrl: config.apiUrl,
|
|
2169
|
+
openTab: (pluginId, spec) => openPluginTab(pluginId, spec, entry.id),
|
|
2170
|
+
openDocument: openDocumentById,
|
|
2171
|
+
}}
|
|
2172
|
+
/>
|
|
2173
|
+
);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
onEntryDraft={
|
|
2177
|
+
changesMode
|
|
2178
|
+
? undefined
|
|
2179
|
+
: (draft) => {
|
|
2180
|
+
const tab = openTabs.find((t: TabRef) => t.id === draft.id && !t.plugin);
|
|
2181
|
+
const doc = loaded[draft.id];
|
|
2182
|
+
if (!tab || !doc) return;
|
|
2183
|
+
const col = collections.find((c) => c.name === tab.collection);
|
|
2184
|
+
draftBus.publish({
|
|
2185
|
+
id: draft.id,
|
|
2186
|
+
collection: tab.collection,
|
|
2187
|
+
path: doc.path,
|
|
2188
|
+
// The draft's fields are the schema's; merge over the document's
|
|
2189
|
+
// full map so frontmatter keys the schema does not surface still
|
|
2190
|
+
// reach a preview, exactly as onSaveEntry merges them for a save.
|
|
2191
|
+
fields: { ...(doc.fields ?? {}), ...draft.fields },
|
|
2192
|
+
body: draft.body ?? undefined,
|
|
2193
|
+
bodyField: col?.fields.find((f) => f.source === "body")?.name,
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
renderField={renderField}
|
|
2198
|
+
media={media}
|
|
2199
|
+
// Nothing on the changes surface is editable; nor is a frozen branch (one
|
|
2200
|
+
// handed to a developer in an open change request).
|
|
2201
|
+
onSaveEntry={changesMode || branchFrozen ? undefined : onSaveEntry}
|
|
2202
|
+
// CRUD wiring driven by the active collection's effective permissions, and
|
|
2203
|
+
// fully disabled while the branch is frozen in a change request.
|
|
2204
|
+
readOnly={changesMode || branchFrozen || (!!active && !active.canUpdate)}
|
|
2205
|
+
canCreate={!changesMode && !branchFrozen && !!active?.canCreate}
|
|
2206
|
+
onStartCreate={() => active && go({ configure: false, collection: active.name, itemPath: "", create: true })}
|
|
2207
|
+
creating={!!params.create && !!active}
|
|
2208
|
+
createFields={createFields}
|
|
2209
|
+
onSubmitCreate={onSubmitCreate}
|
|
2210
|
+
onCancelCreate={() => active && go({ configure: false, collection: active.name, itemPath: "" })}
|
|
2211
|
+
canDelete={!changesMode && !branchFrozen && !!active?.canDelete}
|
|
2212
|
+
// Frozen-branch read-only banner (below the top bar). "Learn more" opens the
|
|
2213
|
+
// modal, which surfaces the shareable change-request link.
|
|
2214
|
+
readOnlyNotice={branchFrozen ? "This branch is in an open change request and is read-only until it merges or is rejected." : undefined}
|
|
2215
|
+
readOnlyNoticeActionLabel="Learn more"
|
|
2216
|
+
onReadOnlyNoticeAction={branchFrozen ? openChangeRequestModal : undefined}
|
|
2217
|
+
onDeleteEntry={onDeleteEntry}
|
|
2218
|
+
selectable={!changesMode && !!active?.canDelete && !active?.singleton}
|
|
2219
|
+
onBulkDelete={onBulkDelete}
|
|
2220
|
+
// Rename/move (path changes). Gated in the DS by canUpdate (readOnly) and,
|
|
2221
|
+
// for move, by whether the collection glob is hierarchical (collectionPath).
|
|
2222
|
+
onRename={!changesMode && active && active.canUpdate && !active.singleton ? onRename : undefined}
|
|
2223
|
+
onMove={!changesMode && active && active.canUpdate && !active.singleton ? onMove : undefined}
|
|
2224
|
+
onBulkMove={!changesMode && active && active.canUpdate && !active.singleton ? onBulkMove : undefined}
|
|
2225
|
+
collectionPath={active?.path}
|
|
2226
|
+
// A `**`-globbed collection spans nested folders — browse it as a folder
|
|
2227
|
+
// drill-down rooted at the glob's wildcard-free base directory.
|
|
2228
|
+
hierarchical={hierarchicalActive && !searchActive}
|
|
2229
|
+
rootPath={active ? globBase(active.path) : ""}
|
|
2230
|
+
notifications={notifications}
|
|
2231
|
+
unreadNotifications={unreadNotifications}
|
|
2232
|
+
importing={importing}
|
|
2233
|
+
onOpenNotifications={() => {
|
|
2234
|
+
// Mark the context read on open, then refresh so the dot clears.
|
|
2235
|
+
void markAllRead().then(() => refetchNotifs());
|
|
2236
|
+
}}
|
|
2237
|
+
onViewAllNotifications={() => setShowAllNotifications(true)}
|
|
2238
|
+
/>
|
|
2239
|
+
</>
|
|
2240
|
+
);
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
// Resolve which branch to open for a repo: an explicit choice, else the
|
|
2244
|
+
// conventional default (main/master), else the first branch. Branch isn't part
|
|
2245
|
+
// of the URL, so a fresh deep link to a repo lands on its default branch.
|
|
2246
|
+
function pickBranch(repo: Repository | undefined, branchId?: string): Branch | undefined {
|
|
2247
|
+
if (!repo) return undefined;
|
|
2248
|
+
if (branchId) return repo.branches.find((b) => b.id === branchId);
|
|
2249
|
+
return (
|
|
2250
|
+
repo.branches.find((b) => b.name === "main") ??
|
|
2251
|
+
repo.branches.find((b) => b.name === "master") ??
|
|
2252
|
+
repo.branches[0]
|
|
2253
|
+
);
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
// The WorkspacePicker route (/app). Redirects straight into a workspace when one
|
|
2257
|
+
// is pinned by config or the user belongs to exactly one; otherwise it shows the
|
|
2258
|
+
// list so the user can choose which workspace to enter.
|
|
2259
|
+
function WorkspacePickerScreen({ workspaces, loading, error, onSignOut }: { workspaces: Workspace[]; loading: boolean; error: boolean; onSignOut: () => void }) {
|
|
2260
|
+
const navigation = useNavigation<any>();
|
|
2261
|
+
const autoId = config.workspaceId ?? (!loading && workspaces.length === 1 ? workspaces[0].id : undefined);
|
|
2262
|
+
useEffect(() => {
|
|
2263
|
+
if (autoId) navigation.dispatch(StackActions.replace("RepoPicker", { workspaceId: autoId }));
|
|
2264
|
+
}, [autoId, navigation]);
|
|
2265
|
+
// Hold the app-shell skeleton while resolving or redirecting so the picker
|
|
2266
|
+
// doesn't flash before an auto-selected workspace takes over.
|
|
2267
|
+
if (loading || autoId) return <ContentBrowserSkeleton />;
|
|
2268
|
+
return (
|
|
2269
|
+
<WorkspacePicker
|
|
2270
|
+
workspaces={workspaces}
|
|
2271
|
+
loading={loading}
|
|
2272
|
+
error={error}
|
|
2273
|
+
onPick={(w) => navigation.navigate("RepoPicker", { workspaceId: w.id })}
|
|
2274
|
+
onSignOut={onSignOut}
|
|
2275
|
+
/>
|
|
2276
|
+
);
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// The RepoPicker route (/app/{ws}). Loads the chosen workspace's repositories;
|
|
2280
|
+
// choosing a repo+branch pushes the Browser route, which the URL now reflects.
|
|
2281
|
+
function RepoPickerScreen({ workspaces, onSignOut }: { workspaces: Workspace[]; onSignOut: () => void }) {
|
|
2282
|
+
const navigation = useNavigation<any>();
|
|
2283
|
+
const route = useRoute();
|
|
2284
|
+
const { workspaceId } = route.params as { workspaceId: string };
|
|
2285
|
+
// The active workspace's name for the topbar affordance. A pinned workspace
|
|
2286
|
+
// (config.workspaceId) skips the WORKSPACES query, so fall back to a neutral
|
|
2287
|
+
// label and drop the "change workspace" action — there's nothing to switch to.
|
|
2288
|
+
const workspaceName = workspaces.find((w) => w.id === workspaceId)?.name ?? "Workspace";
|
|
2289
|
+
const onChangeWorkspace = config.workspaceId ? undefined : () => navigation.navigate("WorkspacePicker");
|
|
2290
|
+
const { data, loading, error } = useQuery(REPOSITORIES, { variables: { workspaceId } });
|
|
2291
|
+
const repos: Repository[] = data?.repositories ?? [];
|
|
2292
|
+
// When a repository is pinned by config, only offer that one.
|
|
2293
|
+
const scoped = config.repositoryId ? repos.filter((r) => r.id === config.repositoryId) : repos;
|
|
2294
|
+
// A pinned repository skips the picker entirely: redirect straight into it on
|
|
2295
|
+
// its default branch once it has loaded, mirroring the workspace pin above.
|
|
2296
|
+
const pinned = config.repositoryId ? scoped[0] : undefined;
|
|
2297
|
+
const pinnedBranch = pickBranch(pinned);
|
|
2298
|
+
useEffect(() => {
|
|
2299
|
+
if (pinned && pinnedBranch) {
|
|
2300
|
+
navigation.dispatch(
|
|
2301
|
+
StackActions.replace("Browser", {
|
|
2302
|
+
workspaceId,
|
|
2303
|
+
repositoryId: pinned.id,
|
|
2304
|
+
branchId: pinnedBranch.id,
|
|
2305
|
+
...EMPTY_SELECTION,
|
|
2306
|
+
}),
|
|
2307
|
+
);
|
|
2308
|
+
}
|
|
2309
|
+
}, [pinned, pinnedBranch, workspaceId, navigation]);
|
|
2310
|
+
// Hold the app-shell skeleton while the pinned repo resolves/redirects so the
|
|
2311
|
+
// picker never flashes before the auto-selection takes over.
|
|
2312
|
+
if (config.repositoryId && (loading || (pinned && pinnedBranch))) return <ContentBrowserSkeleton />;
|
|
2313
|
+
return (
|
|
2314
|
+
<RepoPicker
|
|
2315
|
+
repos={scoped}
|
|
2316
|
+
loading={loading}
|
|
2317
|
+
error={!!error}
|
|
2318
|
+
onPick={(repo, branch) => navigation.navigate("Browser", { workspaceId, repositoryId: repo.id, branchId: branch.id, ...EMPTY_SELECTION })}
|
|
2319
|
+
onSignOut={onSignOut}
|
|
2320
|
+
workspaceName={workspaceName}
|
|
2321
|
+
onChangeWorkspace={onChangeWorkspace}
|
|
2322
|
+
/>
|
|
2323
|
+
);
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
// A terminal configuration error, shown instead of the editor.
|
|
2327
|
+
//
|
|
2328
|
+
// Reserved for a deployment misconfiguration the user cannot fix from inside the
|
|
2329
|
+
// app — currently only a cms.config.js PROJECT lock naming a project that does
|
|
2330
|
+
// not exist. It fails loudly on purpose: the precedent in this codebase is that
|
|
2331
|
+
// a typo in cms.config.mjs is a startup error, because a locked editor quietly
|
|
2332
|
+
// showing a different project's content is worse than one that will not open.
|
|
2333
|
+
function ConfigError({ title, detail, hint }: { title: string; detail: string; hint?: string }) {
|
|
2334
|
+
return (
|
|
2335
|
+
<Screen>
|
|
2336
|
+
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 24, gap: 8 }}>
|
|
2337
|
+
<Text variant="body" weight="semibold">{title}</Text>
|
|
2338
|
+
<Text variant="sm" color="secondary" style={{ textAlign: "center" }}>{detail}</Text>
|
|
2339
|
+
{hint ? <Text variant="monoSm" color="tertiary" style={{ textAlign: "center" }}>{hint}</Text> : null}
|
|
2340
|
+
</View>
|
|
2341
|
+
</Screen>
|
|
2342
|
+
);
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// One branch's import state, as the editor needs it: the query answers on load,
|
|
2346
|
+
// the subscription keeps answering for the rest of the session. Live data wins
|
|
2347
|
+
// whenever it exists — BranchImport is not a normalized cache entity (it has no
|
|
2348
|
+
// id), so a subscription push cannot update the query's cached field on its own.
|
|
2349
|
+
//
|
|
2350
|
+
// `unknown` is the state before either has answered. It is worth a name because
|
|
2351
|
+
// the editor must not paint anything until it clears: an empty editor and a
|
|
2352
|
+
// first-import-in-progress look identical, and guessing wrong shows the user a
|
|
2353
|
+
// repository that appears to have no content.
|
|
2354
|
+
type BranchImportState = {
|
|
2355
|
+
status: string;
|
|
2356
|
+
running: boolean;
|
|
2357
|
+
imported: boolean;
|
|
2358
|
+
error?: string | null;
|
|
2359
|
+
errorCode?: string | null;
|
|
2360
|
+
};
|
|
2361
|
+
|
|
2362
|
+
function useBranchImport(repositoryId?: string, branchId?: string) {
|
|
2363
|
+
const apollo = useApolloClient();
|
|
2364
|
+
const skip = !repositoryId || !branchId;
|
|
2365
|
+
const variables = { repositoryId, branchId };
|
|
2366
|
+
// cache-and-network so a revisit paints from cache immediately and still
|
|
2367
|
+
// corrects itself — the first paint of every editor load waits on this answer.
|
|
2368
|
+
const { data, loading, refetch } = useQuery(BRANCH_IMPORT, {
|
|
2369
|
+
variables,
|
|
2370
|
+
skip,
|
|
2371
|
+
fetchPolicy: "cache-and-network",
|
|
2372
|
+
});
|
|
2373
|
+
const [live, setLive] = useState<BranchImportState | null>(null);
|
|
2374
|
+
// Tracks the previous render's `running`, to spot the moment an import lands.
|
|
2375
|
+
const wasRunning = useRef(false);
|
|
2376
|
+
// A branch switch invalidates the previous branch's live state; without this
|
|
2377
|
+
// the new branch would inherit it until the new subscription's first emission.
|
|
2378
|
+
// Declared before the landing effect below so it resets first, and the new
|
|
2379
|
+
// branch is never credited with the old one's in-flight import.
|
|
2380
|
+
useEffect(() => { setLive(null); wasRunning.current = false; }, [repositoryId, branchId]);
|
|
2381
|
+
useSubscription(BRANCH_IMPORT_PROGRESS, {
|
|
2382
|
+
variables,
|
|
2383
|
+
// Not in local mode: the working copy is never imported, so the stream has
|
|
2384
|
+
// nothing to say — and the local server's WebSocket transport can't carry
|
|
2385
|
+
// its session token anyway, so subscribing would only fail on a loop.
|
|
2386
|
+
skip: skip || config.local,
|
|
2387
|
+
onData: ({ data: payload }) => {
|
|
2388
|
+
const next = payload.data?.branchImportProgress;
|
|
2389
|
+
if (next) setLive(next);
|
|
2390
|
+
},
|
|
2391
|
+
});
|
|
2392
|
+
|
|
2393
|
+
const state: BranchImportState | null = live ?? data?.branchImport ?? null;
|
|
2394
|
+
|
|
2395
|
+
// An import that just landed replaced the content behind every open query, so
|
|
2396
|
+
// re-read them. When this fires for a FIRST import the editor is not mounted
|
|
2397
|
+
// yet and the only active query is the repository list — which is exactly the
|
|
2398
|
+
// one that has to be re-read, since the branch's projects arrive with the
|
|
2399
|
+
// import.
|
|
2400
|
+
useEffect(() => {
|
|
2401
|
+
if (!state) return;
|
|
2402
|
+
if (wasRunning.current && !state.running) void apollo.refetchQueries({ include: "active" });
|
|
2403
|
+
wasRunning.current = state.running;
|
|
2404
|
+
}, [state?.running, apollo]);
|
|
2405
|
+
|
|
2406
|
+
const firstImport = !!state && !state.imported;
|
|
2407
|
+
return {
|
|
2408
|
+
// Nothing has answered yet. Distinct from "no import run exists", which is a
|
|
2409
|
+
// real answer (status "none") and lets the editor open.
|
|
2410
|
+
unknown: !skip && !state,
|
|
2411
|
+
// The first import of this branch is in flight: no content exists yet, so
|
|
2412
|
+
// the editor has nothing to render.
|
|
2413
|
+
firstImportRunning: firstImport && state.running,
|
|
2414
|
+
// The first import failed. Nothing was ever imported, so there is no content
|
|
2415
|
+
// to fall back on.
|
|
2416
|
+
firstImportFailed: firstImport && state.status === "failed",
|
|
2417
|
+
// A re-import over content that is already on screen. The editor stays
|
|
2418
|
+
// usable; only the bell changes.
|
|
2419
|
+
importing: !!state && state.running && state.imported,
|
|
2420
|
+
error: state?.error ?? undefined,
|
|
2421
|
+
errorCode: state?.errorCode ?? undefined,
|
|
2422
|
+
rechecking: loading,
|
|
2423
|
+
recheck: () => { void refetch(); },
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
// The Browser route (/app/{ws}/repositories/{repo}[/…]). Resolves the repo +
|
|
2428
|
+
// branch within the route's workspace, then hands off to CmsView.
|
|
2429
|
+
function BrowserScreen({ onSignOut, workspaces }: { onSignOut: () => void; workspaces: Workspace[] }) {
|
|
2430
|
+
const navigation = useNavigation<any>();
|
|
2431
|
+
const route = useRoute();
|
|
2432
|
+
const { workspaceId, repositoryId, branchId, projectName } = route.params as BrowserParams;
|
|
2433
|
+
const { data, loading } = useQuery(REPOSITORIES, { variables: { workspaceId } });
|
|
2434
|
+
const repos: Repository[] = data?.repositories ?? [];
|
|
2435
|
+
const repo = repos.find((r) => r.id === repositoryId);
|
|
2436
|
+
const branch = pickBranch(repo, branchId);
|
|
2437
|
+
const project = resolveProject(repo, projectName);
|
|
2438
|
+
// Resolved from the repository's provider default branch. It is the base the
|
|
2439
|
+
// changes surface compares against, and the branch whose id navigation.tsx
|
|
2440
|
+
// omits from the URL — so pre-existing links stay unchanged.
|
|
2441
|
+
const defaultBranchId = repo?.branches.find((b) => b.name === repo.defaultBranch)?.id;
|
|
2442
|
+
// Where this branch stands with importing. The URL usually carries the branch
|
|
2443
|
+
// id, so this fires on the first render alongside the repository list rather
|
|
2444
|
+
// than waiting for it — only a link to a repository root (no branch in the
|
|
2445
|
+
// path) has to resolve the default branch first.
|
|
2446
|
+
const branchImport = useBranchImport(repositoryId, branchId ?? branch?.id);
|
|
2447
|
+
// Repositories still loading: hold the app-shell skeleton (the dashboard opens
|
|
2448
|
+
// editors straight at a repo URL) rather than flashing a not-found state.
|
|
2449
|
+
if (loading && !repo) return <ContentBrowserSkeleton />;
|
|
2450
|
+
// Stale/unknown repo link: fall back to the picker so the user can recover.
|
|
2451
|
+
if (!repo || !branch) {
|
|
2452
|
+
const workspaceName = workspaces.find((w) => w.id === workspaceId)?.name ?? "Workspace";
|
|
2453
|
+
const onChangeWorkspace = config.workspaceId ? undefined : () => navigation.navigate("WorkspacePicker");
|
|
2454
|
+
return (
|
|
2455
|
+
<RepoPicker
|
|
2456
|
+
repos={repos}
|
|
2457
|
+
loading={false}
|
|
2458
|
+
error={false}
|
|
2459
|
+
onPick={(r, b) => navigation.navigate("Browser", { workspaceId, repositoryId: r.id, branchId: b.id, ...EMPTY_SELECTION })}
|
|
2460
|
+
onSignOut={onSignOut}
|
|
2461
|
+
workspaceName={workspaceName}
|
|
2462
|
+
onChangeWorkspace={onChangeWorkspace}
|
|
2463
|
+
/>
|
|
2464
|
+
);
|
|
2465
|
+
}
|
|
2466
|
+
// A cms.config.js PROJECT naming a project this repository does not have is a
|
|
2467
|
+
// startup error, matching the precedent that a typo in cms.config.mjs fails
|
|
2468
|
+
// loudly rather than producing a silently degraded editor. Showing another
|
|
2469
|
+
// project's content would defeat the point of locking.
|
|
2470
|
+
if (project.locked && project.missing) {
|
|
2471
|
+
return (
|
|
2472
|
+
<ConfigError
|
|
2473
|
+
title="This editor is locked to a project that does not exist"
|
|
2474
|
+
detail={`cms.config.js names PROJECT "${config.project}", but ${repo.owner}/${repo.name} has no such project.`}
|
|
2475
|
+
hint={(repo.projects ?? []).length > 0
|
|
2476
|
+
? `It has: ${(repo.projects ?? []).map((p) => p.name || "(unnamed)").join(", ")}.`
|
|
2477
|
+
: "It has no imported projects yet."}
|
|
2478
|
+
/>
|
|
2479
|
+
);
|
|
2480
|
+
}
|
|
2481
|
+
// ---- the first-import gate ------------------------------------------------
|
|
2482
|
+
// A branch whose content has never been imported cannot be edited, and an
|
|
2483
|
+
// empty editor would say something false about the repository. Hold the whole
|
|
2484
|
+
// screen until the import lands (the subscription ends this by itself), or say
|
|
2485
|
+
// why it never will.
|
|
2486
|
+
//
|
|
2487
|
+
// The unknown case holds the same skeleton the app already shows while the
|
|
2488
|
+
// repository list loads. It costs a round trip on a cold load — an editor
|
|
2489
|
+
// opened without a branch in the URL can only ask once the default branch has
|
|
2490
|
+
// resolved — and buys never flashing an empty editor over a running first
|
|
2491
|
+
// import. The query is cache-and-network, so every later visit paints at once.
|
|
2492
|
+
const repoLabel = `${repo.owner}/${repo.name}`;
|
|
2493
|
+
if (branchImport.unknown) return <ContentBrowserSkeleton />;
|
|
2494
|
+
if (branchImport.firstImportRunning) {
|
|
2495
|
+
return <BranchImportingScreen branch={branch.name} repository={repoLabel} />;
|
|
2496
|
+
}
|
|
2497
|
+
if (branchImport.firstImportFailed) {
|
|
2498
|
+
return (
|
|
2499
|
+
<BranchImportFailedScreen
|
|
2500
|
+
branch={branch.name}
|
|
2501
|
+
repository={repoLabel}
|
|
2502
|
+
error={branchImport.error}
|
|
2503
|
+
errorCode={branchImport.errorCode}
|
|
2504
|
+
// The editor cannot start an import; re-reading the state is the useful
|
|
2505
|
+
// action right after committing the config the message asked for.
|
|
2506
|
+
onRecheck={branchImport.recheck}
|
|
2507
|
+
rechecking={branchImport.rechecking}
|
|
2508
|
+
/>
|
|
2509
|
+
);
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
return (
|
|
2513
|
+
<CmsView
|
|
2514
|
+
repo={repo}
|
|
2515
|
+
branch={branch}
|
|
2516
|
+
defaultBranchId={defaultBranchId}
|
|
2517
|
+
// A re-import over content that is already imported. The editor stays
|
|
2518
|
+
// fully usable and only the notification bell reports it.
|
|
2519
|
+
importing={branchImport.importing}
|
|
2520
|
+
projectName={project.name}
|
|
2521
|
+
projects={project.projects}
|
|
2522
|
+
soleProjectName={project.sole?.name}
|
|
2523
|
+
projectLocked={project.locked}
|
|
2524
|
+
onSignOut={onSignOut}
|
|
2525
|
+
/>
|
|
2526
|
+
);
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
function Authed({ onSignOut }: { onSignOut: () => void }) {
|
|
2530
|
+
// Skip the workspace list entirely when a workspace is pinned by config — the
|
|
2531
|
+
// WorkspacePicker screen redirects straight into it.
|
|
2532
|
+
const { data, loading, error } = useQuery(WORKSPACES, { skip: !!config.workspaceId });
|
|
2533
|
+
const workspaces: Workspace[] = data?.workspaces ?? [];
|
|
2534
|
+
|
|
2535
|
+
// The whole /app URL tree is a navigator: WorkspacePicker at the root,
|
|
2536
|
+
// RepoPicker beneath a workspace, Browser beneath a repository. The current
|
|
2537
|
+
// screen + its params ARE the URL.
|
|
2538
|
+
return (
|
|
2539
|
+
<NavigationRoot>
|
|
2540
|
+
<RootStack.Navigator initialRouteName="WorkspacePicker">
|
|
2541
|
+
<RootStack.Screen name="WorkspacePicker">
|
|
2542
|
+
{() => <WorkspacePickerScreen workspaces={workspaces} loading={loading} error={!!error} onSignOut={onSignOut} />}
|
|
2543
|
+
</RootStack.Screen>
|
|
2544
|
+
<RootStack.Screen name="RepoPicker">
|
|
2545
|
+
{() => <RepoPickerScreen workspaces={workspaces} onSignOut={onSignOut} />}
|
|
2546
|
+
</RootStack.Screen>
|
|
2547
|
+
<RootStack.Screen name="Browser">
|
|
2548
|
+
{() => <BrowserScreen onSignOut={onSignOut} workspaces={workspaces} />}
|
|
2549
|
+
</RootStack.Screen>
|
|
2550
|
+
</RootStack.Navigator>
|
|
2551
|
+
</NavigationRoot>
|
|
2552
|
+
);
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
function Root() {
|
|
2556
|
+
// "loading" while we resolve the session (PKCE callback or a silent authorize
|
|
2557
|
+
// redirect); "authed" once we hold an access token; "login" to fall back to the
|
|
2558
|
+
// sign-in form when there is no issuer session to authorize against.
|
|
2559
|
+
const [phase, setPhase] = useState<"loading" | "authed" | "login">("loading");
|
|
2560
|
+
|
|
2561
|
+
useEffect(() => {
|
|
2562
|
+
let cancelled = false;
|
|
2563
|
+
(async () => {
|
|
2564
|
+
// First, handle a return from the issuer's /authorize (a ?code or ?error).
|
|
2565
|
+
const result = await handleCallback();
|
|
2566
|
+
if (cancelled) return;
|
|
2567
|
+
if (result.kind === "token") {
|
|
2568
|
+
setAuthToken(result.accessToken);
|
|
2569
|
+
setPhase("authed");
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
if (result.kind === "error") {
|
|
2573
|
+
// login_required (no issuer session) or an exchange failure: show the
|
|
2574
|
+
// sign-in form rather than redirect-looping.
|
|
2575
|
+
setPhase("login");
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2578
|
+
if (result.kind === "loggedout") {
|
|
2579
|
+
// Just returned from /logout with the session cleared: land on sign-in
|
|
2580
|
+
// and do NOT auto-authorize, or we'd immediately sign back in.
|
|
2581
|
+
setPhase("login");
|
|
2582
|
+
return;
|
|
2583
|
+
}
|
|
2584
|
+
// A sticky sign-out from an earlier click in this tab (survives the logout
|
|
2585
|
+
// round-trip and any reload): stay on sign-in instead of silently
|
|
2586
|
+
// re-establishing a session from a still-live issuer cookie — via the
|
|
2587
|
+
// broker or the PKCE bounce — until the user signs in again.
|
|
2588
|
+
if (isSignedOut()) {
|
|
2589
|
+
setPhase("login");
|
|
2590
|
+
return;
|
|
2591
|
+
}
|
|
2592
|
+
// Served by `gogitcms-editor dev`? Take the token the CLI is lending rather
|
|
2593
|
+
// than bouncing through an issuer that cannot allowlist a localhost port.
|
|
2594
|
+
const brokered = await brokeredToken();
|
|
2595
|
+
if (cancelled) return;
|
|
2596
|
+
if (brokered) {
|
|
2597
|
+
setAuthToken(brokered);
|
|
2598
|
+
setPhase("authed");
|
|
2599
|
+
return;
|
|
2600
|
+
}
|
|
2601
|
+
// A plain load with no session yet: start the PKCE flow. If the user has a
|
|
2602
|
+
// session at the issuer (e.g. opened from the dashboard) this bounces back
|
|
2603
|
+
// with a code silently; otherwise it returns ?error and we land on login.
|
|
2604
|
+
void beginAuthorize();
|
|
2605
|
+
})();
|
|
2606
|
+
return () => {
|
|
2607
|
+
cancelled = true;
|
|
2608
|
+
};
|
|
2609
|
+
}, []);
|
|
2610
|
+
|
|
2611
|
+
// Render the app shell as a skeleton while authorizing rather than flashing the
|
|
2612
|
+
// login form or a bare spinner.
|
|
2613
|
+
if (phase === "loading") {
|
|
2614
|
+
return <ContentBrowserSkeleton />;
|
|
2615
|
+
}
|
|
2616
|
+
return (
|
|
2617
|
+
<>
|
|
2618
|
+
{/* Mounted in every phase, not just the authed one, so it observes the
|
|
2619
|
+
sign-out transition and can reset the analytics identity. */}
|
|
2620
|
+
<IdentifyUser authed={phase === "authed"} />
|
|
2621
|
+
{phase === "authed" ? (
|
|
2622
|
+
<Authed
|
|
2623
|
+
onSignOut={() => {
|
|
2624
|
+
// Reflect the sign-out in the UI immediately, before the /logout
|
|
2625
|
+
// navigation. That top-level request clears the issuer session cookie,
|
|
2626
|
+
// but it only bounces back to the editor (re-bootstrapping the app)
|
|
2627
|
+
// when this origin is on the redirect allowlist. A self-hosted editor
|
|
2628
|
+
// that isn't registered gets a 204 instead, which aborts the
|
|
2629
|
+
// navigation and leaves the browser sitting on the stale, still-authed
|
|
2630
|
+
// page until a manual refresh. Flipping to the signed-out view here
|
|
2631
|
+
// makes logout take effect regardless of how /logout answers.
|
|
2632
|
+
setAuthToken("");
|
|
2633
|
+
setPhase("login");
|
|
2634
|
+
beginLogout();
|
|
2635
|
+
}}
|
|
2636
|
+
/>
|
|
2637
|
+
) : (
|
|
2638
|
+
<Login onDone={(tok) => { clearSignedOut(); setAuthToken(tok); setPhase("authed"); }} />
|
|
2639
|
+
)}
|
|
2640
|
+
</>
|
|
2641
|
+
);
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
// IdentifyUser ties analytics events to the signed-in user. It renders nothing;
|
|
2645
|
+
// the ME query is already in Apollo's cache from the editor screen, so this
|
|
2646
|
+
// costs no extra request. The id is the identity; the email rides along as a
|
|
2647
|
+
// person property so the person is recognizable in PostHog.
|
|
2648
|
+
function IdentifyUser({ authed }: { authed: boolean }) {
|
|
2649
|
+
const { data } = useQuery(ME, { skip: !authed });
|
|
2650
|
+
const me = data?.me as { id: string; email?: string } | undefined;
|
|
2651
|
+
useIdentify(authed ? me?.id : undefined, me?.email ? { email: me.email } : undefined);
|
|
2652
|
+
return null;
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
export default function App() {
|
|
2656
|
+
return (
|
|
2657
|
+
<AnalyticsProvider config={analyticsConfig}>
|
|
2658
|
+
<ThemeProvider>
|
|
2659
|
+
<ApolloProvider client={client}>
|
|
2660
|
+
<Root />
|
|
2661
|
+
</ApolloProvider>
|
|
2662
|
+
</ThemeProvider>
|
|
2663
|
+
</AnalyticsProvider>
|
|
2664
|
+
);
|
|
2665
|
+
}
|