@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.
Files changed (143) hide show
  1. package/app/index.html +38 -0
  2. package/app/src/App.tsx +2665 -0
  3. package/app/src/apollo.ts +119 -0
  4. package/app/src/auth.ts +248 -0
  5. package/app/src/config.ts +88 -0
  6. package/app/src/main.tsx +18 -0
  7. package/app/src/media.ts +151 -0
  8. package/app/src/navigation.tsx +321 -0
  9. package/app/src/plugins.ts +11 -0
  10. package/app/src/previewHost.tsx +263 -0
  11. package/app/src/previewToken.ts +68 -0
  12. package/app/src/queries.ts +591 -0
  13. package/app/src/virtual-cms-plugins.d.ts +6 -0
  14. package/app/vendor/analytics/src/__tests__/config.test.ts +91 -0
  15. package/app/vendor/analytics/src/config.ts +94 -0
  16. package/app/vendor/analytics/src/events.ts +56 -0
  17. package/app/vendor/analytics/src/index.ts +23 -0
  18. package/app/vendor/analytics/src/provider.tsx +196 -0
  19. package/app/vendor/design-system/src/ThemeProvider.tsx +86 -0
  20. package/app/vendor/design-system/src/__tests__/ApplyChangesModal.test.tsx +148 -0
  21. package/app/vendor/design-system/src/__tests__/BranchImport.test.tsx +46 -0
  22. package/app/vendor/design-system/src/__tests__/Button.test.tsx +45 -0
  23. package/app/vendor/design-system/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
  24. package/app/vendor/design-system/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
  25. package/app/vendor/design-system/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
  26. package/app/vendor/design-system/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
  27. package/app/vendor/design-system/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
  28. package/app/vendor/design-system/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
  29. package/app/vendor/design-system/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
  30. package/app/vendor/design-system/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
  31. package/app/vendor/design-system/src/__tests__/ContentBrowser.media.test.tsx +140 -0
  32. package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
  33. package/app/vendor/design-system/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
  34. package/app/vendor/design-system/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
  35. package/app/vendor/design-system/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
  36. package/app/vendor/design-system/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
  37. package/app/vendor/design-system/src/__tests__/ContentBrowser.search.test.tsx +135 -0
  38. package/app/vendor/design-system/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
  39. package/app/vendor/design-system/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
  40. package/app/vendor/design-system/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
  41. package/app/vendor/design-system/src/__tests__/MediaBrowser.test.tsx +353 -0
  42. package/app/vendor/design-system/src/__tests__/MediaField.test.tsx +185 -0
  43. package/app/vendor/design-system/src/__tests__/Notifications.test.tsx +69 -0
  44. package/app/vendor/design-system/src/__tests__/Onboarding.test.tsx +287 -0
  45. package/app/vendor/design-system/src/__tests__/cssTokens.test.ts +201 -0
  46. package/app/vendor/design-system/src/__tests__/fieldComponents.test.ts +43 -0
  47. package/app/vendor/design-system/src/__tests__/reorder.test.ts +58 -0
  48. package/app/vendor/design-system/src/components/ApplyChangesModal.tsx +348 -0
  49. package/app/vendor/design-system/src/components/BranchImport.tsx +157 -0
  50. package/app/vendor/design-system/src/components/BranchMenu.tsx +192 -0
  51. package/app/vendor/design-system/src/components/Button.tsx +131 -0
  52. package/app/vendor/design-system/src/components/ChangeDetail.tsx +472 -0
  53. package/app/vendor/design-system/src/components/ChangeRequestSummary.tsx +173 -0
  54. package/app/vendor/design-system/src/components/CollabField.tsx +388 -0
  55. package/app/vendor/design-system/src/components/ContentBrowser.tsx +5073 -0
  56. package/app/vendor/design-system/src/components/Icon.tsx +28 -0
  57. package/app/vendor/design-system/src/components/Icon.web.tsx +31 -0
  58. package/app/vendor/design-system/src/components/Input.tsx +106 -0
  59. package/app/vendor/design-system/src/components/MediaBrowser.tsx +766 -0
  60. package/app/vendor/design-system/src/components/MediaField.tsx +670 -0
  61. package/app/vendor/design-system/src/components/MediaPreview.tsx +91 -0
  62. package/app/vendor/design-system/src/components/MediaPreview.web.tsx +169 -0
  63. package/app/vendor/design-system/src/components/NavRow.tsx +105 -0
  64. package/app/vendor/design-system/src/components/Notifications.tsx +301 -0
  65. package/app/vendor/design-system/src/components/Onboarding.tsx +751 -0
  66. package/app/vendor/design-system/src/components/ProjectMenu.tsx +124 -0
  67. package/app/vendor/design-system/src/components/Segment.tsx +87 -0
  68. package/app/vendor/design-system/src/components/Skeleton.tsx +216 -0
  69. package/app/vendor/design-system/src/components/Spinner.tsx +44 -0
  70. package/app/vendor/design-system/src/components/Text.tsx +85 -0
  71. package/app/vendor/design-system/src/components/documentDrafts.ts +213 -0
  72. package/app/vendor/design-system/src/components/layout.tsx +284 -0
  73. package/app/vendor/design-system/src/components/primitives.tsx +143 -0
  74. package/app/vendor/design-system/src/components/reorder.ts +40 -0
  75. package/app/vendor/design-system/src/fieldComponents.ts +63 -0
  76. package/app/vendor/design-system/src/icons.ts +102 -0
  77. package/app/vendor/design-system/src/index.ts +198 -0
  78. package/app/vendor/design-system/src/media.ts +229 -0
  79. package/app/vendor/design-system/src/theme.ts +116 -0
  80. package/app/vendor/design-system/src/web/Button.tsx +110 -0
  81. package/app/vendor/design-system/src/web/Icon.tsx +52 -0
  82. package/app/vendor/design-system/src/web/Input.tsx +39 -0
  83. package/app/vendor/design-system/src/web/index.ts +43 -0
  84. package/app/vendor/design-system/src/web/primitives.tsx +119 -0
  85. package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +248 -0
  86. package/app/vendor/markdown-editor/src/Toolbar.tsx +157 -0
  87. package/app/vendor/markdown-editor/src/field.tsx +67 -0
  88. package/app/vendor/markdown-editor/src/flavors/commonmark/index.ts +27 -0
  89. package/app/vendor/markdown-editor/src/flavors/gfm/index.ts +30 -0
  90. package/app/vendor/markdown-editor/src/flavors/gfm/parser.ts +73 -0
  91. package/app/vendor/markdown-editor/src/flavors/gfm/schema.ts +70 -0
  92. package/app/vendor/markdown-editor/src/flavors/gfm/serializer.ts +82 -0
  93. package/app/vendor/markdown-editor/src/flavors/gfm/taskList.ts +39 -0
  94. package/app/vendor/markdown-editor/src/flavors/registry.ts +13 -0
  95. package/app/vendor/markdown-editor/src/flavors/shared/inputrules.ts +79 -0
  96. package/app/vendor/markdown-editor/src/flavors/shared/keymap.ts +89 -0
  97. package/app/vendor/markdown-editor/src/flavors/shared/placeholder.ts +24 -0
  98. package/app/vendor/markdown-editor/src/flavors/shared/plugins.ts +58 -0
  99. package/app/vendor/markdown-editor/src/flavors/types.ts +22 -0
  100. package/app/vendor/markdown-editor/src/formats/registry.ts +45 -0
  101. package/app/vendor/markdown-editor/src/icons.tsx +120 -0
  102. package/app/vendor/markdown-editor/src/index.ts +23 -0
  103. package/app/vendor/markdown-editor/src/theme.ts +249 -0
  104. package/app/vendor/markdown-editor/src/types.ts +58 -0
  105. package/app/vendor/plugin-sdk/src/__tests__/documentActions.test.ts +108 -0
  106. package/app/vendor/plugin-sdk/src/__tests__/loader.test.ts +75 -0
  107. package/app/vendor/plugin-sdk/src/__tests__/registry.test.ts +148 -0
  108. package/app/vendor/plugin-sdk/src/index.ts +33 -0
  109. package/app/vendor/plugin-sdk/src/loader.ts +45 -0
  110. package/app/vendor/plugin-sdk/src/navkeys.ts +17 -0
  111. package/app/vendor/plugin-sdk/src/react.tsx +155 -0
  112. package/app/vendor/plugin-sdk/src/registry.ts +337 -0
  113. package/app/vendor/plugin-sdk/src/types.ts +230 -0
  114. package/app/vendor/realtime/src/__tests__/pure.test.ts +58 -0
  115. package/app/vendor/realtime/src/__tests__/seeded-room.base64 +1 -0
  116. package/app/vendor/realtime/src/__tests__/seeding.test.ts +70 -0
  117. package/app/vendor/realtime/src/collab.ts +308 -0
  118. package/app/vendor/realtime/src/hooks.ts +57 -0
  119. package/app/vendor/realtime/src/index.ts +12 -0
  120. package/app/vendor/realtime/src/pure.ts +43 -0
  121. package/app/vite.config.mjs +59 -0
  122. package/bin/gogitcms-editor.mjs +168 -0
  123. package/npm-shrinkwrap.json +5696 -0
  124. package/package.json +74 -0
  125. package/src/commands/build.mjs +100 -0
  126. package/src/commands/dev.mjs +186 -0
  127. package/src/commands/init.mjs +144 -0
  128. package/src/commands/login.mjs +116 -0
  129. package/src/commands/status.mjs +68 -0
  130. package/src/lib/api.mjs +122 -0
  131. package/src/lib/appRoot.mjs +51 -0
  132. package/src/lib/baked.json +3 -0
  133. package/src/lib/config.mjs +108 -0
  134. package/src/lib/credentials.mjs +91 -0
  135. package/src/lib/defaults.mjs +26 -0
  136. package/src/lib/graphql.mjs +28 -0
  137. package/src/lib/localServer.mjs +206 -0
  138. package/src/lib/open.mjs +23 -0
  139. package/src/lib/plugins.mjs +164 -0
  140. package/src/lib/tokenBroker.mjs +85 -0
  141. package/src/tui/prompts.mjs +92 -0
  142. package/src/tui/screen.mjs +282 -0
  143. package/src/tui/theme.mjs +40 -0
@@ -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
+ }