canopycms 0.0.67 → 0.0.68-int.100
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/README.md +21 -14
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +17 -20
- package/dist/api/admin.d.ts +30 -9
- package/dist/api/admin.js +32 -2
- package/dist/api/assets.js +84 -76
- package/dist/api/branch-create-window.d.ts +12 -0
- package/dist/api/branch-create-window.js +13 -0
- package/dist/api/branch-review.d.ts +3 -3
- package/dist/api/branch-review.js +14 -5
- package/dist/api/branch-status.d.ts +5 -5
- package/dist/api/branch-status.js +26 -6
- package/dist/api/branch-withdraw.d.ts +2 -2
- package/dist/api/branch-withdraw.js +7 -2
- package/dist/api/branch.d.ts +15 -2
- package/dist/api/branch.js +111 -92
- package/dist/api/client.d.ts +18 -6
- package/dist/api/client.js +79 -12
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +44 -41
- package/dist/api/entries.js +16 -15
- package/dist/api/github-sync.d.ts +10 -1
- package/dist/api/github-sync.js +20 -10
- package/dist/api/index.d.ts +1 -1
- package/dist/api/reference-options.js +1 -1
- package/dist/api/resolve-references.js +14 -40
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- package/dist/api/user.d.ts +3 -0
- package/dist/api/user.js +3 -0
- package/dist/assets/asset-url.d.ts +22 -16
- package/dist/assets/asset-url.js +42 -25
- package/dist/assets/factory.d.ts +5 -0
- package/dist/assets/factory.js +5 -0
- package/dist/assets/index.d.ts +1 -1
- package/dist/assets/materialize.d.ts +122 -0
- package/dist/assets/materialize.js +305 -0
- package/dist/assets/store-local.d.ts +4 -0
- package/dist/assets/store-local.js +11 -0
- package/dist/assets/store-s3.d.ts +10 -1
- package/dist/assets/store-s3.js +55 -3
- package/dist/assets/transform-directives.d.ts +52 -13
- package/dist/assets/transform-directives.js +87 -27
- package/dist/assets/transform.d.ts +3 -3
- package/dist/assets/transform.js +13 -8
- package/dist/assets/types.d.ts +14 -1
- package/dist/auth/file-based-auth-cache.js +7 -8
- package/dist/authorization/content.d.ts +5 -4
- package/dist/authorization/content.js +5 -5
- package/dist/authorization/path.d.ts +11 -6
- package/dist/authorization/path.js +11 -6
- package/dist/authorization/types.d.ts +2 -2
- package/dist/branch-health.d.ts +7 -1
- package/dist/branch-health.js +8 -3
- package/dist/branch-metadata.d.ts +5 -5
- package/dist/branch-metadata.js +44 -36
- package/dist/branch-provisioning.d.ts +167 -0
- package/dist/branch-provisioning.js +501 -0
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-sparse.d.ts +23 -0
- package/dist/branch-sparse.js +118 -0
- package/dist/branch-workspace.d.ts +42 -3
- package/dist/branch-workspace.js +240 -94
- package/dist/build/asset-refs.d.ts +93 -0
- package/dist/build/asset-refs.js +251 -0
- package/dist/build-identity.d.ts +3 -0
- package/dist/build-identity.js +13 -0
- package/dist/cli/asset-refs.d.ts +30 -0
- package/dist/cli/asset-refs.js +168 -0
- package/dist/cli/cli.d.ts +2 -1
- package/dist/cli/cli.js +4843 -1870
- package/dist/cli/generate-ai-content.js +1584 -444
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +30 -27
- package/dist/cli/migrate.js +12 -8
- package/dist/cli/sync.js +83 -37
- package/dist/cli/template-files/Dockerfile.cms.template +8 -0
- package/dist/cli/template-files/cdk-app.ts.template +4 -0
- package/dist/cli/template-files/cms-stack.ts.template +23 -12
- package/dist/cli/template-files/deploy-cms.yml.template +2 -0
- package/dist/config/helpers.js +1 -2
- package/dist/config/schemas/config.d.ts +19 -32
- package/dist/config/schemas/config.js +9 -11
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/schemas/media.d.ts +4 -13
- package/dist/config/schemas/media.js +5 -8
- package/dist/config/schemas/url.d.ts +4 -20
- package/dist/config/schemas/url.js +6 -22
- package/dist/config/types.d.ts +30 -20
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +12 -12
- package/dist/content-listing.js +10 -6
- package/dist/content-reader.d.ts +11 -2
- package/dist/content-reader.js +22 -14
- package/dist/content-store.d.ts +37 -9
- package/dist/content-store.js +70 -43
- package/dist/content-tree.d.ts +1 -1
- package/dist/content-tree.js +2 -2
- package/dist/context.d.ts +27 -9
- package/dist/context.js +154 -70
- package/dist/editor/BranchManager.d.ts +7 -1
- package/dist/editor/BranchManager.js +29 -14
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +2 -6
- package/dist/editor/Editor.d.ts +4 -3
- package/dist/editor/Editor.js +24 -20
- package/dist/editor/FormRenderer.js +34 -18
- package/dist/editor/admin/SystemHealthPanel.js +47 -7
- package/dist/editor/components/BranchesDrawer.d.ts +9 -0
- package/dist/editor/components/BranchesDrawer.js +4 -0
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/context/AssetContext.d.ts +18 -35
- package/dist/editor/context/AssetContext.js +20 -29
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/editor-config.d.ts +1 -2
- package/dist/editor/editor-config.js +0 -23
- package/dist/editor/editor-utils.d.ts +24 -21
- package/dist/editor/editor-utils.js +75 -62
- package/dist/editor/fields/BlockField.d.ts +1 -0
- package/dist/editor/fields/BlockField.js +6 -4
- package/dist/editor/fields/CodeField.d.ts +1 -0
- package/dist/editor/fields/CodeField.js +2 -2
- package/dist/editor/fields/DateTimeField.d.ts +1 -0
- package/dist/editor/fields/DateTimeField.js +2 -2
- package/dist/editor/fields/FieldDescription.d.ts +16 -0
- package/dist/editor/fields/FieldDescription.js +11 -0
- package/dist/editor/fields/ImageField.d.ts +1 -0
- package/dist/editor/fields/ImageField.js +3 -2
- package/dist/editor/fields/InlineGroupField.js +4 -1
- package/dist/editor/fields/MarkdownField.d.ts +1 -0
- package/dist/editor/fields/MarkdownField.js +115 -20
- package/dist/editor/fields/NumberField.d.ts +1 -0
- package/dist/editor/fields/NumberField.js +2 -2
- package/dist/editor/fields/NumberListField.d.ts +1 -0
- package/dist/editor/fields/NumberListField.js +2 -2
- package/dist/editor/fields/ObjectField.d.ts +1 -0
- package/dist/editor/fields/ObjectField.js +5 -2
- package/dist/editor/fields/ReferenceField.d.ts +1 -0
- package/dist/editor/fields/ReferenceField.js +5 -4
- package/dist/editor/fields/SelectField.d.ts +1 -0
- package/dist/editor/fields/SelectField.js +2 -2
- package/dist/editor/fields/StringListField.d.ts +1 -0
- package/dist/editor/fields/StringListField.js +2 -2
- package/dist/editor/fields/TextField.d.ts +1 -0
- package/dist/editor/fields/TextField.js +2 -2
- package/dist/editor/fields/ToggleField.d.ts +1 -0
- package/dist/editor/fields/ToggleField.js +7 -4
- package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
- package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
- package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
- package/dist/editor/fields/mdx-jsx-support.js +137 -0
- package/dist/editor/hooks/create-branch-request.d.ts +20 -0
- package/dist/editor/hooks/create-branch-request.js +52 -0
- package/dist/editor/hooks/useBranchActions.d.ts +13 -2
- package/dist/editor/hooks/useBranchActions.js +48 -17
- package/dist/editor/hooks/useBranchManager.d.ts +11 -0
- package/dist/editor/hooks/useBranchManager.js +80 -2
- package/dist/editor/hooks/useBranchesData.d.ts +6 -0
- package/dist/editor/hooks/useBranchesData.js +9 -3
- package/dist/editor/hooks/useCommentSystem.js +25 -6
- package/dist/editor/hooks/useDraftManager.d.ts +16 -1
- package/dist/editor/hooks/useDraftManager.js +193 -36
- package/dist/editor/hooks/useEntriesData.js +3 -3
- package/dist/editor/hooks/useEntryManager.d.ts +2 -1
- package/dist/editor/hooks/useEntryManager.js +34 -19
- package/dist/editor/media/AssetCard.d.ts +1 -1
- package/dist/editor/media/crop-math.d.ts +3 -4
- package/dist/editor/media/crop-math.js +11 -22
- package/dist/editor/media/editor-image-src.d.ts +7 -0
- package/dist/editor/media/editor-image-src.js +12 -0
- package/dist/editor/preview-asset-base.d.ts +8 -0
- package/dist/editor/preview-asset-base.js +13 -0
- package/dist/editor/preview-bridge.d.ts +8 -1
- package/dist/editor/preview-bridge.js +15 -2
- package/dist/editor/preview-path.d.ts +10 -0
- package/dist/editor/preview-path.js +20 -0
- package/dist/editor/theme.js +2 -2
- package/dist/entry-schema-registry.d.ts +4 -4
- package/dist/entry-schema-registry.js +10 -6
- package/dist/entry-schema.d.ts +27 -4
- package/dist/entry-schema.js +20 -3
- package/dist/git-manager.d.ts +173 -6
- package/dist/git-manager.js +594 -76
- package/dist/github-service.d.ts +8 -0
- package/dist/github-service.js +5 -1
- package/dist/http/handler.js +37 -13
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +2 -0
- package/dist/http/router.d.ts +2 -0
- package/dist/http/router.js +2 -1
- package/dist/http/types.d.ts +2 -0
- package/dist/http/worker-not-ready.d.ts +10 -0
- package/dist/http/worker-not-ready.js +24 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +1 -4
- package/dist/paths/branch-name.d.ts +5 -0
- package/dist/paths/branch-name.js +9 -0
- package/dist/paths/branch.d.ts +6 -2
- package/dist/paths/branch.js +16 -10
- package/dist/paths/index.d.ts +3 -3
- package/dist/paths/index.js +3 -3
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/paths/validation.js +0 -2
- package/dist/reference-resolver.d.ts +5 -21
- package/dist/reference-resolver.js +9 -44
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/schema/schema-store.d.ts +54 -31
- package/dist/schema/schema-store.js +77 -31
- package/dist/server.d.ts +24 -9
- package/dist/server.js +22 -8
- package/dist/services.d.ts +18 -6
- package/dist/services.js +85 -70
- package/dist/settings-workspace.d.ts +23 -3
- package/dist/settings-workspace.js +197 -45
- package/dist/static/seo.d.ts +2 -14
- package/dist/static/seo.js +2 -25
- package/dist/submission-attribution.d.ts +73 -0
- package/dist/submission-attribution.js +221 -0
- package/dist/sync-core.d.ts +12 -1
- package/dist/sync-core.js +31 -13
- package/dist/task-queue/worker-status.d.ts +8 -0
- package/dist/task-queue/worker-status.js +17 -0
- package/dist/types.d.ts +31 -2
- package/dist/utils/content-write-lock.d.ts +18 -7
- package/dist/utils/content-write-lock.js +18 -9
- package/dist/utils/debug.d.ts +8 -0
- package/dist/utils/debug.js +10 -2
- package/dist/utils/git.d.ts +32 -0
- package/dist/utils/git.js +42 -0
- package/dist/utils/occ-json-write.js +10 -2
- package/dist/utils/provision-log.d.ts +25 -0
- package/dist/utils/provision-log.js +49 -0
- package/dist/utils/provisioning-lock.d.ts +35 -5
- package/dist/utils/provisioning-lock.js +73 -12
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/sanitize-href.d.ts +8 -22
- package/dist/utils/sanitize-href.js +11 -28
- package/dist/utils/url-prefix.d.ts +30 -0
- package/dist/utils/url-prefix.js +61 -0
- package/dist/validation/entry-validator.js +9 -3
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/cms-worker.d.ts +8 -3
- package/dist/worker/cms-worker.js +38 -2
- package/dist/worker/git-sync.d.ts +30 -12
- package/dist/worker/git-sync.js +253 -58
- package/dist/worker/history-rewrite.d.ts +1 -1
- package/dist/worker/history-rewrite.js +1 -1
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +95 -23
- package/dist/worker/remote-git-maintenance.d.ts +3 -0
- package/dist/worker/remote-git-maintenance.js +11 -0
- package/dist/worker/sparse-cone.d.ts +20 -0
- package/dist/worker/sparse-cone.js +132 -0
- package/dist/worker/task-runner.js +39 -4
- package/dist/worker/worker-context.d.ts +6 -1
- package/package.json +2 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { isCreatorsRecentBranch } from '../../api/branch-create-window.js';
|
|
2
|
+
import { isNonApiResponse } from '../../api/client.js';
|
|
3
|
+
import { sanitizeBranchName } from '../../paths/branch-name.js';
|
|
4
|
+
/** @internal Exported for tests. Past the 60 s a Lambda behind CloudFront gets, so its 504 lands first. */
|
|
5
|
+
export const CREATE_BRANCH_DEADLINE_MS = 90_000;
|
|
6
|
+
/** @internal Exported for tests. */
|
|
7
|
+
export const CREATE_TIMED_OUT_MESSAGE = "Creating the branch didn't finish (the server timed out). It's safe to try again.";
|
|
8
|
+
const DEADLINE = Symbol('deadline');
|
|
9
|
+
/**
|
|
10
|
+
* Create a branch. Past the deadline, after an error page from in front of the API, or after a
|
|
11
|
+
* busy 503 the branch may exist anyway, so the branch list decides; retrying is safe because a
|
|
12
|
+
* create publishes the whole branch or nothing. `userId` is the current user, the only creator
|
|
13
|
+
* whose listed branch can be this create's result.
|
|
14
|
+
*/
|
|
15
|
+
export async function requestBranchCreate(apiClient, body, userId, deadlineMs = CREATE_BRANCH_DEADLINE_MS) {
|
|
16
|
+
let timer;
|
|
17
|
+
const deadline = new Promise((resolve) => {
|
|
18
|
+
timer = setTimeout(() => resolve(DEADLINE), deadlineMs);
|
|
19
|
+
});
|
|
20
|
+
const result = await Promise.race([apiClient.branches.create(body), deadline]).finally(() => clearTimeout(timer));
|
|
21
|
+
if (result === DEADLINE)
|
|
22
|
+
return settle(apiClient, body.branch, userId, CREATE_TIMED_OUT_MESSAGE);
|
|
23
|
+
if (result.ok)
|
|
24
|
+
return { kind: 'created', branch: result.data?.branch };
|
|
25
|
+
if (isNonApiResponse(result))
|
|
26
|
+
return settle(apiClient, body.branch, userId, CREATE_TIMED_OUT_MESSAGE);
|
|
27
|
+
const message = result.error || 'Failed to create branch';
|
|
28
|
+
return result.status === 503
|
|
29
|
+
? settle(apiClient, body.branch, userId, message)
|
|
30
|
+
: { kind: 'failed', message };
|
|
31
|
+
}
|
|
32
|
+
/** A same-named branch the server would not answer this create with is a name conflict. */
|
|
33
|
+
async function settle(apiClient, requested, userId, message) {
|
|
34
|
+
const listed = await findListedBranch(apiClient, requested);
|
|
35
|
+
if (!listed)
|
|
36
|
+
return { kind: 'failed', message };
|
|
37
|
+
if (isCreatorsRecentBranch(listed, userId))
|
|
38
|
+
return { kind: 'created', branch: listed };
|
|
39
|
+
return { kind: 'failed', message: `A branch named "${listed.name}" already exists` };
|
|
40
|
+
}
|
|
41
|
+
async function findListedBranch(apiClient, requested) {
|
|
42
|
+
const name = sanitizeBranchName(requested);
|
|
43
|
+
try {
|
|
44
|
+
const listing = await apiClient.branches.list();
|
|
45
|
+
if (!listing.ok)
|
|
46
|
+
return undefined;
|
|
47
|
+
return listing.data?.branches.find((branch) => sanitizeBranchName(branch.name) === name);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -1,17 +1,28 @@
|
|
|
1
|
+
import type { BranchListItem } from '../../api/branch.js';
|
|
2
|
+
import type { UnsavedSummary } from './useDraftManager.js';
|
|
1
3
|
export interface UseBranchActionsOptions {
|
|
2
4
|
branchName: string;
|
|
3
5
|
setBranchName: (name: string) => void;
|
|
4
|
-
|
|
6
|
+
/** From useDraftManager's `resolveUnsaved`: what a branch-level action would leave behind. */
|
|
7
|
+
getUnsaved: () => Promise<UnsavedSummary>;
|
|
5
8
|
onReloadBranches: () => Promise<void>;
|
|
9
|
+
/** Receives the branch the server just created, so it can be shown before any listing includes it. */
|
|
10
|
+
onBranchCreated: (branch: BranchListItem) => void;
|
|
6
11
|
onBranchSwitch?: (branch: string) => void;
|
|
12
|
+
/** The current user; a create the server never answered adopts only a listed branch they made. */
|
|
13
|
+
userId?: string;
|
|
7
14
|
}
|
|
8
15
|
export interface UseBranchActionsReturn {
|
|
9
16
|
handleBranchChange: (branch: string | null) => Promise<void>;
|
|
17
|
+
/** True when nothing is unsaved or the user accepted the warning. Run before any in-flight UI. */
|
|
18
|
+
confirmCreate: () => Promise<boolean>;
|
|
10
19
|
handleCreateBranch: (branch: {
|
|
11
20
|
name: string;
|
|
12
21
|
title?: string;
|
|
13
22
|
description?: string;
|
|
14
|
-
}) => Promise<
|
|
23
|
+
}) => Promise<boolean>;
|
|
24
|
+
/** True while the unsaved-changes confirm is on screen. */
|
|
25
|
+
confirmOpen: boolean;
|
|
15
26
|
}
|
|
16
27
|
/**
|
|
17
28
|
* Custom hook for branch navigation actions with dirty check support.
|
|
@@ -1,13 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
2
3
|
import { modals } from '@mantine/modals';
|
|
3
4
|
import { notifications } from '@mantine/notifications';
|
|
4
5
|
import { Text } from '@mantine/core';
|
|
5
6
|
import { useApiClient } from '../context/index.js';
|
|
7
|
+
import { requestBranchCreate } from './create-branch-request.js';
|
|
8
|
+
const MAX_LISTED_LABELS = 5;
|
|
9
|
+
/** "Unsaved changes in: A, B." */
|
|
10
|
+
const describeUnsaved = ({ labels }) => {
|
|
11
|
+
if (labels.length === 0)
|
|
12
|
+
return 'You have unsaved changes.';
|
|
13
|
+
const shown = labels.slice(0, MAX_LISTED_LABELS).join(', ');
|
|
14
|
+
const rest = labels.length - MAX_LISTED_LABELS;
|
|
15
|
+
return `Unsaved changes in: ${shown}${rest > 0 ? ` and ${rest} more` : ''}.`;
|
|
16
|
+
};
|
|
6
17
|
/**
|
|
7
18
|
* Custom hook for branch navigation actions with dirty check support.
|
|
8
19
|
*/
|
|
9
20
|
export function useBranchActions(options) {
|
|
10
21
|
const apiClient = useApiClient();
|
|
22
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
11
23
|
const performBranchSwitch = (next) => {
|
|
12
24
|
options.setBranchName(next);
|
|
13
25
|
if (typeof window !== 'undefined') {
|
|
@@ -17,62 +29,81 @@ export function useBranchActions(options) {
|
|
|
17
29
|
}
|
|
18
30
|
options.onBranchSwitch?.(next);
|
|
19
31
|
};
|
|
20
|
-
const confirmIfDirty = async (
|
|
21
|
-
|
|
32
|
+
const confirmIfDirty = async (question) => {
|
|
33
|
+
const unsaved = await options.getUnsaved();
|
|
34
|
+
if (unsaved.count === 0)
|
|
22
35
|
return true;
|
|
23
36
|
return new Promise((resolve) => {
|
|
37
|
+
const settle = (value) => {
|
|
38
|
+
// Mantine calls onClose and onCancel from its modals reducer, which runs while
|
|
39
|
+
// ModalsProvider renders, so a setState here must be deferred; resolving needs no deferral.
|
|
40
|
+
queueMicrotask(() => setConfirmOpen(false));
|
|
41
|
+
resolve(value);
|
|
42
|
+
};
|
|
43
|
+
setConfirmOpen(true);
|
|
24
44
|
modals.openConfirmModal({
|
|
25
45
|
title: 'Unsaved Changes',
|
|
26
|
-
|
|
46
|
+
// Drafts are stored per branch, so leaving does not lose them.
|
|
47
|
+
children: (_jsxs(Text, { size: "sm", children: [describeUnsaved(unsaved), " Your drafts stay on \u201C", options.branchName, "\u201D and come back when you return. ", question] })),
|
|
27
48
|
labels: { confirm: 'Continue Anyway', cancel: 'Cancel' },
|
|
28
49
|
confirmProps: { color: 'red' },
|
|
29
|
-
onCancel: () =>
|
|
30
|
-
onConfirm: () =>
|
|
50
|
+
onCancel: () => settle(false),
|
|
51
|
+
onConfirm: () => settle(true),
|
|
52
|
+
// Escape and overlay dismissals fire only onClose. Mantine also calls it
|
|
53
|
+
// right after onConfirm, which is harmless: a promise settles once.
|
|
54
|
+
onClose: () => settle(false),
|
|
31
55
|
});
|
|
32
56
|
});
|
|
33
57
|
};
|
|
34
58
|
const handleBranchChange = async (next) => {
|
|
35
59
|
if (!next || next === options.branchName)
|
|
36
60
|
return;
|
|
37
|
-
const confirmed = await confirmIfDirty('
|
|
61
|
+
const confirmed = await confirmIfDirty('Switch branches anyway?');
|
|
38
62
|
if (!confirmed)
|
|
39
63
|
throw new Error('User cancelled branch switch');
|
|
40
64
|
performBranchSwitch(next);
|
|
41
65
|
};
|
|
66
|
+
const confirmCreate = () => confirmIfDirty('Create the new branch anyway?');
|
|
67
|
+
/** Resolves true when the branch was created and switched to, false when it was not. */
|
|
42
68
|
const handleCreateBranch = async (branch) => {
|
|
43
|
-
const confirmed = await confirmIfDirty('Create new branch without saving changes?');
|
|
44
|
-
if (!confirmed)
|
|
45
|
-
return;
|
|
46
69
|
try {
|
|
47
|
-
const
|
|
70
|
+
const outcome = await requestBranchCreate(apiClient, {
|
|
48
71
|
branch: branch.name,
|
|
49
72
|
title: branch.title,
|
|
50
73
|
description: branch.description,
|
|
51
|
-
});
|
|
52
|
-
if (
|
|
53
|
-
throw new Error(
|
|
74
|
+
}, options.userId);
|
|
75
|
+
if (outcome.kind === 'failed') {
|
|
76
|
+
throw new Error(outcome.message);
|
|
54
77
|
}
|
|
78
|
+
const created = outcome.branch;
|
|
79
|
+
if (created)
|
|
80
|
+
options.onBranchCreated(created);
|
|
55
81
|
// The server sanitizes the branch name (e.g. "feature/x" -> "feature-x")
|
|
56
82
|
// before persisting it. Adopt the canonical name from the response so
|
|
57
83
|
// the client doesn't end up stuck on a name the server never saved.
|
|
58
|
-
const createdName =
|
|
84
|
+
const createdName = created?.name ?? branch.name;
|
|
59
85
|
notifications.show({
|
|
60
86
|
message: createdName === branch.name
|
|
61
87
|
? `Branch "${createdName}" created`
|
|
62
88
|
: `Branch "${createdName}" created (renamed from "${branch.name}")`,
|
|
63
89
|
color: 'green',
|
|
64
90
|
});
|
|
65
|
-
await options.onReloadBranches();
|
|
66
|
-
// Switch to new branch (already confirmed dirty check)
|
|
67
91
|
performBranchSwitch(createdName);
|
|
92
|
+
// Not awaited: a listing can lag the create, and the switch must not wait
|
|
93
|
+
// on it. loadBranches reports its own failures and never rejects.
|
|
94
|
+
void options.onReloadBranches();
|
|
95
|
+
return true;
|
|
68
96
|
}
|
|
69
97
|
catch (err) {
|
|
70
98
|
const message = err instanceof Error ? err.message : 'Failed to create branch';
|
|
71
99
|
notifications.show({ message, color: 'red' });
|
|
100
|
+
return false;
|
|
72
101
|
}
|
|
73
102
|
};
|
|
74
103
|
return {
|
|
75
104
|
handleBranchChange,
|
|
105
|
+
confirmCreate,
|
|
76
106
|
handleCreateBranch,
|
|
107
|
+
confirmOpen,
|
|
77
108
|
};
|
|
78
109
|
}
|
|
@@ -2,6 +2,15 @@ import type { ConflictStatus, PullRequestState, SyncStatus } from '../../types.j
|
|
|
2
2
|
import type { OperatingMode } from '../../operating-mode/index.js';
|
|
3
3
|
import type { CommentThread } from '../../comment-store.js';
|
|
4
4
|
import type { BranchListItem } from '../../api/branch.js';
|
|
5
|
+
/**
|
|
6
|
+
* How long a branch this session created stays overlaid on listings. Each warm
|
|
7
|
+
* server container caches the shared filesystem separately, so for its
|
|
8
|
+
* attribute/dentry cache window (~60s, see docs/concurrency.md window A) a
|
|
9
|
+
* listing can lack the branch even after another container's listing showed
|
|
10
|
+
* it. Twice that absorbs request latency; past it, listings alone decide.
|
|
11
|
+
* @internal Exported only for the test that pins it.
|
|
12
|
+
*/
|
|
13
|
+
export declare const CREATED_BRANCH_GRACE_MS = 120000;
|
|
5
14
|
interface BranchSummary {
|
|
6
15
|
name: string;
|
|
7
16
|
status: string;
|
|
@@ -58,6 +67,8 @@ export interface UseBranchManagerReturn {
|
|
|
58
67
|
branches: BranchListItem[];
|
|
59
68
|
branchSummaries: BranchSummary[];
|
|
60
69
|
currentBranch: BranchListItem | undefined;
|
|
70
|
+
/** Shows a branch the server just created before any listing includes it. */
|
|
71
|
+
addCreatedBranch: (branch: BranchListItem) => void;
|
|
61
72
|
handleSubmit: (branchName: string) => Promise<void>;
|
|
62
73
|
handleWithdraw: (branchName: string) => Promise<void>;
|
|
63
74
|
handleRequestChanges: (branchName: string) => Promise<void>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import { useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
4
4
|
import { useSWRConfig } from 'swr';
|
|
5
5
|
import { Text } from '@mantine/core';
|
|
6
6
|
import { modals } from '@mantine/modals';
|
|
@@ -92,6 +92,46 @@ const showDeleteConfirmation = (branchName, onConfirm, onDismiss) => {
|
|
|
92
92
|
...confirmModalHandlers(onConfirm, onDismiss),
|
|
93
93
|
});
|
|
94
94
|
};
|
|
95
|
+
/**
|
|
96
|
+
* How long a branch this session created stays overlaid on listings. Each warm
|
|
97
|
+
* server container caches the shared filesystem separately, so for its
|
|
98
|
+
* attribute/dentry cache window (~60s, see docs/concurrency.md window A) a
|
|
99
|
+
* listing can lack the branch even after another container's listing showed
|
|
100
|
+
* it. Twice that absorbs request latency; past it, listings alone decide.
|
|
101
|
+
* @internal Exported only for the test that pins it.
|
|
102
|
+
*/
|
|
103
|
+
export const CREATED_BRANCH_GRACE_MS = 120_000;
|
|
104
|
+
/** The server listing plus any pending branches it lacks; the server's copy always wins. */
|
|
105
|
+
function mergePendingBranches(listed, pending) {
|
|
106
|
+
const listedNames = new Set(listed.map((b) => b.name));
|
|
107
|
+
const missing = pending.filter((p) => !listedNames.has(p.branch.name));
|
|
108
|
+
return missing.length === 0 ? listed : [...listed, ...missing.map((p) => p.branch)];
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Applies one received listing to the pending branches: drops those it arrived
|
|
112
|
+
* past the grace window for, and takes the listing's copy of the rest. Written
|
|
113
|
+
* so a listing with no arrival time (NaN) drops nothing.
|
|
114
|
+
*/
|
|
115
|
+
function reconcilePendingBranches(pending, listed, receivedAt) {
|
|
116
|
+
const listedByName = new Map(listed.map((b) => [b.name, b]));
|
|
117
|
+
let changed = false;
|
|
118
|
+
const next = [];
|
|
119
|
+
for (const p of pending) {
|
|
120
|
+
if (receivedAt - p.addedAt > CREATED_BRANCH_GRACE_MS) {
|
|
121
|
+
changed = true;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const listedCopy = listedByName.get(p.branch.name);
|
|
125
|
+
if (listedCopy && listedCopy !== p.branch) {
|
|
126
|
+
changed = true;
|
|
127
|
+
next.push({ branch: listedCopy, addedAt: p.addedAt });
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
next.push(p);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return changed ? next : pending;
|
|
134
|
+
}
|
|
95
135
|
/**
|
|
96
136
|
* Custom hook for managing git branches.
|
|
97
137
|
*/
|
|
@@ -100,7 +140,40 @@ export function useBranchManager(options) {
|
|
|
100
140
|
const { mutate: globalMutate } = useSWRConfig();
|
|
101
141
|
const [branchName, setBranchName] = useState(options.initialBranch);
|
|
102
142
|
const { data: branchesData, error: branchesError, isValidating: branchesIsValidating, } = useBranchesData(apiClient);
|
|
103
|
-
const
|
|
143
|
+
const [pendingBranches, setPendingBranches] = useState([]);
|
|
144
|
+
const branches = useMemo(() => mergePendingBranches(branchesData?.branches ?? [], pendingBranches), [branchesData, pendingBranches]);
|
|
145
|
+
const addCreatedBranch = useCallback((branch) => {
|
|
146
|
+
setPendingBranches((prev) => [
|
|
147
|
+
...prev.filter((p) => p.branch.name !== branch.name),
|
|
148
|
+
{ branch, addedAt: Date.now() },
|
|
149
|
+
]);
|
|
150
|
+
}, []);
|
|
151
|
+
const forgetCreatedBranch = (name) => {
|
|
152
|
+
setPendingBranches((prev) => {
|
|
153
|
+
const kept = prev.filter((p) => p.branch.name !== name);
|
|
154
|
+
return kept.length === prev.length ? prev : kept;
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
// After a workflow action, a pending branch's overlaid copy is stale. The
|
|
158
|
+
// server's returned copy replaces it, so a listing that still lacks the
|
|
159
|
+
// branch shows its new status instead of hiding it; without one, forgetting
|
|
160
|
+
// it fails closed rather than showing the pre-action state.
|
|
161
|
+
const updateCreatedBranch = (name, branch) => {
|
|
162
|
+
if (!branch) {
|
|
163
|
+
forgetCreatedBranch(name);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
setPendingBranches((prev) => prev.some((p) => p.branch.name === name)
|
|
167
|
+
? prev.map((p) => (p.branch.name === name ? { ...p, branch } : p))
|
|
168
|
+
: prev);
|
|
169
|
+
};
|
|
170
|
+
// Each received listing is a new `branchesData` (see `BranchesData.receivedAt`);
|
|
171
|
+
// the grace window is measured to its arrival.
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!branchesData)
|
|
174
|
+
return;
|
|
175
|
+
setPendingBranches((prev) => reconcilePendingBranches(prev, branchesData.branches, branchesData.receivedAt));
|
|
176
|
+
}, [branchesData]);
|
|
104
177
|
// Adopt the server's default branch once data arrives, if nothing pinned one.
|
|
105
178
|
useEffect(() => {
|
|
106
179
|
if (!branchName && branchesData?.defaultBranch) {
|
|
@@ -225,6 +298,7 @@ export function useBranchManager(options) {
|
|
|
225
298
|
message: 'Branch submitted for review',
|
|
226
299
|
color: 'green',
|
|
227
300
|
});
|
|
301
|
+
updateCreatedBranch(branchNameToSubmit, result.data?.branch);
|
|
228
302
|
await loadBranches();
|
|
229
303
|
resolve();
|
|
230
304
|
}
|
|
@@ -257,6 +331,7 @@ export function useBranchManager(options) {
|
|
|
257
331
|
throw new Error(result.error || 'Failed to withdraw branch');
|
|
258
332
|
}
|
|
259
333
|
notifications.show({ message: 'Branch withdrawn', color: 'blue' });
|
|
334
|
+
updateCreatedBranch(branchNameToWithdraw, result.data?.branch);
|
|
260
335
|
await loadBranches();
|
|
261
336
|
resolve();
|
|
262
337
|
}
|
|
@@ -279,6 +354,7 @@ export function useBranchManager(options) {
|
|
|
279
354
|
throw new Error(result.error || 'Failed to request changes');
|
|
280
355
|
}
|
|
281
356
|
notifications.show({ message: 'Changes requested', color: 'orange' });
|
|
357
|
+
updateCreatedBranch(branchNameForChanges, result.data?.branch);
|
|
282
358
|
await loadBranches();
|
|
283
359
|
}
|
|
284
360
|
catch (err) {
|
|
@@ -301,6 +377,7 @@ export function useBranchManager(options) {
|
|
|
301
377
|
throw new Error(result.error || 'Failed to delete branch');
|
|
302
378
|
}
|
|
303
379
|
notifications.show({ message: 'Branch deleted', color: 'green' });
|
|
380
|
+
forgetCreatedBranch(branchNameToDelete);
|
|
304
381
|
await loadBranches();
|
|
305
382
|
}
|
|
306
383
|
catch (err) {
|
|
@@ -338,6 +415,7 @@ export function useBranchManager(options) {
|
|
|
338
415
|
branches,
|
|
339
416
|
branchSummaries,
|
|
340
417
|
currentBranch,
|
|
418
|
+
addCreatedBranch,
|
|
341
419
|
handleSubmit,
|
|
342
420
|
handleWithdraw,
|
|
343
421
|
handleRequestChanges,
|
|
@@ -19,6 +19,12 @@ export declare const BRANCHES_KEY = "canopy:branches";
|
|
|
19
19
|
export interface BranchesData {
|
|
20
20
|
branches: BranchListItem[];
|
|
21
21
|
defaultBranch?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Client clock when this listing arrived. It makes listings fetched at
|
|
24
|
+
* different moments distinct `data` values even when their branches match,
|
|
25
|
+
* while a failed fetch (SWR keeps the previous `data`) changes nothing.
|
|
26
|
+
*/
|
|
27
|
+
receivedAt: number;
|
|
22
28
|
}
|
|
23
29
|
/**
|
|
24
30
|
* Fetch the branches list. Exported for direct testing and for the
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* `mutate()` to revalidate.
|
|
14
14
|
*/
|
|
15
15
|
import useSWR from 'swr';
|
|
16
|
+
import { isNonApiResponse } from '../../api/client.js';
|
|
16
17
|
/** Cache key for the branches list. */
|
|
17
18
|
export const BRANCHES_KEY = 'canopy:branches';
|
|
18
19
|
/**
|
|
@@ -21,15 +22,20 @@ export const BRANCHES_KEY = 'canopy:branches';
|
|
|
21
22
|
*/
|
|
22
23
|
export async function fetchBranches(apiClient) {
|
|
23
24
|
const result = await apiClient.branches.list();
|
|
24
|
-
if (result.status === 404) {
|
|
25
|
+
if (result.status === 404 && !isNonApiResponse(result)) {
|
|
25
26
|
// No branch endpoint available; stay branchless rather than erroring --
|
|
26
27
|
// the branch dropdown stays clickable so the user can retry from there.
|
|
27
|
-
|
|
28
|
+
// A proxy's 404 page means the API was not reached, so it is an error.
|
|
29
|
+
return { branches: [], receivedAt: Date.now() };
|
|
28
30
|
}
|
|
29
31
|
if (!result.ok) {
|
|
30
32
|
throw new Error(result.error ?? `Failed to load branches: ${result.status}`);
|
|
31
33
|
}
|
|
32
|
-
return {
|
|
34
|
+
return {
|
|
35
|
+
branches: result.data?.branches ?? [],
|
|
36
|
+
defaultBranch: result.data?.defaultBranch,
|
|
37
|
+
receivedAt: Date.now(),
|
|
38
|
+
};
|
|
33
39
|
}
|
|
34
40
|
/**
|
|
35
41
|
* How often to re-poll while at least one branch is mid-flight, in ms.
|
|
@@ -4,6 +4,7 @@ import { notifications } from '@mantine/notifications';
|
|
|
4
4
|
import { normalizeCanopyPath } from '../canopy-path.js';
|
|
5
5
|
import { useApiClient } from '../context/index.js';
|
|
6
6
|
import { resolveMessageOrigin } from '../preview-bridge.js';
|
|
7
|
+
import { isSamePreviewPath } from '../preview-path.js';
|
|
7
8
|
import { commentsKey, fetchComments, useCommentsData } from './useCommentsData.js';
|
|
8
9
|
/**
|
|
9
10
|
* Custom hook for managing the comment system.
|
|
@@ -112,6 +113,16 @@ export function useCommentSystem(options) {
|
|
|
112
113
|
}, [activeCommentContext, options.selectedPath, options.branchName]);
|
|
113
114
|
// Listen for field focus messages from preview frame
|
|
114
115
|
useEffect(() => {
|
|
116
|
+
// Delayed undo steps (highlight restore, focus clear). Cleanup runs them early rather than
|
|
117
|
+
// dropping them, so an entry switch never leaves a field highlighted or focused.
|
|
118
|
+
const pending = new Map();
|
|
119
|
+
const later = (undo, ms) => {
|
|
120
|
+
const id = window.setTimeout(() => {
|
|
121
|
+
pending.delete(id);
|
|
122
|
+
undo();
|
|
123
|
+
}, ms);
|
|
124
|
+
pending.set(id, undo);
|
|
125
|
+
};
|
|
115
126
|
const handleFocus = (event) => {
|
|
116
127
|
// Only accept messages from the preview's origin (same-origin when previewSrc is
|
|
117
128
|
// relative). Origin-only by design: this hook has no handle on the preview iframe
|
|
@@ -121,8 +132,11 @@ export function useCommentSystem(options) {
|
|
|
121
132
|
const msg = event.data;
|
|
122
133
|
if (msg?.type !== 'canopycms:preview:focus')
|
|
123
134
|
return;
|
|
135
|
+
const currentPath = options.currentEntry?.previewSrc ?? options.currentEntry?.path;
|
|
124
136
|
if (msg.entryPath &&
|
|
125
|
-
msg.entryPath !==
|
|
137
|
+
(typeof msg.entryPath !== 'string' ||
|
|
138
|
+
currentPath === undefined ||
|
|
139
|
+
!isSamePreviewPath(msg.entryPath, currentPath)))
|
|
126
140
|
return;
|
|
127
141
|
const normalizedPath = msg.fieldPath ? normalizeCanopyPath(msg.fieldPath) : undefined;
|
|
128
142
|
const target = normalizedPath
|
|
@@ -132,21 +146,26 @@ export function useCommentSystem(options) {
|
|
|
132
146
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
133
147
|
const previous = target.style.boxShadow;
|
|
134
148
|
target.style.boxShadow = '0 0 0 3px rgba(79, 70, 229, 0.35)';
|
|
135
|
-
|
|
149
|
+
later(() => {
|
|
136
150
|
target.style.boxShadow = previous;
|
|
137
151
|
}, 1200);
|
|
138
152
|
// Set focused field path to trigger FieldWrapper auto-focus
|
|
139
153
|
if (normalizedPath) {
|
|
140
154
|
setFocusedFieldPath(normalizedPath);
|
|
141
155
|
// Clear after brief delay to allow FieldWrapper to detect the change
|
|
142
|
-
|
|
143
|
-
setFocusedFieldPath(undefined);
|
|
144
|
-
}, 100);
|
|
156
|
+
later(() => setFocusedFieldPath(undefined), 100);
|
|
145
157
|
}
|
|
146
158
|
}
|
|
147
159
|
};
|
|
148
160
|
window.addEventListener('message', handleFocus);
|
|
149
|
-
return () =>
|
|
161
|
+
return () => {
|
|
162
|
+
window.removeEventListener('message', handleFocus);
|
|
163
|
+
for (const [id, undo] of pending) {
|
|
164
|
+
window.clearTimeout(id);
|
|
165
|
+
undo();
|
|
166
|
+
}
|
|
167
|
+
pending.clear();
|
|
168
|
+
};
|
|
150
169
|
}, [options.currentEntry]);
|
|
151
170
|
const handleJumpToField = (entryPath, canopyPath, threadId) => {
|
|
152
171
|
if (entryPath !== options.selectedPath) {
|
|
@@ -8,13 +8,19 @@ export interface UseDraftManagerOptions {
|
|
|
8
8
|
entries: EditorEntry[];
|
|
9
9
|
initialValues?: Record<string, FormValue>;
|
|
10
10
|
loadEntry: (entry: EditorEntry) => Promise<FormValue>;
|
|
11
|
+
/**
|
|
12
|
+
* Server read that records no OCC token (useEntryManager's `readEntryValue`),
|
|
13
|
+
* for checking restored drafts of unopened entries. Without it they stay
|
|
14
|
+
* unverified and count as dirty.
|
|
15
|
+
*/
|
|
16
|
+
readEntryValue?: (entry: EditorEntry) => Promise<FormValue>;
|
|
11
17
|
saveEntry: (entry: EditorEntry, value: FormValue) => Promise<FormValue>;
|
|
12
18
|
/**
|
|
13
19
|
* The OCC version token currently held for an entry on the branch being
|
|
14
20
|
* shown (useEntryManager's `getEntryVersion`). Stamps each draft with the
|
|
15
21
|
* version it was based on, and detects at save time that the token has
|
|
16
22
|
* since moved on. Optional: without it no base versions are recorded and
|
|
17
|
-
* conflict detection falls back entirely to
|
|
23
|
+
* conflict detection falls back entirely to saveEntry's token check.
|
|
18
24
|
*/
|
|
19
25
|
getEntryVersion?: (contentId: string) => number | undefined;
|
|
20
26
|
setBusy: (busy: boolean) => void;
|
|
@@ -26,6 +32,11 @@ export interface UseDraftManagerOptions {
|
|
|
26
32
|
*/
|
|
27
33
|
onSaved?: () => void;
|
|
28
34
|
}
|
|
35
|
+
/** The unsaved work a branch-level action would leave behind. `labels` names the entries (it may be shorter than `count`). */
|
|
36
|
+
export interface UnsavedSummary {
|
|
37
|
+
count: number;
|
|
38
|
+
labels: string[];
|
|
39
|
+
}
|
|
29
40
|
export interface UseDraftManagerReturn {
|
|
30
41
|
drafts: Record<string, FormValue>;
|
|
31
42
|
setDrafts: React.Dispatch<React.SetStateAction<Record<string, FormValue>>>;
|
|
@@ -46,6 +57,10 @@ export interface UseDraftManagerReturn {
|
|
|
46
57
|
isDirtyForEntry: (entryPath: string) => boolean;
|
|
47
58
|
isSelectedDirty: () => boolean;
|
|
48
59
|
isAnyDirty: () => boolean;
|
|
60
|
+
/** Resolves once restored drafts have been checked against the server (or a read failed), capped so a hung read cannot block. */
|
|
61
|
+
whenDraftsVerified: () => Promise<void>;
|
|
62
|
+
/** `whenDraftsVerified`, then the entries still counted dirty. */
|
|
63
|
+
resolveUnsaved: () => Promise<UnsavedSummary>;
|
|
49
64
|
/**
|
|
50
65
|
* Per-field validation errors for the selected entry, keyed by canonical
|
|
51
66
|
* canopy path (e.g. `blocks[0].title`). Populated when a save is blocked by
|