canopycms 0.7.1 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +197 -47
- package/dist/api/admin-branch-health.js +47 -20
- package/dist/api/admin.d.ts +13 -1
- package/dist/api/admin.js +14 -24
- package/dist/api/branch-changes.d.ts +87 -0
- package/dist/api/branch-changes.js +269 -0
- package/dist/api/branch-entry-changes.d.ts +83 -0
- package/dist/api/branch-entry-changes.js +262 -0
- package/dist/api/branch-merge.js +2 -2
- package/dist/api/branch-review.js +35 -4
- package/dist/api/branch-status.js +14 -5
- package/dist/api/branch-withdraw.js +7 -0
- package/dist/api/branch.d.ts +13 -1
- package/dist/api/branch.js +171 -97
- package/dist/api/client.d.ts +15 -0
- package/dist/api/client.js +13 -1
- package/dist/api/content.d.ts +18 -18
- package/dist/api/content.js +1 -1
- package/dist/api/github-sync.js +7 -3
- package/dist/api/groups.d.ts +4 -4
- package/dist/api/pr-comment-sync.d.ts +9 -8
- package/dist/api/pr-comment-sync.js +23 -13
- package/dist/api/routes.js +4 -0
- package/dist/api/types.d.ts +3 -1
- package/dist/authorization/groups/schema.d.ts +4 -4
- package/dist/authorization/protected-branch.d.ts +3 -3
- package/dist/authorization/protected-branch.js +3 -3
- package/dist/branch-metadata-file.d.ts +84 -0
- package/dist/branch-metadata-file.js +9 -0
- package/dist/branch-metadata.d.ts +4 -4
- package/dist/branch-metadata.js +4 -5
- package/dist/branch-param.d.ts +24 -0
- package/dist/branch-param.js +87 -0
- package/dist/branch-provisioning.d.ts +6 -3
- package/dist/branch-provisioning.js +12 -10
- package/dist/branch-registry.d.ts +6 -0
- package/dist/branch-registry.js +12 -3
- package/dist/branch-schema-cache.d.ts +22 -2
- package/dist/branch-schema-cache.js +88 -12
- package/dist/branch-workspace.js +22 -15
- package/dist/cli/cli.js +1122 -181
- package/dist/cli/generate-ai-content.js +791 -109
- package/dist/cli/init.js +126 -0
- package/dist/comment-store.d.ts +3 -0
- package/dist/comment-store.js +4 -1
- package/dist/config/schemas/config.d.ts +3 -0
- package/dist/config/schemas/config.js +2 -0
- package/dist/config/types.d.ts +11 -0
- package/dist/content-id-index.d.ts +5 -0
- package/dist/content-id-index.js +1 -1
- package/dist/content-listing.d.ts +53 -0
- package/dist/content-listing.js +123 -27
- package/dist/content-reader.d.ts +24 -10
- package/dist/content-reader.js +66 -22
- package/dist/context.js +51 -48
- package/dist/editor/BranchManager.d.ts +7 -3
- package/dist/editor/BranchManager.js +51 -6
- package/dist/editor/Editor.js +166 -34
- package/dist/editor/EntryNavigator.d.ts +34 -10
- package/dist/editor/EntryNavigator.js +185 -29
- package/dist/editor/admin/SystemHealthPanel.js +36 -1
- package/dist/editor/admin/useSystemHealth.js +1 -0
- package/dist/editor/branch-names.d.ts +43 -0
- package/dist/editor/branch-names.js +75 -0
- package/dist/editor/components/BranchPill.d.ts +2 -2
- package/dist/editor/components/BranchPill.js +2 -2
- package/dist/editor/components/EditorHeader.d.ts +3 -1
- package/dist/editor/components/EditorHeader.js +2 -2
- package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
- package/dist/editor/components/EntryChangesDrawer.js +363 -0
- package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
- package/dist/editor/components/EntryChangesSkeleton.js +7 -0
- package/dist/editor/components/EntryHeader.d.ts +9 -1
- package/dist/editor/components/EntryHeader.js +25 -3
- package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
- package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
- package/dist/editor/components/ReviewDialogs.js +3 -3
- package/dist/editor/context/ApiClientContext.d.ts +9 -0
- package/dist/editor/context/ApiClientContext.js +26 -2
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/copy.d.ts +1 -0
- package/dist/editor/copy.js +1 -0
- package/dist/editor/editor-utils.d.ts +2 -0
- package/dist/editor/editor-utils.js +15 -0
- package/dist/editor/entry-diff.d.ts +143 -0
- package/dist/editor/entry-diff.js +593 -0
- package/dist/editor/hooks/create-branch-request.d.ts +11 -1
- package/dist/editor/hooks/create-branch-request.js +16 -6
- package/dist/editor/hooks/useBranchActions.d.ts +19 -2
- package/dist/editor/hooks/useBranchActions.js +29 -20
- package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
- package/dist/editor/hooks/useBranchChangesData.js +134 -0
- package/dist/editor/hooks/useBranchManager.d.ts +16 -0
- package/dist/editor/hooks/useBranchManager.js +142 -54
- package/dist/editor/hooks/useDraftManager.js +6 -2
- package/dist/editor/hooks/useEntryManager.d.ts +18 -2
- package/dist/editor/hooks/useEntryManager.js +36 -10
- package/dist/editor/utils/editor-errors.d.ts +5 -2
- package/dist/git-manager.d.ts +71 -0
- package/dist/git-manager.js +270 -1
- package/dist/http/handler.js +11 -9
- package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
- package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
- package/dist/operating-mode/branch-namespace.d.ts +34 -0
- package/dist/operating-mode/branch-namespace.js +121 -0
- package/dist/operating-mode/client-unsafe-strategy.js +7 -0
- package/dist/operating-mode/types.d.ts +8 -0
- package/dist/paths/branch-name.d.ts +11 -19
- package/dist/paths/branch-name.js +17 -27
- package/dist/paths/index.d.ts +2 -2
- package/dist/paths/index.js +2 -2
- package/dist/paths/resolve.d.ts +0 -1
- package/dist/paths/resolve.js +0 -1
- package/dist/paths/validation.d.ts +40 -1
- package/dist/paths/validation.js +83 -4
- package/dist/pr-comments.d.ts +17 -9
- package/dist/pr-comments.js +53 -15
- package/dist/server.d.ts +6 -1
- package/dist/server.js +6 -1
- package/dist/services.js +2 -1
- package/dist/submission-attribution.d.ts +22 -0
- package/dist/submission-attribution.js +62 -6
- package/dist/task-queue/worker-status.d.ts +6 -1
- package/dist/task-queue/worker-status.js +23 -2
- package/dist/types.d.ts +59 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-policy.d.ts +111 -16
- package/dist/worker/branch-policy.js +222 -17
- package/dist/worker/cms-worker.d.ts +24 -2
- package/dist/worker/cms-worker.js +35 -24
- package/dist/worker/git-sync.d.ts +3 -3
- package/dist/worker/git-sync.js +42 -1
- package/dist/worker/github-gateway-http.js +2 -2
- package/dist/worker/github-gateway-wire.d.ts +42 -27
- package/dist/worker/github-gateway-wire.js +14 -3
- package/dist/worker/github-gateway.d.ts +17 -6
- package/dist/worker/github-gateway.js +62 -6
- package/dist/worker/github-mirror.d.ts +7 -4
- package/dist/worker/github-mirror.js +27 -16
- package/dist/worker/history-rewrite.d.ts +23 -10
- package/dist/worker/history-rewrite.js +43 -20
- package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
- package/dist/worker/legacy-branch-fixtures.js +71 -0
- package/dist/worker/rebase.d.ts +2 -1
- package/dist/worker/rebase.js +93 -36
- package/dist/worker/task-runner.d.ts +4 -1
- package/dist/worker/task-runner.js +77 -48
- package/dist/worker/unpublishable-branches.d.ts +34 -0
- package/dist/worker/unpublishable-branches.js +182 -0
- package/package.json +2 -1
package/dist/content-reader.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { loadOrCreateBranchContext } from './branch-workspace.js';
|
|
2
|
+
import { resolveBranchParam } from './branch-param.js';
|
|
2
3
|
import { SchemaUnavailableError } from './schema/schema-unavailable-error.js';
|
|
3
4
|
import { ContentStore, ContentStoreError } from './content-store.js';
|
|
4
5
|
import { namesNoWorkspace, resolveBranchPaths, } from './paths/index.js';
|
|
5
6
|
import { trimSlashes } from './paths/normalize.js';
|
|
7
|
+
import { exactBranchName, isExactBranchForm } from './paths/validation.js';
|
|
6
8
|
import { computeEntryUrl, isIndexSlug } from './utils/entry-url.js';
|
|
7
9
|
import { isDeployedStatic, isBuildMode, readsFromCheckout } from './build-mode.js';
|
|
8
10
|
import { isNotFoundError } from './utils/error.js';
|
|
@@ -14,6 +16,11 @@ import { CONTENT_READER_USER_ID } from './system-users.js';
|
|
|
14
16
|
* reads as NOT_FOUND unless `allowCreateBranch` or a `getBranchContext` resolver says otherwise.
|
|
15
17
|
*/
|
|
16
18
|
export const createContentReader = (options) => {
|
|
19
|
+
const { read } = createContentReaderInternal(options);
|
|
20
|
+
return { read };
|
|
21
|
+
};
|
|
22
|
+
/** @internal {@link createContentReader} plus `readFirst`, for context.ts. */
|
|
23
|
+
export const createContentReaderInternal = (options) => {
|
|
17
24
|
const services = options.services;
|
|
18
25
|
const operatingMode = services.config.mode;
|
|
19
26
|
const basePathOverride = options.basePathOverride;
|
|
@@ -40,28 +47,33 @@ export const createContentReader = (options) => {
|
|
|
40
47
|
throw new ContentStoreError(`Branch not found: ${branchName}`, 'NOT_FOUND');
|
|
41
48
|
return resolved;
|
|
42
49
|
}
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
// An existing branch resolves first, so a short name reads its namespace branch; only a
|
|
51
|
+
// name that resolves to none is provisioned.
|
|
52
|
+
const existing = await resolveBranchParam(branchName, services.config, {
|
|
53
|
+
basePathOverride,
|
|
54
|
+
}).catch((err) => {
|
|
55
|
+
if (namesNoWorkspace(err))
|
|
56
|
+
return null;
|
|
57
|
+
throw err;
|
|
58
|
+
});
|
|
59
|
+
if (existing)
|
|
60
|
+
return existing;
|
|
61
|
+
// The exact form provisions the branch its full name names; a malformed one names none.
|
|
62
|
+
const named = isExactBranchForm(branchName) ? exactBranchName(branchName) : branchName;
|
|
63
|
+
if (allowCreateBranch && named !== null) {
|
|
64
|
+
const context = await loadOrCreateBranchContext({
|
|
45
65
|
config: services.config,
|
|
46
|
-
branchName,
|
|
66
|
+
branchName: named,
|
|
47
67
|
mode: operatingMode,
|
|
48
68
|
basePathOverride,
|
|
49
69
|
createdBy,
|
|
50
70
|
remoteUrl: services.config.defaultRemoteUrl,
|
|
51
71
|
});
|
|
72
|
+
// A workspace already at this name's key that holds another branch is that branch's.
|
|
73
|
+
if (context.branch.name === named)
|
|
74
|
+
return context;
|
|
52
75
|
}
|
|
53
|
-
|
|
54
|
-
branchName,
|
|
55
|
-
mode: operatingMode,
|
|
56
|
-
basePathOverride,
|
|
57
|
-
}).catch((err) => {
|
|
58
|
-
if (namesNoWorkspace(err))
|
|
59
|
-
return null;
|
|
60
|
-
throw err;
|
|
61
|
-
});
|
|
62
|
-
if (!existing)
|
|
63
|
-
throw new ContentStoreError(`Branch not found: ${branchName}`, 'NOT_FOUND');
|
|
64
|
-
return existing;
|
|
76
|
+
throw new ContentStoreError(`Branch not found: ${branchName}`, 'NOT_FOUND');
|
|
65
77
|
};
|
|
66
78
|
const resolveStore = async (branchName) => {
|
|
67
79
|
const context = await resolveBranchContext(branchName);
|
|
@@ -120,9 +132,9 @@ export const createContentReader = (options) => {
|
|
|
120
132
|
const url = encodedSlug ? `${trimmed}/${encodedSlug}` : trimmed || '/';
|
|
121
133
|
return appendBranch(url);
|
|
122
134
|
};
|
|
123
|
-
const readDocument = async (input) => {
|
|
124
|
-
const { entryPath, slug,
|
|
125
|
-
const { context, branchRoot, store } =
|
|
135
|
+
const readDocument = async (input, resolved) => {
|
|
136
|
+
const { entryPath, slug, user } = resolveTarget(input);
|
|
137
|
+
const { context, branchRoot, store } = resolved;
|
|
126
138
|
// Rule 1 of urlAddressableOnly (see ReadContentInput): a published URL's non-slug segments
|
|
127
139
|
// are collection names, so a candidate landing on an entry-TYPE item can only ever produce a
|
|
128
140
|
// URL enumeration never emits. NO_SCHEMA_ITEM is what assertCollection raises for exactly
|
|
@@ -209,9 +221,9 @@ export const createContentReader = (options) => {
|
|
|
209
221
|
throw err;
|
|
210
222
|
}
|
|
211
223
|
};
|
|
212
|
-
const
|
|
224
|
+
const readResolved = async (input, resolved, message) => {
|
|
213
225
|
const { entryPath, slug, branchName } = resolveTarget(input);
|
|
214
|
-
const result = await readDocument(input);
|
|
226
|
+
const result = await readDocument(input, resolved);
|
|
215
227
|
if (!result || typeof result.doc !== 'object' || !('data' in result.doc)) {
|
|
216
228
|
const defaultMessage = `Content not found for ${entryPath}${slug ? `/${slug}` : ''} on branch ${branchName}`;
|
|
217
229
|
throw new ContentStoreError(message ?? defaultMessage, 'NOT_FOUND');
|
|
@@ -235,5 +247,37 @@ export const createContentReader = (options) => {
|
|
|
235
247
|
});
|
|
236
248
|
return { data, path, meta: { physicalPath, entryType, entryId, slug: entrySlug, urlPath } };
|
|
237
249
|
};
|
|
238
|
-
|
|
250
|
+
const read = async (input, message) => {
|
|
251
|
+
const { branchName } = resolveTarget(input);
|
|
252
|
+
return readResolved(input, await resolveStore(branchName), message);
|
|
253
|
+
};
|
|
254
|
+
const readFirst = async (inputs, isMiss) => {
|
|
255
|
+
const [first] = inputs;
|
|
256
|
+
if (!first)
|
|
257
|
+
return null;
|
|
258
|
+
if (inputs.some((input) => input.branch !== first.branch || input.user !== first.user)) {
|
|
259
|
+
throw new Error('readFirst: every input must name the same branch and user');
|
|
260
|
+
}
|
|
261
|
+
let resolved;
|
|
262
|
+
try {
|
|
263
|
+
resolved = await resolveStore(resolveTarget(first).branchName);
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
if (isMiss(err, first))
|
|
267
|
+
return null;
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
for (const input of inputs) {
|
|
271
|
+
try {
|
|
272
|
+
return await readResolved(input, resolved);
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
if (isMiss(err, input))
|
|
276
|
+
continue;
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
};
|
|
282
|
+
return { read, readFirst };
|
|
239
283
|
};
|
package/dist/context.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { isDeployedStatic, isBuildMode, readsFromCheckout, STATIC_DEPLOY_USER } from './build-mode.js';
|
|
2
|
-
import {
|
|
2
|
+
import { createContentReaderInternal } from './content-reader.js';
|
|
3
3
|
import { ContentStoreError } from './content-store.js';
|
|
4
4
|
import { createLogicalPath, namesNoWorkspace, parseSlug, resolveBranchPaths, } from './paths/index.js';
|
|
5
5
|
import { resolveUrlPathCandidates } from './url-path-resolver.js';
|
|
6
|
-
import {
|
|
6
|
+
import { loadOrCreateBranchContext } from './branch-workspace.js';
|
|
7
|
+
import { resolveBranchParam } from './branch-param.js';
|
|
7
8
|
import { buildContentTree as buildContentTreeImpl, } from './content-tree.js';
|
|
8
9
|
import { listEntries as listEntriesImpl, } from './content-listing.js';
|
|
9
10
|
import { createDebugLogger } from './utils/debug.js';
|
|
@@ -34,13 +35,13 @@ function isBranchOption(branch) {
|
|
|
34
35
|
return branch == null || typeof branch === 'string';
|
|
35
36
|
}
|
|
36
37
|
/**
|
|
37
|
-
*
|
|
38
|
-
* is null too (`namesNoWorkspace`): it arrives from the request, so it reads as
|
|
39
|
-
*
|
|
38
|
+
* The existing branch `param` names (`resolveBranchParam`), or null. A name that cannot name one
|
|
39
|
+
* is null too (`namesNoWorkspace`): it arrives from the request, so it reads as not-found rather
|
|
40
|
+
* than failing the page.
|
|
40
41
|
*/
|
|
41
|
-
async function loadExistingBranch(
|
|
42
|
+
async function loadExistingBranch(param, config) {
|
|
42
43
|
try {
|
|
43
|
-
return await
|
|
44
|
+
return await resolveBranchParam(param, config);
|
|
44
45
|
}
|
|
45
46
|
catch (err) {
|
|
46
47
|
if (namesNoWorkspace(err))
|
|
@@ -119,7 +120,7 @@ export function createCanopyContext(options) {
|
|
|
119
120
|
remoteUrl: services.config.defaultRemoteUrl,
|
|
120
121
|
});
|
|
121
122
|
}
|
|
122
|
-
const context = await loadExistingBranch(requested, services.config
|
|
123
|
+
const context = await loadExistingBranch(requested, services.config);
|
|
123
124
|
if (!context)
|
|
124
125
|
return null;
|
|
125
126
|
if (enforceAcls && !services.checkBranchAccess(context, user).allowed)
|
|
@@ -128,7 +129,7 @@ export function createCanopyContext(options) {
|
|
|
128
129
|
};
|
|
129
130
|
// The reader resolves every branch through resolveBranch; a null reads as
|
|
130
131
|
// NOT_FOUND. Reads from the checkout bypass the resolver inside the reader.
|
|
131
|
-
const baseReader =
|
|
132
|
+
const baseReader = createContentReaderInternal({
|
|
132
133
|
services,
|
|
133
134
|
defaultBranch: activeBranch,
|
|
134
135
|
getBranchContext: (branchName) => resolveBranch(otherBranch(branchName)),
|
|
@@ -139,7 +140,7 @@ export function createCanopyContext(options) {
|
|
|
139
140
|
// function rather than an optional second parameter on the
|
|
140
141
|
// `CanopyContext['read']`-typed closure, so the two call sites stay visible
|
|
141
142
|
// and nobody widens the public API by accident.
|
|
142
|
-
const
|
|
143
|
+
const toReadInput = (input, extra) => {
|
|
143
144
|
const entryPath = createLogicalPath(input.entryPath);
|
|
144
145
|
let slug;
|
|
145
146
|
if (input.slug) {
|
|
@@ -160,53 +161,55 @@ export function createCanopyContext(options) {
|
|
|
160
161
|
resolveReferences: input.resolveReferences ?? true,
|
|
161
162
|
...extra,
|
|
162
163
|
};
|
|
163
|
-
return
|
|
164
|
+
return readInput;
|
|
164
165
|
};
|
|
165
|
-
const read = (input) =>
|
|
166
|
+
const read = async (input) => baseReader.read(toReadInput(input));
|
|
166
167
|
const readByUrlPath = async (urlPath, options) => {
|
|
167
168
|
const contentRoot = services.config.contentRoot || 'content';
|
|
168
169
|
const candidates = resolveUrlPathCandidates(urlPath, contentRoot);
|
|
169
170
|
if (candidates.length === 0)
|
|
170
171
|
return null;
|
|
171
172
|
const { branch, resolveReferences } = options ?? {};
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
// A read BY PUBLISHED URL accepts only what listEntries publishes.
|
|
186
|
-
// See ReadContentInput.urlAddressableOnly for the two rules, and
|
|
187
|
-
// why they live in the reader (which holds the branch-correct
|
|
188
|
-
// schema) rather than the candidate builder (pure and schema-free).
|
|
189
|
-
{ urlAddressableOnly: true });
|
|
190
|
-
}
|
|
191
|
-
catch (err) {
|
|
192
|
-
// Swallow a candidate path's "not found", and FORBIDDEN so a denied
|
|
193
|
-
// or anonymous read renders as the adopter's 404 rather than an
|
|
194
|
-
// unhandled 500. Real errors (validation, corruption, anything not a
|
|
195
|
-
// ContentStoreError) rethrow.
|
|
196
|
-
if (err instanceof ContentStoreError && isPageSwallowable(err)) {
|
|
197
|
-
if (err.code === 'FORBIDDEN') {
|
|
198
|
-
log.debug('readByUrlPath', 'Read denied, treating as not-found: ' + err.message, {
|
|
199
|
-
urlPath,
|
|
200
|
-
entryPath: candidate.entryPath,
|
|
201
|
-
slug: candidate.slug,
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
throw err;
|
|
173
|
+
// Swallow a candidate path's "not found", and FORBIDDEN so a denied or
|
|
174
|
+
// anonymous read renders as the adopter's 404 rather than an unhandled
|
|
175
|
+
// 500. Real errors (validation, corruption, anything not a
|
|
176
|
+
// ContentStoreError) rethrow.
|
|
177
|
+
const isMiss = (err, input) => {
|
|
178
|
+
if (!(err instanceof ContentStoreError && isPageSwallowable(err)))
|
|
179
|
+
return false;
|
|
180
|
+
if (err.code === 'FORBIDDEN') {
|
|
181
|
+
log.debug('readByUrlPath', 'Read denied, treating as not-found: ' + err.message, {
|
|
182
|
+
urlPath,
|
|
183
|
+
entryPath: input?.entryPath,
|
|
184
|
+
slug: input?.slug,
|
|
185
|
+
});
|
|
207
186
|
}
|
|
187
|
+
return true;
|
|
188
|
+
};
|
|
189
|
+
// Converting every candidate up front throws nothing a candidate-by-candidate
|
|
190
|
+
// loop would not: a valid slug holds no `..`, so the index candidate's path
|
|
191
|
+
// fails createLogicalPath only when the entry candidate's does.
|
|
192
|
+
let inputs;
|
|
193
|
+
try {
|
|
194
|
+
inputs = candidates
|
|
195
|
+
// Skip a candidate whose slug isn't valid (/favicon.ico, Next internals
|
|
196
|
+
// the [...slug] route catches). None can match an entry, so treat it as
|
|
197
|
+
// a miss rather than let read() throw "Invalid slug".
|
|
198
|
+
.filter((candidate) => parseSlug(candidate.slug).ok)
|
|
199
|
+
.map((candidate) => toReadInput({ entryPath: candidate.entryPath, slug: candidate.slug, branch, resolveReferences },
|
|
200
|
+
// A read BY PUBLISHED URL accepts only what listEntries publishes.
|
|
201
|
+
// See ReadContentInput.urlAddressableOnly for the two rules, and
|
|
202
|
+
// why they live in the reader (which holds the branch-correct
|
|
203
|
+
// schema) rather than the candidate builder (pure and schema-free).
|
|
204
|
+
{ urlAddressableOnly: true }));
|
|
208
205
|
}
|
|
209
|
-
|
|
206
|
+
catch (err) {
|
|
207
|
+
if (isMiss(err))
|
|
208
|
+
return null;
|
|
209
|
+
throw err;
|
|
210
|
+
}
|
|
211
|
+
// One branch and schema resolve for every candidate.
|
|
212
|
+
return baseReader.readFirst(inputs, isMiss);
|
|
210
213
|
};
|
|
211
214
|
/**
|
|
212
215
|
* What a batch read (listEntries / buildContentTree) lists from: the branch's
|
|
@@ -3,9 +3,12 @@ import type { OperatingMode } from '../operating-mode/index.js';
|
|
|
3
3
|
import type { ConflictStatus, PullRequestState, SyncStatus } from '../types.js';
|
|
4
4
|
import type { CommentThread } from '../comment-store.js';
|
|
5
5
|
import type { UserSearchResult } from '../auth/types.js';
|
|
6
|
+
import type { CreateRefusal } from './hooks/create-branch-request.js';
|
|
6
7
|
/** @internal Exported for tests. */
|
|
7
8
|
export interface BranchSummary {
|
|
8
9
|
name: string;
|
|
10
|
+
/** What the card shows for the branch (`displayBranchName`); the full `name` keys it. */
|
|
11
|
+
shortName?: string;
|
|
9
12
|
status: string;
|
|
10
13
|
createdBy?: string;
|
|
11
14
|
updatedAt?: string;
|
|
@@ -78,14 +81,15 @@ export interface BranchManagerProps {
|
|
|
78
81
|
/** Awaited before the spinner starts, so a confirmation it shows never reads as a hung create. `false` abandons the create. */
|
|
79
82
|
onBeforeCreate?: () => Promise<boolean>;
|
|
80
83
|
/**
|
|
81
|
-
* Resolving `false` means the branch was not created, so the form stays open
|
|
82
|
-
*
|
|
84
|
+
* Resolving `false` means the branch was not created, so the form stays open with the user's
|
|
85
|
+
* values; `{ refusal }` also shows why under the name field. Any other return (including none)
|
|
86
|
+
* closes it.
|
|
83
87
|
*/
|
|
84
88
|
onCreate?: (branch: {
|
|
85
89
|
name: string;
|
|
86
90
|
title?: string;
|
|
87
91
|
description?: string;
|
|
88
|
-
}) => Promise<boolean> | void;
|
|
92
|
+
}) => Promise<boolean | CreateRefusal> | void;
|
|
89
93
|
onDelete?: (name: string) => void;
|
|
90
94
|
onSubmit?: (name: string) => void;
|
|
91
95
|
onWithdraw?: (name: string) => void;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
import { useRef, useState } from 'react';
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
4
|
import { ActionIcon, Badge, Button, Group, Menu, Paper, ScrollArea, Stack, Text, TextInput, Textarea, Collapse, Tooltip, } from '@mantine/core';
|
|
5
5
|
import { IconDots, IconExternalLink } from '@tabler/icons-react';
|
|
6
6
|
import { BranchComments } from './comments/BranchComments.js';
|
|
@@ -9,10 +9,12 @@ import { BranchAccessList } from './components/BranchAccessList.js';
|
|
|
9
9
|
// Import directly from helpers to avoid server-only code in authorization barrel
|
|
10
10
|
import { isAdmin, isLockedOutOfBranch, isReviewer } from '../authorization/helpers.js';
|
|
11
11
|
import { clientOperatingStrategy } from '../operating-mode/client.js';
|
|
12
|
+
import { parseCreatedBranchName } from '../paths/validation.js';
|
|
12
13
|
import { DestructiveMenuItem } from './components/DestructiveMenuItem.js';
|
|
13
14
|
import { ReasonedMenuItem } from './components/ReasonedMenuItem.js';
|
|
14
15
|
import { RelativeTime } from './components/RelativeTime.js';
|
|
15
16
|
import { branchStatusPresentation } from './branch-status.js';
|
|
17
|
+
import { branchAddress, displayBranchName } from './branch-names.js';
|
|
16
18
|
import { CLOSED_PULL_REQUEST_REASON, EDITOR_ACTIONS } from './copy.js';
|
|
17
19
|
import { isReviewerOfOthersReview } from '../authorization/review-role.js';
|
|
18
20
|
import { SYSTEM_USER_ID } from '../system-users.js';
|
|
@@ -183,9 +185,9 @@ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelec
|
|
|
183
185
|
const showViewPullRequest = !!b.pullRequestUrl;
|
|
184
186
|
const showDelete = !b.isProtected && (userIsAdmin || (!!user && b.createdBy === user.userId));
|
|
185
187
|
const hasMenu = menuActions.length > 0 || showViewPullRequest || showDelete;
|
|
186
|
-
const menuLabel = `More actions for ${b
|
|
188
|
+
const menuLabel = `More actions for ${displayBranchName(b)}`;
|
|
187
189
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
188
|
-
return (_jsxs(Paper, { withBorder: true, p: "md", shadow: "xs", "aria-current": isCurrent ? 'true' : undefined, "data-current": isCurrent || undefined, "data-testid": `branch-list-item-${b.name}`, children: [_jsxs(Group, { justify: "space-between", align: "flex-start", wrap: "nowrap", children: [_jsxs(Stack, { gap: 4, children: [_jsxs(Group, { gap: "xs", children: [_jsx(Text, { fw: 600, children: b
|
|
190
|
+
return (_jsxs(Paper, { withBorder: true, p: "md", shadow: "xs", "aria-current": isCurrent ? 'true' : undefined, "data-current": isCurrent || undefined, "data-testid": `branch-list-item-${b.name}`, children: [_jsxs(Group, { justify: "space-between", align: "flex-start", wrap: "nowrap", children: [_jsxs(Stack, { gap: 4, children: [_jsxs(Group, { gap: "xs", children: [_jsx(Text, { fw: 600, children: displayBranchName(b) }), isCurrent && (_jsx(Badge, { color: "brand", variant: "filled", "data-testid": `branch-current-badge-${b.name}`, children: "Current" })), statusBadge && (_jsx(Badge, { color: statusBadge.color, variant: statusBadge.variant, "data-testid": `branch-status-badge-${b.name}`, "data-status": b.status, "data-protected": b.isProtected || undefined, children: statusBadge.label })), b.status === 'archived' && b.mergedAt && (_jsx(Badge, { color: "teal", variant: "light", "data-testid": `branch-merged-badge-${b.name}`, children: "Merged" })), b.pullRequestNumber && (_jsxs(Badge, { color: b.pullRequestState === 'closed'
|
|
189
191
|
? 'red'
|
|
190
192
|
: b.pullRequestState === 'merged'
|
|
191
193
|
? 'violet'
|
|
@@ -206,10 +208,29 @@ export const BranchManager = ({ branches, mode, user, currentBranchName, current
|
|
|
206
208
|
const [newBranchTitle, setNewBranchTitle] = useState('');
|
|
207
209
|
const [newBranchDescription, setNewBranchDescription] = useState('');
|
|
208
210
|
const [creating, setCreating] = useState(false);
|
|
211
|
+
// Why a create refused the name, shown while that name is still what is typed.
|
|
212
|
+
const [refused, setRefused] = useState(null);
|
|
213
|
+
const nameInputRef = useRef(null);
|
|
214
|
+
// Set by a refusal: the name field takes focus once it is enabled again, so its error is read.
|
|
215
|
+
const focusNameRef = useRef(false);
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
if (creating || !focusNameRef.current)
|
|
218
|
+
return;
|
|
219
|
+
focusNameRef.current = false;
|
|
220
|
+
nameInputRef.current?.focus();
|
|
221
|
+
}, [creating, refused]);
|
|
209
222
|
// Read synchronously: state would not yet show a first click to a second one in the same tick.
|
|
210
223
|
const creatingRef = useRef(false);
|
|
224
|
+
// The server's own rules, so the field says what will be created, or why nothing can be.
|
|
225
|
+
const typedBranchName = newBranchName.trim();
|
|
226
|
+
const createdBranchName = typedBranchName ? parseCreatedBranchName(typedBranchName) : null;
|
|
227
|
+
const nameError = createdBranchName?.ok === false
|
|
228
|
+
? createdBranchName.error
|
|
229
|
+
: refused?.typed === typedBranchName
|
|
230
|
+
? refused.reason
|
|
231
|
+
: undefined;
|
|
211
232
|
const handleCreate = async () => {
|
|
212
|
-
if (creatingRef.current || !
|
|
233
|
+
if (creatingRef.current || !createdBranchName?.ok)
|
|
213
234
|
return;
|
|
214
235
|
creatingRef.current = true;
|
|
215
236
|
try {
|
|
@@ -217,12 +238,18 @@ export const BranchManager = ({ branches, mode, user, currentBranchName, current
|
|
|
217
238
|
return;
|
|
218
239
|
setCreating(true);
|
|
219
240
|
const created = await onCreate?.({
|
|
220
|
-
name:
|
|
241
|
+
name: typedBranchName,
|
|
221
242
|
title: newBranchTitle.trim() || undefined,
|
|
222
243
|
description: newBranchDescription.trim() || undefined,
|
|
223
244
|
});
|
|
224
245
|
if (created === false)
|
|
225
246
|
return;
|
|
247
|
+
if (typeof created === 'object') {
|
|
248
|
+
focusNameRef.current = true;
|
|
249
|
+
setRefused({ typed: typedBranchName, reason: created.refusal });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
setRefused(null);
|
|
226
253
|
setNewBranchName('');
|
|
227
254
|
setNewBranchTitle('');
|
|
228
255
|
setNewBranchDescription('');
|
|
@@ -240,5 +267,23 @@ export const BranchManager = ({ branches, mode, user, currentBranchName, current
|
|
|
240
267
|
]
|
|
241
268
|
: branches;
|
|
242
269
|
const hasBranchThreads = comments.some((t) => t.type === 'branch');
|
|
243
|
-
return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && hasBranchThreads && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetCommentUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () =>
|
|
270
|
+
return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && hasBranchThreads && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetCommentUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () => {
|
|
271
|
+
if (showCreateForm)
|
|
272
|
+
setRefused(null);
|
|
273
|
+
setShowCreateForm(!showCreateForm);
|
|
274
|
+
}, disabled: creating, "data-testid": "create-branch-button", children: showCreateForm ? 'Cancel' : `${EDITOR_ACTIONS.newBranch}…` }), _jsx(Collapse, { in: showCreateForm, children: _jsx(Paper, { withBorder: true, p: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsx(TextInput, { label: "Branch name", placeholder: "spring-update",
|
|
275
|
+
// Below the input, so a preview that wraps never moves the field being typed in.
|
|
276
|
+
inputWrapperOrder: ['label', 'input', 'description', 'error'],
|
|
277
|
+
// Only when sanitizing changed what was typed, and never beside an error.
|
|
278
|
+
description: !nameError &&
|
|
279
|
+
createdBranchName?.ok &&
|
|
280
|
+
createdBranchName.shortName !== typedBranchName
|
|
281
|
+
? `Will be named ${createdBranchName.shortName}`
|
|
282
|
+
: undefined, error: nameError, value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, disabled: creating, ref: nameInputRef, "data-autofocus": startCreating || undefined, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), disabled: creating, "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, disabled: creating, "data-testid": "branch-description-textarea" }), _jsx(Button, { size: "sm", onClick: handleCreate, disabled: !createdBranchName?.ok, loading: creating, fullWidth: true, "data-testid": "create-branch-submit", children: "Create branch" })] }) }) })] })), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (_jsx(Stack, { gap: "sm", children: orderedBranches.map((b) => {
|
|
283
|
+
// The editor resolves a shadowed outside branch's full name to the namespace branch
|
|
284
|
+
// whose short name it is, so a card's actions name its branch by `branchAddress`.
|
|
285
|
+
const address = branchAddress(branches, b);
|
|
286
|
+
const act = (run) => run && (() => run(address));
|
|
287
|
+
return (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, user: user, onSelect: act(onSelect), onSubmit: act(onSubmit), onWithdraw: act(onWithdraw), onApprove: act(onApprove), onRequestChanges: act(onRequestChanges), onDelete: act(onDelete), onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name));
|
|
288
|
+
}) })) })] }));
|
|
244
289
|
};
|