canopycms 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +326 -48
- package/dist/api/admin.d.ts +4 -4
- package/dist/api/branch-review.d.ts +20 -2
- package/dist/api/branch-review.js +153 -32
- package/dist/api/branch-status.d.ts +23 -2
- package/dist/api/branch-status.js +16 -2
- package/dist/api/branch-update-from-base.d.ts +23 -0
- package/dist/api/branch-update-from-base.js +227 -0
- package/dist/api/branch.d.ts +10 -0
- package/dist/api/branch.js +28 -2
- package/dist/api/client.d.ts +15 -2
- package/dist/api/client.js +24 -2
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/content.d.ts +38 -1
- package/dist/api/content.js +41 -5
- package/dist/api/merge-conflicts.d.ts +235 -0
- package/dist/api/merge-conflicts.js +709 -0
- package/dist/api/routes.js +2 -0
- package/dist/api/types.d.ts +8 -3
- package/dist/base-update.d.ts +32 -0
- package/dist/base-update.js +31 -0
- package/dist/branch-metadata-file.d.ts +66 -0
- package/dist/branch-metadata-file.js +16 -1
- package/dist/branch-schema-cache.d.ts +12 -0
- package/dist/branch-schema-cache.js +27 -2
- package/dist/cli/backfill-item-keys.d.ts +77 -0
- package/dist/cli/backfill-item-keys.js +320 -0
- package/dist/cli/cli.d.ts +1 -1
- package/dist/cli/cli.js +9798 -1509
- package/dist/cli/generate-ai-content.js +373 -249
- package/dist/cli/github-app-manifest.d.ts +4 -1
- package/dist/cli/github-app-manifest.js +5 -1
- package/dist/cli/init.js +12 -0
- package/dist/cli/site-config.d.ts +14 -2
- package/dist/cli/site-config.js +20 -4
- package/dist/comment-store.d.ts +8 -0
- package/dist/comment-store.js +28 -0
- package/dist/content-index-generation.d.ts +1 -4
- package/dist/content-index-generation.js +1 -4
- package/dist/content-store.d.ts +39 -3
- package/dist/content-store.js +48 -15
- package/dist/editor/BranchManager.d.ts +4 -1
- package/dist/editor/BranchManager.js +9 -5
- package/dist/editor/Editor.js +462 -62
- package/dist/editor/EntryNavigator.d.ts +10 -2
- package/dist/editor/EntryNavigator.js +12 -4
- package/dist/editor/FormRenderer.d.ts +8 -2
- package/dist/editor/FormRenderer.js +5 -6
- package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
- package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
- package/dist/editor/admin/SystemHealthPanel.js +4 -3
- package/dist/editor/base-update-wait.d.ts +78 -0
- package/dist/editor/base-update-wait.js +98 -0
- package/dist/editor/branch-status.js +1 -1
- package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
- package/dist/editor/components/BaseUpdateNotice.js +24 -0
- package/dist/editor/components/BranchPill.d.ts +4 -1
- package/dist/editor/components/BranchPill.js +5 -2
- package/dist/editor/components/ConflictsGroup.d.ts +112 -0
- package/dist/editor/components/ConflictsGroup.js +404 -0
- package/dist/editor/components/EditorHeader.d.ts +18 -1
- package/dist/editor/components/EditorHeader.js +99 -33
- package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
- package/dist/editor/components/EntryChangesDrawer.js +85 -167
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -2
- package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
- package/dist/editor/components/GetLatestChangesButton.js +29 -0
- package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
- package/dist/editor/components/ReleaseHoldButton.js +36 -0
- package/dist/editor/components/RetryToastMessage.js +1 -1
- package/dist/editor/components/change-parts.d.ts +51 -0
- package/dist/editor/components/change-parts.js +171 -0
- package/dist/editor/conflict-cards.d.ts +93 -0
- package/dist/editor/conflict-cards.js +284 -0
- package/dist/editor/conflict-choices.d.ts +60 -0
- package/dist/editor/conflict-choices.js +108 -0
- package/dist/editor/conflict-notice.d.ts +25 -0
- package/dist/editor/conflict-notice.js +39 -0
- package/dist/editor/copy.d.ts +12 -0
- package/dist/editor/copy.js +12 -0
- package/dist/editor/entry-diff.d.ts +2 -1
- package/dist/editor/entry-diff.js +5 -21
- package/dist/editor/hooks/useBaseUpdate.d.ts +38 -0
- package/dist/editor/hooks/useBaseUpdate.js +279 -0
- package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
- package/dist/editor/hooks/useBranchChangesData.js +13 -0
- package/dist/editor/hooks/useBranchManager.d.ts +8 -1
- package/dist/editor/hooks/useBranchManager.js +108 -13
- package/dist/editor/hooks/useBranchesData.js +3 -0
- package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
- package/dist/editor/hooks/useConflictChoices.js +75 -0
- package/dist/editor/hooks/useConflictsData.d.ts +26 -0
- package/dist/editor/hooks/useConflictsData.js +89 -0
- package/dist/editor/hooks/useDraftManager.d.ts +13 -1
- package/dist/editor/hooks/useDraftManager.js +40 -9
- package/dist/editor/hooks/useEntryManager.d.ts +17 -3
- package/dist/editor/hooks/useEntryManager.js +11 -5
- package/dist/editor/release-hold.d.ts +12 -0
- package/dist/editor/release-hold.js +39 -0
- package/dist/editor/server-clock.d.ts +4 -0
- package/dist/editor/server-clock.js +15 -0
- package/dist/editor/undo-change.d.ts +7 -0
- package/dist/editor/undo-change.js +19 -4
- package/dist/git-manager.d.ts +6 -0
- package/dist/git-manager.js +38 -3
- package/dist/item-keys.d.ts +13 -0
- package/dist/item-keys.js +36 -14
- package/dist/merge/conflict-path.d.ts +40 -0
- package/dist/merge/conflict-path.js +128 -0
- package/dist/merge/diff3.d.ts +27 -0
- package/dist/merge/diff3.js +113 -0
- package/dist/merge/entry-merge.d.ts +94 -0
- package/dist/merge/entry-merge.js +876 -0
- package/dist/merge/list-alignment.d.ts +55 -0
- package/dist/merge/list-alignment.js +490 -0
- package/dist/merge/rekey.d.ts +11 -0
- package/dist/merge/rekey.js +75 -0
- package/dist/merge/text-merge.d.ts +29 -0
- package/dist/merge/text-merge.js +59 -0
- package/dist/merge/value-equality.d.ts +18 -0
- package/dist/merge/value-equality.js +50 -0
- package/dist/merge-conflicts.d.ts +232 -0
- package/dist/merge-conflicts.js +451 -0
- package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
- package/dist/operating-mode/client-safe-strategy.js +6 -0
- package/dist/operating-mode/client-unsafe-strategy.js +6 -0
- package/dist/operating-mode/types.d.ts +13 -0
- package/dist/services.d.ts +24 -1
- package/dist/services.js +138 -22
- package/dist/task-queue/cms-task-queue.d.ts +3 -1
- package/dist/types.d.ts +63 -5
- package/dist/utils/content-serialize.d.ts +18 -1
- package/dist/utils/content-serialize.js +12 -0
- package/dist/utils/content-write-lock.d.ts +3 -3
- package/dist/utils/content-write-lock.js +3 -3
- package/dist/utils/git.d.ts +7 -0
- package/dist/utils/git.js +35 -0
- package/dist/utils/markdown-body-splice.d.ts +1 -1
- package/dist/utils/markdown-body-splice.js +1 -19
- package/dist/utils/markdown-parse.d.ts +9 -0
- package/dist/utils/markdown-parse.js +27 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +80 -40
- package/dist/worker/branch-merge.js +244 -58
- package/dist/worker/branch-policy.d.ts +18 -2
- package/dist/worker/branch-policy.js +36 -0
- package/dist/worker/cms-worker.d.ts +37 -3
- package/dist/worker/cms-worker.js +88 -16
- package/dist/worker/conflicts-status.d.ts +20 -0
- package/dist/worker/conflicts-status.js +204 -0
- package/dist/worker/field-merge.d.ts +80 -0
- package/dist/worker/field-merge.js +729 -0
- package/dist/worker/git-sync.d.ts +3 -2
- package/dist/worker/git-sync.js +16 -6
- package/dist/worker/github-commits.d.ts +42 -10
- package/dist/worker/github-commits.js +444 -52
- package/dist/worker/github-errors.d.ts +15 -0
- package/dist/worker/github-errors.js +47 -0
- package/dist/worker/github-gateway-http.js +24 -9
- package/dist/worker/github-gateway-wire.d.ts +49 -20
- package/dist/worker/github-gateway-wire.js +19 -3
- package/dist/worker/github-gateway.d.ts +27 -1
- package/dist/worker/github-gateway.js +55 -7
- package/dist/worker/github-transfer.d.ts +5 -3
- package/dist/worker/github-transfer.js +8 -5
- package/dist/worker/history-rewrite.d.ts +40 -8
- package/dist/worker/history-rewrite.js +51 -23
- package/dist/worker/publish-decision.d.ts +37 -0
- package/dist/worker/publish-decision.js +16 -0
- package/dist/worker/rebase.d.ts +29 -8
- package/dist/worker/rebase.js +492 -211
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +30 -41
- package/dist/worker/worker-context.d.ts +24 -2
- package/package.json +1 -1
|
@@ -11,7 +11,7 @@ export type PermissionSet = Readonly<Record<string, PermissionLevel>>;
|
|
|
11
11
|
* The App's entire security surface. Every entry names the call site that
|
|
12
12
|
* forces it — a permission justified only in a commit message can't be
|
|
13
13
|
* re-checked. Enumerated from `github-service.ts`,
|
|
14
|
-
* `worker/{task-runner,rebase}.ts`, and every git call reaching github.com;
|
|
14
|
+
* `worker/{task-runner,rebase,conflicts-status}.ts`, and every git call reaching github.com;
|
|
15
15
|
* `canopycms-cdk` makes no REST calls at all.
|
|
16
16
|
* - `contents: write` — git over HTTPS (first-boot clone, per-cycle branch
|
|
17
17
|
* fetch, pushes) and `octokit.git.deleteRef`. GitHub's reference puts ref
|
|
@@ -21,6 +21,9 @@ export type PermissionSet = Readonly<Record<string, PermissionLevel>>;
|
|
|
21
21
|
* - `pull_requests: write` — `pulls.create`/`pulls.update`, and the GraphQL
|
|
22
22
|
* mutations `markPullRequestReadyForReview`/`convertPullRequestToDraft`,
|
|
23
23
|
* derived by analogy since GitHub's reference has no line for them.
|
|
24
|
+
* - `statuses: write` — `repos.createCommitStatus`, in worker/github-gateway.ts
|
|
25
|
+
* `setConflictsStatus`: the "CanopyCMS conflicts" status the git sync sets on
|
|
26
|
+
* each pull request's head (worker/conflicts-status.ts).
|
|
24
27
|
* - `metadata: read` — implied by any repository permission; declared so
|
|
25
28
|
* this object states the whole surface, not just the non-automatic part.
|
|
26
29
|
* Nothing else: no `issues`, `administration`, `actions`, or organisation
|
|
@@ -9,7 +9,7 @@ const PERMISSION_LEVELS = ['read', 'write', 'admin'];
|
|
|
9
9
|
* The App's entire security surface. Every entry names the call site that
|
|
10
10
|
* forces it — a permission justified only in a commit message can't be
|
|
11
11
|
* re-checked. Enumerated from `github-service.ts`,
|
|
12
|
-
* `worker/{task-runner,rebase}.ts`, and every git call reaching github.com;
|
|
12
|
+
* `worker/{task-runner,rebase,conflicts-status}.ts`, and every git call reaching github.com;
|
|
13
13
|
* `canopycms-cdk` makes no REST calls at all.
|
|
14
14
|
* - `contents: write` — git over HTTPS (first-boot clone, per-cycle branch
|
|
15
15
|
* fetch, pushes) and `octokit.git.deleteRef`. GitHub's reference puts ref
|
|
@@ -19,6 +19,9 @@ const PERMISSION_LEVELS = ['read', 'write', 'admin'];
|
|
|
19
19
|
* - `pull_requests: write` — `pulls.create`/`pulls.update`, and the GraphQL
|
|
20
20
|
* mutations `markPullRequestReadyForReview`/`convertPullRequestToDraft`,
|
|
21
21
|
* derived by analogy since GitHub's reference has no line for them.
|
|
22
|
+
* - `statuses: write` — `repos.createCommitStatus`, in worker/github-gateway.ts
|
|
23
|
+
* `setConflictsStatus`: the "CanopyCMS conflicts" status the git sync sets on
|
|
24
|
+
* each pull request's head (worker/conflicts-status.ts).
|
|
22
25
|
* - `metadata: read` — implied by any repository permission; declared so
|
|
23
26
|
* this object states the whole surface, not just the non-automatic part.
|
|
24
27
|
* Nothing else: no `issues`, `administration`, `actions`, or organisation
|
|
@@ -30,6 +33,7 @@ const PERMISSION_LEVELS = ['read', 'write', 'admin'];
|
|
|
30
33
|
export const CANOPY_APP_PERMISSIONS = {
|
|
31
34
|
contents: 'write',
|
|
32
35
|
pull_requests: 'write',
|
|
36
|
+
statuses: 'write',
|
|
33
37
|
metadata: 'read',
|
|
34
38
|
};
|
|
35
39
|
/**
|
package/dist/cli/init.js
CHANGED
|
@@ -1558,6 +1558,9 @@ var ProdClientSafeStrategy = class {
|
|
|
1558
1558
|
supportsPullRequests() {
|
|
1559
1559
|
return true;
|
|
1560
1560
|
}
|
|
1561
|
+
syncsBranchesWithBase() {
|
|
1562
|
+
return true;
|
|
1563
|
+
}
|
|
1561
1564
|
getPermissionsFileName() {
|
|
1562
1565
|
return "permissions.json";
|
|
1563
1566
|
}
|
|
@@ -1584,6 +1587,9 @@ var DevClientSafeStrategy = class {
|
|
|
1584
1587
|
supportsPullRequests() {
|
|
1585
1588
|
return false;
|
|
1586
1589
|
}
|
|
1590
|
+
syncsBranchesWithBase() {
|
|
1591
|
+
return true;
|
|
1592
|
+
}
|
|
1587
1593
|
getPermissionsFileName() {
|
|
1588
1594
|
return "permissions.json";
|
|
1589
1595
|
}
|
|
@@ -1771,6 +1777,9 @@ var ProdStrategy = class extends ProdClientSafeStrategy {
|
|
|
1771
1777
|
autoDetectRemotePath: path.join(this.getWorkspaceRoot(), "remote.git")
|
|
1772
1778
|
};
|
|
1773
1779
|
}
|
|
1780
|
+
baseUpdateRunsInRequest() {
|
|
1781
|
+
return false;
|
|
1782
|
+
}
|
|
1774
1783
|
requiresExistingRepo() {
|
|
1775
1784
|
return false;
|
|
1776
1785
|
}
|
|
@@ -1823,6 +1832,9 @@ var DevStrategy = class extends DevClientSafeStrategy {
|
|
|
1823
1832
|
envVarName: "CANOPYCMS_REMOTE_URL"
|
|
1824
1833
|
};
|
|
1825
1834
|
}
|
|
1835
|
+
baseUpdateRunsInRequest() {
|
|
1836
|
+
return true;
|
|
1837
|
+
}
|
|
1826
1838
|
requiresExistingRepo() {
|
|
1827
1839
|
return false;
|
|
1828
1840
|
}
|
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Imports a site's
|
|
3
|
-
* `configured-asset-store.ts`)
|
|
2
|
+
* Imports a site's own TypeScript modules for CLI commands that need them: `canopycms.config.ts`
|
|
3
|
+
* (`sync`, `generate-ai-content`, `backfill-item-keys`, `configured-asset-store.ts`) and the
|
|
4
|
+
* entry schema registry (`generate-ai-content`, `backfill-item-keys`). jiti lets plain node
|
|
5
|
+
* import them without tsx. Importing runs the module and loads its own dependencies.
|
|
4
6
|
*/
|
|
7
|
+
/** A site module's exports, by name. */
|
|
8
|
+
export declare function importSiteModule(filePath: string): Promise<Record<string, unknown>>;
|
|
5
9
|
/** The site's server config export (`server` of a split config, else the export itself). */
|
|
6
10
|
export declare function importSiteServerConfig(projectDir: string): Promise<{
|
|
7
11
|
configPath: string;
|
|
8
12
|
server: unknown;
|
|
9
13
|
}>;
|
|
14
|
+
/**
|
|
15
|
+
* The site's entry schema registry: `{appDir}/schemas.ts`'s `entrySchemaRegistry` export, else
|
|
16
|
+
* the module's exports. Throws when the file is missing or fails to import.
|
|
17
|
+
*/
|
|
18
|
+
export declare function importSiteEntrySchemaRegistry(projectDir: string, appDir: string): Promise<{
|
|
19
|
+
schemasPath: string;
|
|
20
|
+
registry: Record<string, unknown>;
|
|
21
|
+
}>;
|
package/dist/cli/site-config.js
CHANGED
|
@@ -1,17 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Imports a site's
|
|
3
|
-
* `configured-asset-store.ts`)
|
|
2
|
+
* Imports a site's own TypeScript modules for CLI commands that need them: `canopycms.config.ts`
|
|
3
|
+
* (`sync`, `generate-ai-content`, `backfill-item-keys`, `configured-asset-store.ts`) and the
|
|
4
|
+
* entry schema registry (`generate-ai-content`, `backfill-item-keys`). jiti lets plain node
|
|
5
|
+
* import them without tsx. Importing runs the module and loads its own dependencies.
|
|
4
6
|
*/
|
|
5
7
|
import path from 'node:path';
|
|
6
8
|
import { createJiti } from 'jiti';
|
|
9
|
+
const jiti = createJiti(import.meta.url);
|
|
10
|
+
/** A site module's exports, by name. */
|
|
11
|
+
export async function importSiteModule(filePath) {
|
|
12
|
+
return (await jiti.import(filePath));
|
|
13
|
+
}
|
|
7
14
|
/** The site's server config export (`server` of a split config, else the export itself). */
|
|
8
15
|
export async function importSiteServerConfig(projectDir) {
|
|
9
16
|
const configPath = path.join(projectDir, 'canopycms.config.ts');
|
|
10
|
-
const
|
|
11
|
-
const mod = (await jiti.import(configPath));
|
|
17
|
+
const mod = await importSiteModule(configPath);
|
|
12
18
|
const configExport = mod.default ?? mod.config ?? mod;
|
|
13
19
|
const server = typeof configExport === 'object' && configExport !== null && 'server' in configExport
|
|
14
20
|
? configExport.server
|
|
15
21
|
: configExport;
|
|
16
22
|
return { configPath, server };
|
|
17
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* The site's entry schema registry: `{appDir}/schemas.ts`'s `entrySchemaRegistry` export, else
|
|
26
|
+
* the module's exports. Throws when the file is missing or fails to import.
|
|
27
|
+
*/
|
|
28
|
+
export async function importSiteEntrySchemaRegistry(projectDir, appDir) {
|
|
29
|
+
const schemasPath = path.join(projectDir, appDir, 'schemas.ts');
|
|
30
|
+
const mod = await importSiteModule(schemasPath);
|
|
31
|
+
const registry = mod.entrySchemaRegistry ?? mod;
|
|
32
|
+
return { schemasPath, registry };
|
|
33
|
+
}
|
package/dist/comment-store.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { KeyRemap } from './merge/entry-merge.js';
|
|
1
2
|
export type CommentType = 'field' | 'entry' | 'branch';
|
|
2
3
|
interface Comment {
|
|
3
4
|
id: string;
|
|
@@ -94,6 +95,13 @@ export declare class CommentStore {
|
|
|
94
95
|
* `CommentStoreConflictError`.
|
|
95
96
|
*/
|
|
96
97
|
private withMutation;
|
|
98
|
+
/**
|
|
99
|
+
* Point the threads on `entryPath` at the item keys a merge gave their items. A remap names its
|
|
100
|
+
* item by the keys of the items holding it, outermost first, as the threads held them before the
|
|
101
|
+
* merge, so every remap is matched against a thread's keys before any of them is applied.
|
|
102
|
+
* Resolves how many threads moved.
|
|
103
|
+
*/
|
|
104
|
+
remapItemKeys(entryPath: string, remaps: readonly KeyRemap[]): Promise<number>;
|
|
97
105
|
addComment(options: {
|
|
98
106
|
userId: string;
|
|
99
107
|
text: string;
|
package/dist/comment-store.js
CHANGED
|
@@ -138,6 +138,34 @@ export class CommentStore {
|
|
|
138
138
|
throw err;
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Point the threads on `entryPath` at the item keys a merge gave their items. A remap names its
|
|
143
|
+
* item by the keys of the items holding it, outermost first, as the threads held them before the
|
|
144
|
+
* merge, so every remap is matched against a thread's keys before any of them is applied.
|
|
145
|
+
* Resolves how many threads moved.
|
|
146
|
+
*/
|
|
147
|
+
async remapItemKeys(entryPath, remaps) {
|
|
148
|
+
if (remaps.length === 0)
|
|
149
|
+
return 0;
|
|
150
|
+
return this.withMutation(async (data, version) => {
|
|
151
|
+
let moved = 0;
|
|
152
|
+
for (const thread of Object.values(data.threads)) {
|
|
153
|
+
const keys = thread.itemKeys;
|
|
154
|
+
if (thread.entryPath !== entryPath || !keys)
|
|
155
|
+
continue;
|
|
156
|
+
const remapped = keys.map((key, depth) => remaps.find((r) => r.from === key &&
|
|
157
|
+
r.within.length === depth &&
|
|
158
|
+
r.within.every((outer, i) => outer === keys[i]))?.to ?? key);
|
|
159
|
+
if (remapped.every((key, i) => key === keys[i]))
|
|
160
|
+
continue;
|
|
161
|
+
thread.itemKeys = remapped;
|
|
162
|
+
moved++;
|
|
163
|
+
}
|
|
164
|
+
if (moved > 0)
|
|
165
|
+
await this.writeData(data, version);
|
|
166
|
+
return moved;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
141
169
|
async addComment(options) {
|
|
142
170
|
// Generate IDs outside the mutation so they stay stable across retries
|
|
143
171
|
const threadId = options.threadId || randomUUID();
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Absolute path of the generation marker for a branch-clone root.
|
|
3
|
-
* @internal No importer; deletion candidate in safeinsights/canopycms#725.
|
|
4
|
-
*/
|
|
1
|
+
/** Absolute path of the generation marker under `root`. */
|
|
5
2
|
export declare function contentIndexGenerationPath(root: string): string;
|
|
6
3
|
/**
|
|
7
4
|
* Record on disk that indexed files under `root` changed, so ContentStores in
|
|
@@ -18,10 +18,7 @@ import { SCHEMA_GENERATION_RESOURCE } from './branch-schema-cache.js';
|
|
|
18
18
|
* before recreating a missing expected file.
|
|
19
19
|
*/
|
|
20
20
|
const RESOURCE = 'content-index';
|
|
21
|
-
/**
|
|
22
|
-
* Absolute path of the generation marker for a branch-clone root.
|
|
23
|
-
* @internal No importer; deletion candidate in safeinsights/canopycms#725.
|
|
24
|
-
*/
|
|
21
|
+
/** Absolute path of the generation marker under `root`. */
|
|
25
22
|
export function contentIndexGenerationPath(root) {
|
|
26
23
|
return resourceGenerationPath(root, RESOURCE);
|
|
27
24
|
}
|
package/dist/content-store.d.ts
CHANGED
|
@@ -419,7 +419,17 @@ export declare class ContentStore {
|
|
|
419
419
|
* `referenceAccess` denies resolves to a `RestrictedReference` rather than its data.
|
|
420
420
|
*/
|
|
421
421
|
read(collectionPath: LogicalPath, slug?: Slug | '', options?: ReadOptions): Promise<ContentDocument>;
|
|
422
|
-
write(collectionPath: LogicalPath, slug: (Slug | "") | undefined, input: WriteInput, entryTypeName?: string, existingId?: ContentId
|
|
422
|
+
write(collectionPath: LogicalPath, slug: (Slug | "") | undefined, input: WriteInput, entryTypeName?: string, existingId?: ContentId, options?: {
|
|
423
|
+
/**
|
|
424
|
+
* Runs once the file is written, under both of the write's locks, so what it changes about
|
|
425
|
+
* the entry no other writer or rebase sees before it does. A throw rejects the write, though
|
|
426
|
+
* the file is already written.
|
|
427
|
+
*/
|
|
428
|
+
afterWrite?: (written: {
|
|
429
|
+
id?: string;
|
|
430
|
+
relativePath: PhysicalPath;
|
|
431
|
+
}) => Promise<void>;
|
|
432
|
+
}): Promise<ContentDocument>;
|
|
423
433
|
/**
|
|
424
434
|
* Set top-level `field` of the entry file at `physicalPath` to the string `value` when the
|
|
425
435
|
* file holds it empty (absent, null or ''), keeping every other byte. Resolves to the file's
|
|
@@ -482,8 +492,19 @@ export declare class ContentStore {
|
|
|
482
492
|
* pattern in docs/concurrency.md and branch-registry.test.ts's `BlockingRegistry`.
|
|
483
493
|
*/
|
|
484
494
|
protected afterPrePassForTesting(): Promise<void>;
|
|
485
|
-
/**
|
|
486
|
-
|
|
495
|
+
/**
|
|
496
|
+
* Delete an entry and remove it from the index. With `expectId`, only the entry of that content
|
|
497
|
+
* ID: another at the slug, or none, is `EntryGoneError`. `beforeDelete` runs under both of the
|
|
498
|
+
* delete's locks before anything changes, and throwing refuses; `afterDelete` runs once it is
|
|
499
|
+
* gone, under them too.
|
|
500
|
+
*/
|
|
501
|
+
delete(collectionPath: LogicalPath, slug: Slug, options?: {
|
|
502
|
+
expectId?: string;
|
|
503
|
+
beforeDelete?: () => Promise<void>;
|
|
504
|
+
afterDelete?: (deleted: {
|
|
505
|
+
id?: string;
|
|
506
|
+
}) => Promise<void>;
|
|
507
|
+
}): Promise<void>;
|
|
487
508
|
/**
|
|
488
509
|
* Puts back an entry deleted on this branch: `raw`, its file as the base holds it, verbatim,
|
|
489
510
|
* under its old slug, entry type and content ID, so references and comment threads find it
|
|
@@ -503,9 +524,24 @@ export declare class ContentStore {
|
|
|
503
524
|
}, options?: {
|
|
504
525
|
/** Runs under the content-write lock before anything is written; throwing refuses. */
|
|
505
526
|
beforeWrite?: () => Promise<void>;
|
|
527
|
+
/** Runs once the entry is back, under the restore's locks. */
|
|
528
|
+
afterWrite?: () => Promise<void>;
|
|
506
529
|
}): Promise<{
|
|
507
530
|
relativePath: PhysicalPath;
|
|
508
531
|
}>;
|
|
532
|
+
/**
|
|
533
|
+
* Replace the file of entry `id`, at `slug` in `collectionPath`, with `raw`, verbatim, under
|
|
534
|
+
* write()'s locks; `beforeWrite` runs under them first, and throwing refuses, and `afterWrite`
|
|
535
|
+
* after. For a file no parse can be trusted with, so nothing is re-serialised. `EntryGoneError`
|
|
536
|
+
* when no file of that id is there.
|
|
537
|
+
*/
|
|
538
|
+
replaceRaw(collectionPath: LogicalPath, slug: Slug, entry: {
|
|
539
|
+
id: ContentId;
|
|
540
|
+
raw: string;
|
|
541
|
+
}, options?: {
|
|
542
|
+
beforeWrite?: () => Promise<void>;
|
|
543
|
+
afterWrite?: () => Promise<void>;
|
|
544
|
+
}): Promise<void>;
|
|
509
545
|
/**
|
|
510
546
|
* Rename an entry by changing its slug (middle segment of filename).
|
|
511
547
|
* Entry filename pattern: {entryTypeName}.{slug}.{id}.{ext}
|
package/dist/content-store.js
CHANGED
|
@@ -16,7 +16,7 @@ import path from 'node:path';
|
|
|
16
16
|
import matter from 'gray-matter';
|
|
17
17
|
import { parse as yamlParse } from 'yaml';
|
|
18
18
|
import { atomicWriteFile } from './utils/atomic-write.js';
|
|
19
|
-
import { serializeFrontmatter, serializeYaml } from './utils/content-serialize.js';
|
|
19
|
+
import { serializeEntry, serializeFrontmatter, serializeYaml } from './utils/content-serialize.js';
|
|
20
20
|
import { serializeJson } from './utils/json-source-splice.js';
|
|
21
21
|
import { withLock } from './utils/async-mutex.js';
|
|
22
22
|
import { assertContentWriteLockHeld, ContentWriteLockBusyError, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, withContentWriteLock, } from './utils/content-write-lock.js';
|
|
@@ -802,7 +802,7 @@ export class ContentStore {
|
|
|
802
802
|
doc.version = stat.mtimeMs;
|
|
803
803
|
return doc;
|
|
804
804
|
}
|
|
805
|
-
async write(collectionPath, slug = '', input, entryTypeName, existingId) {
|
|
805
|
+
async write(collectionPath, slug = '', input, entryTypeName, existingId, options = {}) {
|
|
806
806
|
// Warm the index outside the lock (a full scan must not hold the entry lock)
|
|
807
807
|
await this.idIndex();
|
|
808
808
|
const schemaItem = this.assertSchemaItem(collectionPath);
|
|
@@ -1027,16 +1027,7 @@ export class ContentStore {
|
|
|
1027
1027
|
// intact, and no caller passes `existingId`. If a relocating write becomes
|
|
1028
1028
|
// reachable, read the ID's current path instead of `absolutePath`.
|
|
1029
1029
|
const existingRaw = current?.content;
|
|
1030
|
-
|
|
1031
|
-
if (input.format === 'json') {
|
|
1032
|
-
content = serializeJson(input.data ?? {}, existingRaw);
|
|
1033
|
-
}
|
|
1034
|
-
else if (input.format === 'yaml') {
|
|
1035
|
-
content = serializeYaml(input.data ?? {}, existingRaw);
|
|
1036
|
-
}
|
|
1037
|
-
else {
|
|
1038
|
-
content = serializeFrontmatter(input.body, input.data ?? {}, existingRaw, input.format, fields);
|
|
1039
|
-
}
|
|
1030
|
+
const content = serializeEntry(input, existingRaw, fields);
|
|
1040
1031
|
await atomicWriteFile(absolutePath, content);
|
|
1041
1032
|
// Update the ID index after a successful write. Look up and mutate the LIVE index
|
|
1042
1033
|
// in one synchronous window (no awaits in between): a concurrent rebuild may have
|
|
@@ -1073,6 +1064,7 @@ export class ContentStore {
|
|
|
1073
1064
|
});
|
|
1074
1065
|
}
|
|
1075
1066
|
await this.recordOwnMutation(liveIndex);
|
|
1067
|
+
await options.afterWrite?.({ id, relativePath });
|
|
1076
1068
|
const afterStat = await fs.stat(absolutePath);
|
|
1077
1069
|
const base = {
|
|
1078
1070
|
collection: schemaItem.logicalPath,
|
|
@@ -1232,8 +1224,13 @@ export class ContentStore {
|
|
|
1232
1224
|
* pattern in docs/concurrency.md and branch-registry.test.ts's `BlockingRegistry`.
|
|
1233
1225
|
*/
|
|
1234
1226
|
async afterPrePassForTesting() { }
|
|
1235
|
-
/**
|
|
1236
|
-
|
|
1227
|
+
/**
|
|
1228
|
+
* Delete an entry and remove it from the index. With `expectId`, only the entry of that content
|
|
1229
|
+
* ID: another at the slug, or none, is `EntryGoneError`. `beforeDelete` runs under both of the
|
|
1230
|
+
* delete's locks before anything changes, and throwing refuses; `afterDelete` runs once it is
|
|
1231
|
+
* gone, under them too.
|
|
1232
|
+
*/
|
|
1233
|
+
async delete(collectionPath, slug, options = {}) {
|
|
1237
1234
|
// Warm the index outside the lock (a full scan must not hold the entry lock)
|
|
1238
1235
|
await this.idIndex();
|
|
1239
1236
|
const collection = this.assertCollection(collectionPath);
|
|
@@ -1242,7 +1239,9 @@ export class ContentStore {
|
|
|
1242
1239
|
const prePass = await this.buildPaths(collection, slug);
|
|
1243
1240
|
if (prePass.existed)
|
|
1244
1241
|
this.assertEntryTypeAvailable(collection, prePass.entryTypeName);
|
|
1245
|
-
if (!prePass.existed) {
|
|
1242
|
+
if (!prePass.existed || (options.expectId !== undefined && prePass.id !== options.expectId)) {
|
|
1243
|
+
if (options.expectId !== undefined)
|
|
1244
|
+
throw new EntryGoneError();
|
|
1246
1245
|
// Nothing on disk for this slug -- no shared resource to lock on, and fs.unlink on the
|
|
1247
1246
|
// freshly generated (necessarily nonexistent) path throws ENOENT, as callers expect.
|
|
1248
1247
|
await fs.unlink(prePass.absolutePath);
|
|
@@ -1268,6 +1267,11 @@ export class ContentStore {
|
|
|
1268
1267
|
return RETRY_KEY;
|
|
1269
1268
|
}
|
|
1270
1269
|
const { absolutePath, relativePath } = inLock;
|
|
1270
|
+
if (options.expectId !== undefined &&
|
|
1271
|
+
(!inLock.existed || inLock.id !== options.expectId)) {
|
|
1272
|
+
throw new EntryGoneError();
|
|
1273
|
+
}
|
|
1274
|
+
await options.beforeDelete?.();
|
|
1271
1275
|
await fs.unlink(absolutePath);
|
|
1272
1276
|
// Remove from the LIVE index — lookup and mutation in one synchronous
|
|
1273
1277
|
// window, since a concurrent rebuild may swap instances across awaits.
|
|
@@ -1277,6 +1281,7 @@ export class ContentStore {
|
|
|
1277
1281
|
liveIndex.remove(id);
|
|
1278
1282
|
}
|
|
1279
1283
|
await this.recordOwnMutation(liveIndex);
|
|
1284
|
+
await options.afterDelete?.({ id: id ?? inLock.id });
|
|
1280
1285
|
return undefined;
|
|
1281
1286
|
});
|
|
1282
1287
|
if (outcome !== RETRY_KEY)
|
|
@@ -1352,10 +1357,38 @@ export class ContentStore {
|
|
|
1352
1357
|
});
|
|
1353
1358
|
}
|
|
1354
1359
|
await this.recordOwnMutation(index);
|
|
1360
|
+
await options.afterWrite?.();
|
|
1355
1361
|
return { relativePath: target.relativePath };
|
|
1356
1362
|
});
|
|
1357
1363
|
});
|
|
1358
1364
|
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Replace the file of entry `id`, at `slug` in `collectionPath`, with `raw`, verbatim, under
|
|
1367
|
+
* write()'s locks; `beforeWrite` runs under them first, and throwing refuses, and `afterWrite`
|
|
1368
|
+
* after. For a file no parse can be trusted with, so nothing is re-serialised. `EntryGoneError`
|
|
1369
|
+
* when no file of that id is there.
|
|
1370
|
+
*/
|
|
1371
|
+
async replaceRaw(collectionPath, slug, entry, options = {}) {
|
|
1372
|
+
await this.idIndex();
|
|
1373
|
+
const collection = this.assertCollection(collectionPath);
|
|
1374
|
+
const prePass = await this.buildPaths(collection, slug);
|
|
1375
|
+
if (!prePass.existed || prePass.id !== entry.id)
|
|
1376
|
+
throw new EntryGoneError();
|
|
1377
|
+
this.assertEntryTypeAvailable(collection, prePass.entryTypeName);
|
|
1378
|
+
const lockKey = this.entryLockKey(collection, slug, prePass);
|
|
1379
|
+
return this.withContentWriteExclusion(() => withLock(lockKey, async () => {
|
|
1380
|
+
const inLock = await this.buildPaths(collection, slug);
|
|
1381
|
+
if (!inLock.existed || inLock.id !== entry.id)
|
|
1382
|
+
throw new EntryGoneError();
|
|
1383
|
+
if (this.entryLockKey(collection, slug, inLock) !== lockKey) {
|
|
1384
|
+
throw new ContentConflictError();
|
|
1385
|
+
}
|
|
1386
|
+
await options.beforeWrite?.();
|
|
1387
|
+
await atomicWriteFile(inLock.absolutePath, entry.raw);
|
|
1388
|
+
await this.recordOwnMutation(this._idIndex);
|
|
1389
|
+
await options.afterWrite?.();
|
|
1390
|
+
}));
|
|
1391
|
+
}
|
|
1359
1392
|
/**
|
|
1360
1393
|
* Rename an entry by changing its slug (middle segment of filename).
|
|
1361
1394
|
* Entry filename pattern: {entryTypeName}.{slug}.{id}.{ext}
|
|
@@ -4,6 +4,7 @@ import type { BranchHold, ConflictStatus, PullRequestState, SyncStatus } from '.
|
|
|
4
4
|
import type { CommentThread } from '../comment-store.js';
|
|
5
5
|
import type { UserSearchResult } from '../auth/types.js';
|
|
6
6
|
import type { CreateRefusal } from './hooks/create-branch-request.js';
|
|
7
|
+
import type { BaseUpdateControl } from './base-update-wait.js';
|
|
7
8
|
/** @internal Exported for tests. */
|
|
8
9
|
export interface BranchSummary {
|
|
9
10
|
name: string;
|
|
@@ -33,7 +34,7 @@ export interface BranchSummary {
|
|
|
33
34
|
syncFailureReason?: string;
|
|
34
35
|
/** Whether this branch has unresolved merge conflicts with the base branch */
|
|
35
36
|
conflictStatus?: ConflictStatus;
|
|
36
|
-
/** ContentIds of entries
|
|
37
|
+
/** ContentIds of entries with a merge conflict a person has yet to resolve */
|
|
37
38
|
conflictFiles?: string[];
|
|
38
39
|
/** What is stuck and needs a person (api/branch.ts). */
|
|
39
40
|
hold?: BranchHold;
|
|
@@ -82,6 +83,8 @@ export interface BranchManagerProps {
|
|
|
82
83
|
currentBranchName?: string;
|
|
83
84
|
/** Why the open branch's Submit or Withdraw must wait, such as a Save all still writing it. */
|
|
84
85
|
currentWorkflowBlockedReason?: string;
|
|
86
|
+
/** "Get latest changes" for the open branch, when it applies (hooks/useBaseUpdate.tsx). */
|
|
87
|
+
currentBaseUpdate?: BaseUpdateControl;
|
|
85
88
|
/** Opens on the new-branch form, its name field focused, as New branch… in the branch bar asks. */
|
|
86
89
|
startCreating?: boolean;
|
|
87
90
|
onSelect?: (name: string) => void;
|
|
@@ -17,6 +17,7 @@ import { HOLD_BADGE, SYNC_FAILED_IN_REVIEW_NOTE, branchHoldMessage, endSentence,
|
|
|
17
17
|
import { TooltipDetail } from './components/TooltipDetail.js';
|
|
18
18
|
import { branchAddress, displayBranchName } from './branch-names.js';
|
|
19
19
|
import { CLOSED_PULL_REQUEST_REASON, DELETE_NEEDS_ADMIN_REASON, EDITOR_ACTIONS, rewrittenSubmitReason, UNFINISHED_SUBMIT_APPROVE_REASON, } from './copy.js';
|
|
20
|
+
import { GetLatestChangesButton } from './components/GetLatestChangesButton.js';
|
|
20
21
|
import { isReviewerOfOthersReview } from '../authorization/review-role.js';
|
|
21
22
|
import { SYSTEM_USER_ID } from '../system-users.js';
|
|
22
23
|
/**
|
|
@@ -149,7 +150,7 @@ const WRAP_ANYWHERE = { overflowWrap: 'anywhere' };
|
|
|
149
150
|
* reviewer Review, which opens it; the other cards show Open, which the current one never needs.
|
|
150
151
|
* No action is shown twice.
|
|
151
152
|
*/
|
|
152
|
-
const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelect, onSubmit, onWithdraw, onApprove, onRequestChanges, onArchive, onDelete, onGetUserMetadata, onGetOwnerMetadata = onGetUserMetadata, }) => {
|
|
153
|
+
const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, baseUpdate, user, onSelect, onSubmit, onWithdraw, onApprove, onRequestChanges, onArchive, onDelete, onGetUserMetadata, onGetOwnerMetadata = onGetUserMetadata, }) => {
|
|
153
154
|
const statusBadge = branchStatusPresentation(b.status, b.isProtected);
|
|
154
155
|
const perms = getBranchPermissions(b, user);
|
|
155
156
|
const userIsAdmin = isAdmin(user?.groups);
|
|
@@ -194,7 +195,10 @@ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelec
|
|
|
194
195
|
// Two visible actions, the long "Withdraw from review", or any action beside a hold's line would
|
|
195
196
|
// crowd the details at the drawer's width, so they take a row below. Open and Review stay beside
|
|
196
197
|
// the title.
|
|
197
|
-
const actionRow = !!secondary ||
|
|
198
|
+
const actionRow = !!secondary ||
|
|
199
|
+
!!baseUpdate ||
|
|
200
|
+
primary?.label === EDITOR_ACTIONS.withdrawFromReview ||
|
|
201
|
+
(!!primary && !!b.hold);
|
|
198
202
|
const archive = heldBack(userIsAdmin && b.status !== 'archived' && b.pullRequestState === 'closed'
|
|
199
203
|
? {
|
|
200
204
|
label: EDITOR_ACTIONS.archiveBranch,
|
|
@@ -227,7 +231,7 @@ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelec
|
|
|
227
231
|
b.hold?.kind === 'rewritten-at-merge') &&
|
|
228
232
|
!userIsAdmin
|
|
229
233
|
? DELETE_NEEDS_ADMIN_REASON
|
|
230
|
-
: undefined, onClick: () => onDelete?.(b.name), children: "Delete branch" })] }))] })] }))] })] }), actionRow && (_jsxs(Group, { justify: "flex-end", gap: 8, wrap: "nowrap", mt: "sm", "data-testid": `branch-action-row-${b.name}`, children: [secondary && _jsx(CardActionButton, { action: secondary }), primary && _jsx(CardActionButton, { action: primary })] }))] }));
|
|
234
|
+
: undefined, onClick: () => onDelete?.(b.name), children: "Delete branch" })] }))] })] }))] })] }), actionRow && (_jsxs(Group, { justify: "flex-end", gap: 8, wrap: "nowrap", mt: "sm", "data-testid": `branch-action-row-${b.name}`, children: [baseUpdate && (_jsx(GetLatestChangesButton, { control: baseUpdate, testId: `get-latest-changes-button-${b.name}` })), secondary && _jsx(CardActionButton, { action: secondary }), primary && _jsx(CardActionButton, { action: primary })] }))] }));
|
|
231
235
|
};
|
|
232
236
|
const CardActionButton = ({ action }) => (_jsx(Tooltip, { label: action.blockedReason, disabled: !action.blockedReason, multiline: true, maw: 280, children: _jsx(Button, { variant: "light", color: action.color, onClick: () => {
|
|
233
237
|
if (!action.blockedReason)
|
|
@@ -281,7 +285,7 @@ const ArchivedSection = ({ count, open, onToggle, forcedOpen, children }) => {
|
|
|
281
285
|
// Inset and content-width, or the ScrollArea clips the focus ring.
|
|
282
286
|
style: { alignSelf: 'flex-start', outlineOffset: -2 }, "data-testid": "archived-branches-toggle", children: _jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Chevron, { size: 16, "aria-hidden": true }), _jsxs(Text, { size: "sm", fw: 600, children: ["Archived (", count, ")"] })] }) })), open && children] }));
|
|
283
287
|
};
|
|
284
|
-
export const BranchManager = ({ branches, mode, user, currentBranchName, currentWorkflowBlockedReason, startCreating = false, onSelect, onBeforeCreate, onCreate, onDelete, onSubmit, onWithdraw, onApprove, onRequestChanges, onArchive, onClose: _, comments = [], currentUserId, canResolve = false, onAddComment, onResolveThread, highlightThreadId, onGetUserMetadata, onGetOwnerMetadata, onGetCommentUserMetadata, }) => {
|
|
288
|
+
export const BranchManager = ({ branches, mode, user, currentBranchName, currentWorkflowBlockedReason, currentBaseUpdate, startCreating = false, onSelect, onBeforeCreate, onCreate, onDelete, onSubmit, onWithdraw, onApprove, onRequestChanges, onArchive, onClose: _, comments = [], currentUserId, canResolve = false, onAddComment, onResolveThread, highlightThreadId, onGetUserMetadata, onGetOwnerMetadata, onGetCommentUserMetadata, }) => {
|
|
285
289
|
const supportsBranching = clientOperatingStrategy(mode).supportsBranching();
|
|
286
290
|
const [showCreateForm, setShowCreateForm] = useState(startCreating);
|
|
287
291
|
const [newBranchName, setNewBranchName] = useState('');
|
|
@@ -359,7 +363,7 @@ export const BranchManager = ({ branches, mode, user, currentBranchName, current
|
|
|
359
363
|
// short name it is, so a card's actions name its branch by `branchAddress`.
|
|
360
364
|
const address = branchAddress(branches, b);
|
|
361
365
|
const act = (run) => run && (() => run(address));
|
|
362
|
-
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), onArchive: act(onArchive), onDelete: act(onDelete), onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name));
|
|
366
|
+
return (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, baseUpdate: b.name === currentBranchName ? currentBaseUpdate : undefined, user: user, onSelect: act(onSelect), onSubmit: act(onSubmit), onWithdraw: act(onWithdraw), onApprove: act(onApprove), onRequestChanges: act(onRequestChanges), onArchive: act(onArchive), onDelete: act(onDelete), onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name));
|
|
363
367
|
};
|
|
364
368
|
const hasBranchThreads = comments.some((t) => t.type === 'branch');
|
|
365
369
|
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: () => {
|