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.
Files changed (152) hide show
  1. package/CHANGELOG.md +197 -47
  2. package/dist/api/admin-branch-health.js +47 -20
  3. package/dist/api/admin.d.ts +13 -1
  4. package/dist/api/admin.js +14 -24
  5. package/dist/api/branch-changes.d.ts +87 -0
  6. package/dist/api/branch-changes.js +269 -0
  7. package/dist/api/branch-entry-changes.d.ts +83 -0
  8. package/dist/api/branch-entry-changes.js +262 -0
  9. package/dist/api/branch-merge.js +2 -2
  10. package/dist/api/branch-review.js +35 -4
  11. package/dist/api/branch-status.js +14 -5
  12. package/dist/api/branch-withdraw.js +7 -0
  13. package/dist/api/branch.d.ts +13 -1
  14. package/dist/api/branch.js +171 -97
  15. package/dist/api/client.d.ts +15 -0
  16. package/dist/api/client.js +13 -1
  17. package/dist/api/content.d.ts +18 -18
  18. package/dist/api/content.js +1 -1
  19. package/dist/api/github-sync.js +7 -3
  20. package/dist/api/groups.d.ts +4 -4
  21. package/dist/api/pr-comment-sync.d.ts +9 -8
  22. package/dist/api/pr-comment-sync.js +23 -13
  23. package/dist/api/routes.js +4 -0
  24. package/dist/api/types.d.ts +3 -1
  25. package/dist/authorization/groups/schema.d.ts +4 -4
  26. package/dist/authorization/protected-branch.d.ts +3 -3
  27. package/dist/authorization/protected-branch.js +3 -3
  28. package/dist/branch-metadata-file.d.ts +84 -0
  29. package/dist/branch-metadata-file.js +9 -0
  30. package/dist/branch-metadata.d.ts +4 -4
  31. package/dist/branch-metadata.js +4 -5
  32. package/dist/branch-param.d.ts +24 -0
  33. package/dist/branch-param.js +87 -0
  34. package/dist/branch-provisioning.d.ts +6 -3
  35. package/dist/branch-provisioning.js +12 -10
  36. package/dist/branch-registry.d.ts +6 -0
  37. package/dist/branch-registry.js +12 -3
  38. package/dist/branch-schema-cache.d.ts +22 -2
  39. package/dist/branch-schema-cache.js +88 -12
  40. package/dist/branch-workspace.js +22 -15
  41. package/dist/cli/cli.js +1122 -181
  42. package/dist/cli/generate-ai-content.js +791 -109
  43. package/dist/cli/init.js +126 -0
  44. package/dist/comment-store.d.ts +3 -0
  45. package/dist/comment-store.js +4 -1
  46. package/dist/config/schemas/config.d.ts +3 -0
  47. package/dist/config/schemas/config.js +2 -0
  48. package/dist/config/types.d.ts +11 -0
  49. package/dist/content-id-index.d.ts +5 -0
  50. package/dist/content-id-index.js +1 -1
  51. package/dist/content-listing.d.ts +53 -0
  52. package/dist/content-listing.js +123 -27
  53. package/dist/content-reader.d.ts +24 -10
  54. package/dist/content-reader.js +66 -22
  55. package/dist/context.js +51 -48
  56. package/dist/editor/BranchManager.d.ts +7 -3
  57. package/dist/editor/BranchManager.js +51 -6
  58. package/dist/editor/Editor.js +166 -34
  59. package/dist/editor/EntryNavigator.d.ts +34 -10
  60. package/dist/editor/EntryNavigator.js +185 -29
  61. package/dist/editor/admin/SystemHealthPanel.js +36 -1
  62. package/dist/editor/admin/useSystemHealth.js +1 -0
  63. package/dist/editor/branch-names.d.ts +43 -0
  64. package/dist/editor/branch-names.js +75 -0
  65. package/dist/editor/components/BranchPill.d.ts +2 -2
  66. package/dist/editor/components/BranchPill.js +2 -2
  67. package/dist/editor/components/EditorHeader.d.ts +3 -1
  68. package/dist/editor/components/EditorHeader.js +2 -2
  69. package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
  70. package/dist/editor/components/EntryChangesDrawer.js +363 -0
  71. package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
  72. package/dist/editor/components/EntryChangesSkeleton.js +7 -0
  73. package/dist/editor/components/EntryHeader.d.ts +9 -1
  74. package/dist/editor/components/EntryHeader.js +25 -3
  75. package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
  76. package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
  77. package/dist/editor/components/ReviewDialogs.js +3 -3
  78. package/dist/editor/context/ApiClientContext.d.ts +9 -0
  79. package/dist/editor/context/ApiClientContext.js +26 -2
  80. package/dist/editor/context/index.d.ts +1 -1
  81. package/dist/editor/context/index.js +1 -1
  82. package/dist/editor/copy.d.ts +1 -0
  83. package/dist/editor/copy.js +1 -0
  84. package/dist/editor/editor-utils.d.ts +2 -0
  85. package/dist/editor/editor-utils.js +15 -0
  86. package/dist/editor/entry-diff.d.ts +143 -0
  87. package/dist/editor/entry-diff.js +593 -0
  88. package/dist/editor/hooks/create-branch-request.d.ts +11 -1
  89. package/dist/editor/hooks/create-branch-request.js +16 -6
  90. package/dist/editor/hooks/useBranchActions.d.ts +19 -2
  91. package/dist/editor/hooks/useBranchActions.js +29 -20
  92. package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
  93. package/dist/editor/hooks/useBranchChangesData.js +134 -0
  94. package/dist/editor/hooks/useBranchManager.d.ts +16 -0
  95. package/dist/editor/hooks/useBranchManager.js +142 -54
  96. package/dist/editor/hooks/useDraftManager.js +6 -2
  97. package/dist/editor/hooks/useEntryManager.d.ts +18 -2
  98. package/dist/editor/hooks/useEntryManager.js +36 -10
  99. package/dist/editor/utils/editor-errors.d.ts +5 -2
  100. package/dist/git-manager.d.ts +71 -0
  101. package/dist/git-manager.js +270 -1
  102. package/dist/http/handler.js +11 -9
  103. package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
  104. package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
  105. package/dist/operating-mode/branch-namespace.d.ts +34 -0
  106. package/dist/operating-mode/branch-namespace.js +121 -0
  107. package/dist/operating-mode/client-unsafe-strategy.js +7 -0
  108. package/dist/operating-mode/types.d.ts +8 -0
  109. package/dist/paths/branch-name.d.ts +11 -19
  110. package/dist/paths/branch-name.js +17 -27
  111. package/dist/paths/index.d.ts +2 -2
  112. package/dist/paths/index.js +2 -2
  113. package/dist/paths/resolve.d.ts +0 -1
  114. package/dist/paths/resolve.js +0 -1
  115. package/dist/paths/validation.d.ts +40 -1
  116. package/dist/paths/validation.js +83 -4
  117. package/dist/pr-comments.d.ts +17 -9
  118. package/dist/pr-comments.js +53 -15
  119. package/dist/server.d.ts +6 -1
  120. package/dist/server.js +6 -1
  121. package/dist/services.js +2 -1
  122. package/dist/submission-attribution.d.ts +22 -0
  123. package/dist/submission-attribution.js +62 -6
  124. package/dist/task-queue/worker-status.d.ts +6 -1
  125. package/dist/task-queue/worker-status.js +23 -2
  126. package/dist/types.d.ts +59 -0
  127. package/dist/version.d.ts +1 -1
  128. package/dist/version.js +1 -1
  129. package/dist/worker/branch-policy.d.ts +111 -16
  130. package/dist/worker/branch-policy.js +222 -17
  131. package/dist/worker/cms-worker.d.ts +24 -2
  132. package/dist/worker/cms-worker.js +35 -24
  133. package/dist/worker/git-sync.d.ts +3 -3
  134. package/dist/worker/git-sync.js +42 -1
  135. package/dist/worker/github-gateway-http.js +2 -2
  136. package/dist/worker/github-gateway-wire.d.ts +42 -27
  137. package/dist/worker/github-gateway-wire.js +14 -3
  138. package/dist/worker/github-gateway.d.ts +17 -6
  139. package/dist/worker/github-gateway.js +62 -6
  140. package/dist/worker/github-mirror.d.ts +7 -4
  141. package/dist/worker/github-mirror.js +27 -16
  142. package/dist/worker/history-rewrite.d.ts +23 -10
  143. package/dist/worker/history-rewrite.js +43 -20
  144. package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
  145. package/dist/worker/legacy-branch-fixtures.js +71 -0
  146. package/dist/worker/rebase.d.ts +2 -1
  147. package/dist/worker/rebase.js +93 -36
  148. package/dist/worker/task-runner.d.ts +4 -1
  149. package/dist/worker/task-runner.js +77 -48
  150. package/dist/worker/unpublishable-branches.d.ts +34 -0
  151. package/dist/worker/unpublishable-branches.js +182 -0
  152. package/package.json +2 -1
@@ -1,8 +1,10 @@
1
- import { loadBranchContext, loadOrCreateBranchContext } from './branch-workspace.js';
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
- if (allowCreateBranch) {
44
- return loadOrCreateBranchContext({
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
- const existing = await loadBranchContext({
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, branchName, user } = resolveTarget(input);
125
- const { context, branchRoot, store } = await resolveStore(branchName);
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 read = async (input, message) => {
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
- return { read };
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 { createContentReader } from './content-reader.js';
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 { loadBranchContext, loadOrCreateBranchContext } from './branch-workspace.js';
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
- * A branch that already has a workspace, or null. A name that cannot name one
38
- * is null too (`namesNoWorkspace`): it arrives from the request, so it reads as
39
- * not-found rather than failing the page.
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(branchName, mode) {
42
+ async function loadExistingBranch(param, config) {
42
43
  try {
43
- return await loadBranchContext({ branchName, mode });
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.mode);
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 = createContentReader({
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 readWithOptions = async (input, extra) => {
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 baseReader.read(readInput);
164
+ return readInput;
164
165
  };
165
- const read = (input) => readWithOptions(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
- for (const candidate of candidates) {
173
- // Skip a candidate whose slug isn't valid (/favicon.ico, Next internals
174
- // the [...slug] route catches). None can match an entry, so treat it as
175
- // a miss rather than let read() throw "Invalid slug".
176
- if (!parseSlug(candidate.slug).ok)
177
- continue;
178
- try {
179
- return await readWithOptions({
180
- entryPath: candidate.entryPath,
181
- slug: candidate.slug,
182
- branch,
183
- resolveReferences,
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
- return null;
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
- * with the user's values; any other return (including none) closes it.
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.name}`;
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.name }), 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'
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 || !newBranchName.trim())
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: newBranchName.trim(),
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: () => setShowCreateForm(!showCreateForm), 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: "feature/my-branch", value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, disabled: creating, "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: !newBranchName.trim(), 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) => (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, user: user, onSelect: onSelect, onSubmit: onSubmit, onWithdraw: onWithdraw, onApprove: onApprove, onRequestChanges: onRequestChanges, onDelete: onDelete, onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name))) })) })] }));
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
  };