@1agh/maude 1.0.11 → 1.3.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/README.md +3 -1
- package/apps/studio/acp/bridge.ts +18 -13
- package/apps/studio/acp/index.ts +43 -7
- package/apps/studio/acp/transcript-io.ts +394 -0
- package/apps/studio/acp/transcript.ts +103 -38
- package/apps/studio/annotations-layer.tsx +191 -116
- package/apps/studio/annotations-model.ts +39 -0
- package/apps/studio/annotations-sync.ts +50 -0
- package/apps/studio/api.ts +821 -55
- package/apps/studio/bin/_import-figma.mjs +57 -11
- package/apps/studio/bin/_seed-fixture.mjs +163 -0
- package/apps/studio/bin/annotate.mjs +3 -1
- package/apps/studio/bin/server-up.sh +17 -2
- package/apps/studio/canvas-edit.ts +391 -7
- package/apps/studio/canvas-imports.ts +89 -0
- package/apps/studio/canvas-lib.tsx +82 -18
- package/apps/studio/canvas-notice-message.ts +16 -0
- package/apps/studio/canvas-notifications.tsx +19 -0
- package/apps/studio/canvas-shell.tsx +57 -6
- package/apps/studio/client/app.jsx +943 -248
- package/apps/studio/client/apply-edit-request.ts +55 -0
- package/apps/studio/client/export-center.jsx +125 -67
- package/apps/studio/client/github.js +38 -1
- package/apps/studio/client/index-loader.ts +76 -0
- package/apps/studio/client/panels/CloudBar.jsx +52 -6
- package/apps/studio/client/panels/GitPanel.jsx +103 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
- package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
- package/apps/studio/client/panels/SyncPanel.jsx +238 -4
- package/apps/studio/client/panels/TeamProjects.jsx +376 -0
- package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
- package/apps/studio/client/photo-knobs.jsx +3 -0
- package/apps/studio/client/report-bug.jsx +19 -1
- package/apps/studio/client/share-dialog.jsx +91 -0
- package/apps/studio/client/share-link.js +94 -0
- package/apps/studio/client/styles/3-shell-maude.css +69 -18
- package/apps/studio/client/styles/4-components.css +44 -1
- package/apps/studio/client/tree-row-menu.jsx +48 -3
- package/apps/studio/client/whats-new.jsx +26 -32
- package/apps/studio/cloud/endpoints.ts +132 -0
- package/apps/studio/collab/index.ts +4 -0
- package/apps/studio/collab/persistence.ts +137 -17
- package/apps/studio/collab/registry.ts +19 -21
- package/apps/studio/collab/room.ts +13 -2
- package/apps/studio/context.ts +59 -0
- package/apps/studio/debug-bundle.ts +13 -0
- package/apps/studio/dist/client.bundle.js +1911 -1879
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/sonner.js +1 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/export-dialog.tsx +54 -0
- package/apps/studio/exporters/degraded.ts +19 -3
- package/apps/studio/exporters/ghostscript.ts +194 -0
- package/apps/studio/exporters/pdf-fonts.ts +261 -0
- package/apps/studio/exporters/pdf.ts +152 -3
- package/apps/studio/exporters/remote.ts +36 -1
- package/apps/studio/fs-watch.ts +45 -1
- package/apps/studio/http.ts +296 -19
- package/apps/studio/inspect.ts +19 -0
- package/apps/studio/managed-projects.ts +150 -0
- package/apps/studio/mem.ts +45 -9
- package/apps/studio/notifications.tsx +292 -0
- package/apps/studio/runtime-bundle.ts +2 -0
- package/apps/studio/server.ts +69 -6
- package/apps/studio/sync/accepted-cold-start.ts +225 -0
- package/apps/studio/sync/accepted-link.ts +320 -0
- package/apps/studio/sync/action-stage.ts +343 -0
- package/apps/studio/sync/agent.ts +104 -45
- package/apps/studio/sync/asset-push.ts +1 -47
- package/apps/studio/sync/cell-file-events.ts +3 -0
- package/apps/studio/sync/codec.ts +192 -34
- package/apps/studio/sync/cold-start-apply.ts +7 -0
- package/apps/studio/sync/cold-start.ts +118 -22
- package/apps/studio/sync/comment-identity.ts +68 -0
- package/apps/studio/sync/connection-state.ts +15 -1
- package/apps/studio/sync/ctl-provider.ts +15 -2
- package/apps/studio/sync/document-discovery.ts +81 -0
- package/apps/studio/sync/file-ledger.ts +117 -0
- package/apps/studio/sync/file-membership.ts +10 -2
- package/apps/studio/sync/file-plane.ts +926 -47
- package/apps/studio/sync/index.ts +1786 -36
- package/apps/studio/sync/journal-client.ts +13 -1
- package/apps/studio/sync/migrate-seed.ts +150 -22
- package/apps/studio/sync/poke.ts +4 -2
- package/apps/studio/sync/presentation.ts +311 -5
- package/apps/studio/sync/projection.ts +820 -23
- package/apps/studio/sync/repeated-module.ts +73 -0
- package/apps/studio/sync/retry-after.ts +169 -0
- package/apps/studio/sync/revision-barrier.ts +129 -0
- package/apps/studio/sync/seed-progress.ts +218 -0
- package/apps/studio/sync/seed-repair.ts +46 -0
- package/apps/studio/sync/source-merge.ts +100 -0
- package/apps/studio/sync/source-ops.ts +289 -0
- package/apps/studio/sync/source-recovery.ts +70 -0
- package/apps/studio/sync/source-validation.ts +56 -0
- package/apps/studio/sync/status.ts +201 -6
- package/apps/studio/sync/transaction-client.ts +460 -0
- package/apps/studio/sync/transport-error.ts +111 -0
- package/apps/studio/sync/writer-registry.ts +236 -0
- package/apps/studio/text-caret.ts +35 -0
- package/apps/studio/undo-hud.tsx +9 -87
- package/apps/studio/use-canvas-media-drop.tsx +4 -39
- package/apps/studio/whats-new.json +173 -0
- package/cli/bin/maude.mjs +2 -0
- package/cli/commands/codex.mjs +191 -0
- package/cli/commands/harness.mjs +1051 -0
- package/cli/commands/help.mjs +13 -0
- package/cli/lib/design-link.mjs +190 -2
- package/cli/lib/harness/capabilities.mjs +302 -0
- package/cli/lib/harness/codex-runtime.mjs +892 -0
- package/cli/lib/harness/compatibility.mjs +27 -0
- package/cli/lib/harness/discover-claude.mjs +1393 -0
- package/cli/lib/harness/managed-state.mjs +1296 -0
- package/cli/lib/harness/model.mjs +86 -0
- package/cli/lib/harness/secrets.mjs +390 -0
- package/cli/lib/harness/targets/codex.mjs +1141 -0
- package/cli/lib/harness/targets/opencode.mjs +807 -0
- package/cli/lib/harness/transaction.mjs +1099 -0
- package/cli/lib/hubs-config.mjs +35 -8
- package/cli/templates/harness/opencode/maude-projector.ts +58 -0
- package/docs/harness-capability-matrix.md +125 -0
- package/docs/harness-environment-projection.md +95 -0
- package/package.json +33 -9
- package/plugins/design/dependencies.json +3 -3
- package/plugins/design/templates/_shell.html +47 -0
- package/plugins/flow/.claude-plugin/config.schema.json +8 -0
- package/plugins/flow/dependencies.json +3 -3
- package/apps/studio/.ai/cache/_stats.json +0 -7
- package/apps/studio/bin/_agent-browser-safe.test.mjs +0 -165
- package/apps/studio/bin/_curl-local.test.mjs +0 -280
- package/apps/studio/bin/_pw-launch.test.mjs +0 -90
- package/apps/studio/bin/_smart-frames.test.mjs +0 -195
- package/apps/studio/bin/_transcribe.test.mjs +0 -80
- package/apps/studio/bun.lock +0 -928
- package/apps/studio/draw/test/animate.test.ts +0 -172
- package/apps/studio/draw/test/brush.test.ts +0 -213
- package/apps/studio/draw/test/composition.test.ts +0 -141
- package/apps/studio/draw/test/geometry.test.ts +0 -199
- package/apps/studio/draw/test/gradient.test.ts +0 -167
- package/apps/studio/draw/test/layout.test.ts +0 -120
- package/apps/studio/draw/test/morph.test.ts +0 -107
- package/apps/studio/draw/test/optimize.test.ts +0 -40
- package/apps/studio/draw/test/palette.test.ts +0 -123
- package/apps/studio/draw/test/primitives.test.ts +0 -129
- package/apps/studio/draw/test/serialize-animate.test.ts +0 -142
- package/apps/studio/draw/test/serialize.test.ts +0 -105
- package/apps/studio/figma/assets.test.ts +0 -464
- package/apps/studio/figma/client.test.ts +0 -395
- package/apps/studio/figma/codegen-client.test.ts +0 -276
- package/apps/studio/figma/codegen-fonts.test.ts +0 -103
- package/apps/studio/figma/codegen-values.test.ts +0 -179
- package/apps/studio/figma/comments-to-strokes.test.ts +0 -194
- package/apps/studio/figma/fig-decode.test.ts +0 -788
- package/apps/studio/figma/fig-differential.test.ts +0 -182
- package/apps/studio/figma/fig-translator.test.ts +0 -192
- package/apps/studio/figma/fig-vector.test.ts +0 -113
- package/apps/studio/figma/from-codegen.test.ts +0 -408
- package/apps/studio/figma/sanitize.test.ts +0 -325
- package/apps/studio/figma/tailwind-map.test.ts +0 -142
- package/apps/studio/figma/to-artboard.test.ts +0 -808
- package/apps/studio/figma/to-render.test.ts +0 -180
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +0 -152
- package/apps/studio/figma/to-strokes.test.ts +0 -705
- package/apps/studio/figma/to-tokens.test.ts +0 -321
- package/apps/studio/figma/url.test.ts +0 -167
- package/apps/studio/footage/schema.test.ts +0 -287
- package/apps/studio/generation/adapters/groq.test.ts +0 -80
- package/apps/studio/generation/audio-library.test.ts +0 -95
- package/apps/studio/generation/captions.test.ts +0 -78
- package/apps/studio/generation/elevenlabs.test.ts +0 -223
- package/apps/studio/generation/gemini.test.ts +0 -350
- package/apps/studio/generation/jobs.test.ts +0 -128
- package/apps/studio/generation/keys.test.ts +0 -75
- package/apps/studio/generation/prefs.test.ts +0 -95
- package/apps/studio/generation/types.test.ts +0 -123
- package/apps/studio/generation/whisper-models.test.ts +0 -179
- package/apps/studio/test/_helpers.ts +0 -108
- package/apps/studio/test/acp-activity-endpoint.test.ts +0 -183
- package/apps/studio/test/acp-activity.test.ts +0 -154
- package/apps/studio/test/acp-ai-activity.test.ts +0 -182
- package/apps/studio/test/acp-attachment-serve.test.ts +0 -74
- package/apps/studio/test/acp-bootstrap-brief.test.ts +0 -178
- package/apps/studio/test/acp-branch-guard.test.ts +0 -123
- package/apps/studio/test/acp-bridge-lifetime.test.ts +0 -475
- package/apps/studio/test/acp-bridge.test.ts +0 -545
- package/apps/studio/test/acp-capabilities.test.ts +0 -123
- package/apps/studio/test/acp-caps-bridge.test.ts +0 -279
- package/apps/studio/test/acp-commands.test.ts +0 -119
- package/apps/studio/test/acp-elicitation-bridge.test.ts +0 -480
- package/apps/studio/test/acp-elicitation.test.ts +0 -251
- package/apps/studio/test/acp-env.test.ts +0 -95
- package/apps/studio/test/acp-mode-banner.test.ts +0 -45
- package/apps/studio/test/acp-origin-gate.test.ts +0 -160
- package/apps/studio/test/acp-permission-prompt.test.ts +0 -251
- package/apps/studio/test/acp-permission.test.ts +0 -278
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +0 -89
- package/apps/studio/test/acp-session-allowed-tools.test.ts +0 -235
- package/apps/studio/test/acp-session-plugins.test.ts +0 -209
- package/apps/studio/test/acp-toolgroup.test.ts +0 -76
- package/apps/studio/test/acp-transcript-view.test.ts +0 -71
- package/apps/studio/test/acp-transcript.test.ts +0 -238
- package/apps/studio/test/acp-usage-bridge.test.ts +0 -141
- package/apps/studio/test/acp-usage.test.ts +0 -143
- package/apps/studio/test/acp-write-gate.test.ts +0 -323
- package/apps/studio/test/acp-write-scope.test.ts +0 -533
- package/apps/studio/test/active-state.test.ts +0 -186
- package/apps/studio/test/activity.test.ts +0 -189
- package/apps/studio/test/ai-activity.test.ts +0 -113
- package/apps/studio/test/annotate-write.test.ts +0 -1074
- package/apps/studio/test/annotation-strokes-command.test.ts +0 -166
- package/apps/studio/test/annotations-align.test.ts +0 -88
- package/apps/studio/test/annotations-api.test.ts +0 -145
- package/apps/studio/test/annotations-bindings.test.ts +0 -262
- package/apps/studio/test/annotations-draw-modifiers.test.ts +0 -206
- package/apps/studio/test/annotations-groups.test.ts +0 -231
- package/apps/studio/test/annotations-layer.test.ts +0 -1011
- package/apps/studio/test/annotations-roundtrip.test.ts +0 -663
- package/apps/studio/test/annotations-snap.test.ts +0 -135
- package/apps/studio/test/annotations-text-format.test.ts +0 -396
- package/apps/studio/test/artboard-activity-overlay.test.tsx +0 -56
- package/apps/studio/test/artboard-guides-overlay.test.tsx +0 -152
- package/apps/studio/test/artboard-hug-commit.test.ts +0 -36
- package/apps/studio/test/artboard-kinds.test.tsx +0 -83
- package/apps/studio/test/artboard-selection-attrs.test.ts +0 -69
- package/apps/studio/test/asset-api.test.ts +0 -149
- package/apps/studio/test/assets-s3.test.ts +0 -249
- package/apps/studio/test/binary-smoke.test.ts +0 -47
- package/apps/studio/test/boot-self-heal.test.ts +0 -87
- package/apps/studio/test/browse-posture.test.tsx +0 -107
- package/apps/studio/test/bundle-smoke.test.ts +0 -46
- package/apps/studio/test/camera-reveal.test.tsx +0 -173
- package/apps/studio/test/canvas-artifacts.test.ts +0 -125
- package/apps/studio/test/canvas-build-sandbox.test.ts +0 -69
- package/apps/studio/test/canvas-build.test.ts +0 -141
- package/apps/studio/test/canvas-create-api.test.ts +0 -676
- package/apps/studio/test/canvas-cursors.test.ts +0 -164
- package/apps/studio/test/canvas-edit.test.ts +0 -470
- package/apps/studio/test/canvas-freshness-deps.test.ts +0 -64
- package/apps/studio/test/canvas-header.test.ts +0 -127
- package/apps/studio/test/canvas-hide-chrome.test.ts +0 -88
- package/apps/studio/test/canvas-hmr-runtime.test.tsx +0 -114
- package/apps/studio/test/canvas-lib-inline.test.ts +0 -146
- package/apps/studio/test/canvas-lib-motion.test.ts +0 -131
- package/apps/studio/test/canvas-lib-resolver.test.ts +0 -111
- package/apps/studio/test/canvas-list-watch.test.ts +0 -388
- package/apps/studio/test/canvas-media-drop.test.ts +0 -187
- package/apps/studio/test/canvas-meta-api.test.ts +0 -707
- package/apps/studio/test/canvas-move-api.test.ts +0 -587
- package/apps/studio/test/canvas-origin-gate.test.ts +0 -417
- package/apps/studio/test/canvas-pipeline.test.ts +0 -180
- package/apps/studio/test/canvas-rects.test.ts +0 -198
- package/apps/studio/test/canvas-reorder.test.ts +0 -211
- package/apps/studio/test/canvas-route.test.ts +0 -179
- package/apps/studio/test/canvas-shell-base.test.ts +0 -107
- package/apps/studio/test/canvas-shell-build-error.test.ts +0 -52
- package/apps/studio/test/canvas-url.test.ts +0 -138
- package/apps/studio/test/canvas-wheel-delta.test.ts +0 -42
- package/apps/studio/test/canvas-zoom-floor.test.ts +0 -73
- package/apps/studio/test/capture-core.test.ts +0 -32
- package/apps/studio/test/capture-determinism-shape.test.ts +0 -135
- package/apps/studio/test/chat-attachments.test.ts +0 -117
- package/apps/studio/test/chat-context.test.ts +0 -129
- package/apps/studio/test/chat-markdown.test.tsx +0 -58
- package/apps/studio/test/clip-addressing.test.ts +0 -738
- package/apps/studio/test/clip-ops.test.ts +0 -922
- package/apps/studio/test/cloud-build.test.ts +0 -95
- package/apps/studio/test/cloud-connect-note.test.ts +0 -135
- package/apps/studio/test/cloud-endpoints.test.ts +0 -590
- package/apps/studio/test/cloud-history-hardening.test.ts +0 -165
- package/apps/studio/test/cloud-history-posture.test.ts +0 -230
- package/apps/studio/test/cloud-managed-save-surfaces.test.ts +0 -90
- package/apps/studio/test/cloud-renew.test.ts +0 -205
- package/apps/studio/test/cloud-session-role.test.ts +0 -161
- package/apps/studio/test/cloud-shell-surfaces.test.ts +0 -170
- package/apps/studio/test/cold-start-apply.test.ts +0 -303
- package/apps/studio/test/collab-annotations-bridge.test.ts +0 -55
- package/apps/studio/test/collab-awareness-bridge.test.ts +0 -223
- package/apps/studio/test/collab-bridge.test.ts +0 -79
- package/apps/studio/test/collab-loopback.test.ts +0 -63
- package/apps/studio/test/collab-origin-gate.test.ts +0 -323
- package/apps/studio/test/collab-protocol.test.ts +0 -146
- package/apps/studio/test/collab-readonly-gate.test.ts +0 -194
- package/apps/studio/test/collab-reseed-guard.test.ts +0 -78
- package/apps/studio/test/collab-room.test.ts +0 -188
- package/apps/studio/test/collab-session-survival.test.tsx +0 -176
- package/apps/studio/test/collab-stress.test.ts +0 -129
- package/apps/studio/test/comment-mount.test.ts +0 -97
- package/apps/studio/test/comment-relay-origin-gate.test.ts +0 -117
- package/apps/studio/test/comments-api.test.ts +0 -277
- package/apps/studio/test/comments-fs-rebroadcast.test.ts +0 -155
- package/apps/studio/test/comments-overlay.test.ts +0 -117
- package/apps/studio/test/compile-entry.test.ts +0 -133
- package/apps/studio/test/component-map.test.ts +0 -48
- package/apps/studio/test/config-projection.test.ts +0 -104
- package/apps/studio/test/config-reload.test.ts +0 -230
- package/apps/studio/test/config-version.test.ts +0 -88
- package/apps/studio/test/context-resolve-tokens.test.ts +0 -97
- package/apps/studio/test/convert-to-absolute.test.ts +0 -333
- package/apps/studio/test/credential-grammar.test.ts +0 -107
- package/apps/studio/test/csp-canvas-shell.test.ts +0 -62
- package/apps/studio/test/csrf-write-guard.test.ts +0 -141
- package/apps/studio/test/debug-bundle.test.ts +0 -105
- package/apps/studio/test/design-setup-readiness.test.ts +0 -155
- package/apps/studio/test/detach-component.test.ts +0 -94
- package/apps/studio/test/dns-rebinding-guard.test.ts +0 -101
- package/apps/studio/test/dom-selection.test.ts +0 -198
- package/apps/studio/test/dynamic-text-edit.test.ts +0 -162
- package/apps/studio/test/edit-css-occurrence.test.ts +0 -81
- package/apps/studio/test/edit-persistence.test.ts +0 -91
- package/apps/studio/test/edit-scope-api.test.ts +0 -119
- package/apps/studio/test/edit-source-command.test.ts +0 -142
- package/apps/studio/test/edit-suppress.test.ts +0 -170
- package/apps/studio/test/editing-presence.test.ts +0 -103
- package/apps/studio/test/element-resize.test.ts +0 -136
- package/apps/studio/test/element-structural-api.test.ts +0 -434
- package/apps/studio/test/element-structural-edit.test.ts +0 -750
- package/apps/studio/test/ensure-browser.test.ts +0 -92
- package/apps/studio/test/equal-spacing-detector.test.ts +0 -257
- package/apps/studio/test/export-assemble.test.ts +0 -53
- package/apps/studio/test/export-browser-lane.test.ts +0 -126
- package/apps/studio/test/export-capture-fidelity.test.ts +0 -146
- package/apps/studio/test/export-capture-hygiene.test.ts +0 -675
- package/apps/studio/test/export-center.test.tsx +0 -287
- package/apps/studio/test/export-e2e-lanes.test.ts +0 -857
- package/apps/studio/test/export-format-scope-coherence.test.ts +0 -89
- package/apps/studio/test/export-lane.test.ts +0 -245
- package/apps/studio/test/export-shim-multi-capture.test.ts +0 -83
- package/apps/studio/test/exporters/canva.test.ts +0 -63
- package/apps/studio/test/exporters/degraded-propagation.test.ts +0 -123
- package/apps/studio/test/exporters/endpoint.test.ts +0 -121
- package/apps/studio/test/exporters/history.test.ts +0 -107
- package/apps/studio/test/exporters/html.test.ts +0 -26
- package/apps/studio/test/exporters/jobs.test.ts +0 -342
- package/apps/studio/test/exporters/pdf.test.ts +0 -85
- package/apps/studio/test/exporters/png.test.ts +0 -103
- package/apps/studio/test/exporters/pptx-deck.test.ts +0 -112
- package/apps/studio/test/exporters/pptx.test.ts +0 -31
- package/apps/studio/test/exporters/pw-launch.test.ts +0 -49
- package/apps/studio/test/exporters/runtime.test.ts +0 -59
- package/apps/studio/test/exporters/scope.test.ts +0 -292
- package/apps/studio/test/exporters/svg.test.ts +0 -29
- package/apps/studio/test/exporters/unsupported-media.test.ts +0 -123
- package/apps/studio/test/exporters/zip.test.ts +0 -104
- package/apps/studio/test/fetch-asset-gate.test.ts +0 -189
- package/apps/studio/test/figjam-v3-model.test.ts +0 -342
- package/apps/studio/test/figma-explode.test.ts +0 -438
- package/apps/studio/test/figma-provenance.test.ts +0 -108
- package/apps/studio/test/figma-routes.test.ts +0 -294
- package/apps/studio/test/file-lock.test.ts +0 -84
- package/apps/studio/test/fixtures/backgrounding-claude-auth.sh +0 -12
- package/apps/studio/test/fixtures/fake-claude-auth.mjs +0 -23
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +0 -1
- package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +0 -158
- package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +0 -44
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +0 -46
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +0 -42
- package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +0 -63
- package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +0 -51
- package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +0 -50
- package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +0 -45
- package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +0 -56
- package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +0 -37
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +0 -118
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +0 -69
- package/apps/studio/test/fixtures/noisy-claude-auth.mjs +0 -27
- package/apps/studio/test/fixtures/perf-canvas.mjs +0 -201
- package/apps/studio/test/fixtures/phase-20-annotations.svg +0 -1
- package/apps/studio/test/fixtures/phase-21-annotations.svg +0 -1
- package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +0 -82
- package/apps/studio/test/fixtures/wedged-claude-auth.sh +0 -11
- package/apps/studio/test/footage-store.test.ts +0 -100
- package/apps/studio/test/fs-mkdir-api.test.ts +0 -248
- package/apps/studio/test/fs-watch.test.ts +0 -41
- package/apps/studio/test/gemma-models.test.ts +0 -245
- package/apps/studio/test/generate-route.test.ts +0 -106
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +0 -341
- package/apps/studio/test/git-cloud-posture.test.ts +0 -59
- package/apps/studio/test/git-grouping.test.ts +0 -106
- package/apps/studio/test/git-lifecycle.test.ts +0 -100
- package/apps/studio/test/git-log-format.test.ts +0 -95
- package/apps/studio/test/git-safe-rel.test.ts +0 -132
- package/apps/studio/test/git-system-engine.test.ts +0 -135
- package/apps/studio/test/git-watch.test.ts +0 -97
- package/apps/studio/test/github-api.test.ts +0 -577
- package/apps/studio/test/grid-track-handles.test.ts +0 -160
- package/apps/studio/test/handoff-static-frames.test.ts +0 -215
- package/apps/studio/test/handoff.test.ts +0 -329
- package/apps/studio/test/history-rollback.test.ts +0 -88
- package/apps/studio/test/hmr-broadcast.test.ts +0 -236
- package/apps/studio/test/hmr-classify.test.ts +0 -87
- package/apps/studio/test/hub-link.test.ts +0 -80
- package/apps/studio/test/identity-cache.test.ts +0 -83
- package/apps/studio/test/import-asset-browser.test.ts +0 -64
- package/apps/studio/test/import-asset-route.test.ts +0 -71
- package/apps/studio/test/import-asset.test.ts +0 -427
- package/apps/studio/test/import-brand-browser.test.ts +0 -88
- package/apps/studio/test/import-brand-route.test.ts +0 -120
- package/apps/studio/test/import-brand.test.ts +0 -206
- package/apps/studio/test/import-figma.test.ts +0 -667
- package/apps/studio/test/import-tokens.test.ts +0 -722
- package/apps/studio/test/index-data-canvas-ds.test.ts +0 -97
- package/apps/studio/test/input-router.test.ts +0 -757
- package/apps/studio/test/inspect-script-syntax.test.ts +0 -53
- package/apps/studio/test/inspect-selections.test.ts +0 -219
- package/apps/studio/test/knob-props-authored.test.ts +0 -87
- package/apps/studio/test/layers-synthetic-groups.test.ts +0 -96
- package/apps/studio/test/locator.test.ts +0 -214
- package/apps/studio/test/marquee-overlay.test.ts +0 -94
- package/apps/studio/test/media-commit-chain.test.ts +0 -210
- package/apps/studio/test/mode-toggle.test.tsx +0 -226
- package/apps/studio/test/motion-sample.test.ts +0 -35
- package/apps/studio/test/move-artboards-command.test.ts +0 -108
- package/apps/studio/test/participants-chrome.test.ts +0 -65
- package/apps/studio/test/paths.test.ts +0 -88
- package/apps/studio/test/pdf-print-boxes.test.ts +0 -359
- package/apps/studio/test/peer-selection-follows-camera.test.tsx +0 -131
- package/apps/studio/test/perf-harness.ts +0 -193
- package/apps/studio/test/phase-3.6-smoke.test.ts +0 -79
- package/apps/studio/test/photo-bg-remove-validation.test.ts +0 -71
- package/apps/studio/test/photo-canvas-bundle.test.ts +0 -79
- package/apps/studio/test/photo-edit-api.test.ts +0 -201
- package/apps/studio/test/photo-filters.test.ts +0 -149
- package/apps/studio/test/photo-pipeline.test.ts +0 -106
- package/apps/studio/test/photo-taxonomy.test.ts +0 -96
- package/apps/studio/test/print-marks.test.ts +0 -113
- package/apps/studio/test/print-units.test.ts +0 -173
- package/apps/studio/test/read-annotations.test.ts +0 -727
- package/apps/studio/test/read-only-gate.test.ts +0 -188
- package/apps/studio/test/read-only-role.test.ts +0 -95
- package/apps/studio/test/readiness.test.ts +0 -337
- package/apps/studio/test/remote-ahead-behind-cache.test.ts +0 -89
- package/apps/studio/test/reorder-api.test.ts +0 -210
- package/apps/studio/test/repo-concurrency.test.ts +0 -146
- package/apps/studio/test/repo-lock.test.ts +0 -350
- package/apps/studio/test/report-proxy.test.ts +0 -125
- package/apps/studio/test/ripple.test.ts +0 -197
- package/apps/studio/test/runtime-bundle-error-mapping.test.ts +0 -43
- package/apps/studio/test/runtime-bundle.test.ts +0 -69
- package/apps/studio/test/sanitize-annotation-svg.test.ts +0 -238
- package/apps/studio/test/scaffold-design.test.ts +0 -122
- package/apps/studio/test/server-lifecycle.test.ts +0 -38
- package/apps/studio/test/session-runtime-state.test.ts +0 -226
- package/apps/studio/test/session-scope.test.ts +0 -100
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +0 -642
- package/apps/studio/test/shared-doc-convergence.test.ts +0 -265
- package/apps/studio/test/shared-doc-foundation.test.ts +0 -63
- package/apps/studio/test/shared-doc-migrate.test.ts +0 -369
- package/apps/studio/test/shared-doc-projection.test.ts +0 -238
- package/apps/studio/test/shell-importmap.test.ts +0 -56
- package/apps/studio/test/sizing-mode.test.ts +0 -102
- package/apps/studio/test/slash-commands.test.ts +0 -117
- package/apps/studio/test/snap-distance-pill.test.ts +0 -68
- package/apps/studio/test/spacing-handles.test.ts +0 -138
- package/apps/studio/test/specimen-select.test.ts +0 -88
- package/apps/studio/test/sync-agent.test.ts +0 -660
- package/apps/studio/test/sync-annotations-cold-start.test.ts +0 -302
- package/apps/studio/test/sync-asset-push.test.ts +0 -931
- package/apps/studio/test/sync-atomic-write.test.ts +0 -62
- package/apps/studio/test/sync-attach-incremental.test.ts +0 -527
- package/apps/studio/test/sync-autocommit.test.ts +0 -537
- package/apps/studio/test/sync-canvas-path.test.ts +0 -200
- package/apps/studio/test/sync-cell-write-nudge.test.ts +0 -346
- package/apps/studio/test/sync-codec.test.ts +0 -230
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +0 -187
- package/apps/studio/test/sync-cold-start.test.ts +0 -304
- package/apps/studio/test/sync-connect-honesty.test.ts +0 -114
- package/apps/studio/test/sync-connection-state.test.ts +0 -337
- package/apps/studio/test/sync-ctl-channel.test.ts +0 -508
- package/apps/studio/test/sync-decide-file.test.ts +0 -420
- package/apps/studio/test/sync-doc-name.test.ts +0 -281
- package/apps/studio/test/sync-echo-guard.test.ts +0 -96
- package/apps/studio/test/sync-file-ledger.test.ts +0 -334
- package/apps/studio/test/sync-file-membership.test.ts +0 -347
- package/apps/studio/test/sync-file-plane.test.ts +0 -976
- package/apps/studio/test/sync-file-pull.test.ts +0 -333
- package/apps/studio/test/sync-fresh-link-parity.test.ts +0 -267
- package/apps/studio/test/sync-fs-mirror.test.ts +0 -181
- package/apps/studio/test/sync-hardening.test.ts +0 -586
- package/apps/studio/test/sync-hub-listing.test.ts +0 -46
- package/apps/studio/test/sync-hubs-config.test.ts +0 -133
- package/apps/studio/test/sync-incident-replay.test.ts +0 -211
- package/apps/studio/test/sync-journal.test.ts +0 -176
- package/apps/studio/test/sync-meta-codec.test.ts +0 -199
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +0 -98
- package/apps/studio/test/sync-move-retirement.test.ts +0 -231
- package/apps/studio/test/sync-panel-surface.test.ts +0 -153
- package/apps/studio/test/sync-path-pull.test.ts +0 -531
- package/apps/studio/test/sync-presentation.test.ts +0 -262
- package/apps/studio/test/sync-pull-budget.test.ts +0 -169
- package/apps/studio/test/sync-remote-docs.test.ts +0 -333
- package/apps/studio/test/sync-resync-routes.test.ts +0 -168
- package/apps/studio/test/sync-runtime.test.ts +0 -1675
- package/apps/studio/test/sync-seed-defers-to-hub.test.ts +0 -83
- package/apps/studio/test/sync-settings-routes.test.ts +0 -195
- package/apps/studio/test/sync-settings.test.ts +0 -151
- package/apps/studio/test/sync-status.test.ts +0 -261
- package/apps/studio/test/sync-supervisor.test.ts +0 -262
- package/apps/studio/test/sync-tombstone-apply.test.ts +0 -111
- package/apps/studio/test/sync-trash.test.ts +0 -132
- package/apps/studio/test/sync-two-peer-discovery.test.ts +0 -343
- package/apps/studio/test/sync-untrusted.test.ts +0 -103
- package/apps/studio/test/system-endpoint.test.ts +0 -144
- package/apps/studio/test/text-editability-stamp.test.ts +0 -131
- package/apps/studio/test/timeline-comp-target.test.ts +0 -139
- package/apps/studio/test/timeline-media-cache.test.ts +0 -41
- package/apps/studio/test/timeline-parse.test.ts +0 -184
- package/apps/studio/test/timeline-scale.test.ts +0 -65
- package/apps/studio/test/timeline-snap.test.ts +0 -85
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +0 -86
- package/apps/studio/test/tool-palette.test.tsx +0 -55
- package/apps/studio/test/tour-overlay.test.tsx +0 -90
- package/apps/studio/test/trusted-request-host.test.ts +0 -66
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +0 -211
- package/apps/studio/test/undo-stack.test.ts +0 -244
- package/apps/studio/test/use-agent-presence.test.tsx +0 -117
- package/apps/studio/test/use-annotation-resize.test.ts +0 -402
- package/apps/studio/test/use-annotation-selection.test.tsx +0 -77
- package/apps/studio/test/use-artboard-drag.test.ts +0 -324
- package/apps/studio/test/use-canvas-activity.test.tsx +0 -206
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-cursor-modifiers.test.ts +0 -59
- package/apps/studio/test/use-keyboard-discipline.test.ts +0 -27
- package/apps/studio/test/use-selection-set.test.tsx +0 -166
- package/apps/studio/test/use-snap-guides.test.ts +0 -271
- package/apps/studio/test/use-tool-mode.test.tsx +0 -186
- package/apps/studio/test/use-undo-stack.test.tsx +0 -223
- package/apps/studio/test/video-asset.test.ts +0 -163
- package/apps/studio/test/video-comp-fixture.test.ts +0 -50
- package/apps/studio/test/video-comp.test.ts +0 -270
- package/apps/studio/test/video-encode-lib.test.ts +0 -63
- package/apps/studio/test/video-render-bridge.test.ts +0 -171
- package/apps/studio/test/whats-new.test.ts +0 -113
- package/apps/studio/test/whisper-setup.test.ts +0 -97
- package/apps/studio/test/workspace-containment.test.ts +0 -382
- package/apps/studio/test/workspace-signin.test.ts +0 -292
- package/apps/studio/test/ws-handshake.test.ts +0 -33
- package/cli/commands/cache.test.mjs +0 -166
- package/cli/commands/design-link.test.mjs +0 -419
- package/cli/commands/design.test.mjs +0 -240
- package/cli/commands/doctor.test.mjs +0 -237
- package/cli/commands/hub-workspace.test.mjs +0 -275
- package/cli/commands/hub.test.mjs +0 -225
- package/cli/commands/kg.test.mjs +0 -142
- package/cli/commands/preflight.test.mjs +0 -46
- package/cli/lib/argv.test.mjs +0 -46
- package/cli/lib/cache.test.mjs +0 -303
- package/cli/lib/cell-plan.test.mjs +0 -225
- package/cli/lib/config-lint.test.mjs +0 -117
- package/cli/lib/ddr-to-kgai.test.mjs +0 -118
- package/cli/lib/design-ownership.test.mjs +0 -329
- package/cli/lib/fetch-asset.test.mjs +0 -213
- package/cli/lib/figma-codegen-reachability.test.mjs +0 -104
- package/cli/lib/figma-import-controls.test.mjs +0 -70
- package/cli/lib/flow-design-integration.test.mjs +0 -165
- package/cli/lib/gitignore-block.test.mjs +0 -136
- package/cli/lib/gitignore-drift.test.mjs +0 -156
- package/cli/lib/hubs-config.test.mjs +0 -165
- package/cli/lib/pkg-root.test.mjs +0 -111
- package/cli/lib/plugin-cli-reachability.test.mjs +0 -57
- package/cli/lib/plugin-name-namespace.test.mjs +0 -71
- package/cli/lib/reconstruct-toolset.test.mjs +0 -194
- package/cli/lib/stack-detect.test.mjs +0 -121
- package/cli/lib/update-check.test.mjs +0 -32
- package/cli/lib/workspace-plan.test.mjs +0 -699
package/apps/studio/api.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Returns plain objects; http.ts wraps them in Response.json().
|
|
3
3
|
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
|
-
import type
|
|
5
|
+
import { type Dirent, renameSync } from 'node:fs';
|
|
6
6
|
import {
|
|
7
7
|
lstat,
|
|
8
8
|
mkdir,
|
|
@@ -17,7 +17,11 @@ import path from 'node:path';
|
|
|
17
17
|
import { createAssetMirror, s3ConfigFromEnv } from './assets-s3.ts';
|
|
18
18
|
import { canvasArtifacts, locatorKeyFor, relocatedName } from './canvas-artifacts.ts';
|
|
19
19
|
import { renderBriefBoard, validateCanvasName, validateFolderName } from './canvas-create.ts';
|
|
20
|
+
import { rewriteRelativeImports } from './canvas-imports.ts';
|
|
20
21
|
import { canvasSlugFromRel } from './canvas-slug.ts';
|
|
22
|
+
import { atomicWrite } from './sync/atomic-write.ts';
|
|
23
|
+
import { dedupeCommentsById } from './sync/comment-identity.ts';
|
|
24
|
+
import { isRuntimeStateRel } from './sync/file-membership.ts';
|
|
21
25
|
|
|
22
26
|
// Re-exported so existing external callers (canvas-list-watch.ts, tests) keep
|
|
23
27
|
// importing it from api.ts — the actual implementation now lives in
|
|
@@ -25,8 +29,19 @@ import { canvasSlugFromRel } from './canvas-slug.ts';
|
|
|
25
29
|
// without a cycle back through api.ts.
|
|
26
30
|
export { canvasSlugFromRel } from './canvas-slug.ts';
|
|
27
31
|
|
|
32
|
+
/** Plan T17/L03 — the supporting files the file tree can move, rename and
|
|
33
|
+
* delete: what it previews (notes, styles, data, images, media, fonts), minus
|
|
34
|
+
* the canvas's own sidecars, which only ever travel with their canvas. */
|
|
35
|
+
export function isSupportingFileRel(rel: string): boolean {
|
|
36
|
+
if (/\.(meta\.json|annotations\.svg|registry\.json)$/i.test(rel)) return false;
|
|
37
|
+
return /\.(md|css|json|txt|ya?ml|svg|png|jpe?g|gif|webp|avif|mp4|webm|mov|mp3|wav|ogg|m4a|woff2?|ttf|otf)$/i.test(
|
|
38
|
+
rel
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
import {
|
|
29
43
|
type AssembleClip,
|
|
44
|
+
type AttributeState,
|
|
30
45
|
assembleCompSource,
|
|
31
46
|
CanvasEditError,
|
|
32
47
|
type ClipInfo,
|
|
@@ -60,6 +75,7 @@ import {
|
|
|
60
75
|
setArtboardGuides,
|
|
61
76
|
setArtboardHug,
|
|
62
77
|
setArtboardKind,
|
|
78
|
+
setArtboardLabel,
|
|
63
79
|
setArtboardPrint,
|
|
64
80
|
setArtboardStyle,
|
|
65
81
|
toggleClipHidden,
|
|
@@ -99,6 +115,7 @@ import { clearLocatorSlug, readLocator, writeLocator } from './locator.ts';
|
|
|
99
115
|
import { STICKERS_DIR } from './paths.ts';
|
|
100
116
|
import { getPaperPreset, MAX_PRINT_MM } from './print/units.ts';
|
|
101
117
|
import { sessionDir } from './session-scope.ts';
|
|
118
|
+
import { describeSourceOp } from './sync/source-ops.ts';
|
|
102
119
|
import { isWorkspaceMode } from './workspace-mode.ts';
|
|
103
120
|
|
|
104
121
|
// Directories that never hold user-facing canvases. Exported so the
|
|
@@ -309,8 +326,27 @@ export type CreateFolderResult =
|
|
|
309
326
|
|
|
310
327
|
/** Phase 12 — result of an in-canvas direct edit (`editCss` / `editText`). */
|
|
311
328
|
export type EditOpResult =
|
|
312
|
-
|
|
313
|
-
|
|
329
|
+
/** `previous` — what a css/attr write replaced (`null` = was unset); absent
|
|
330
|
+
* when it was an expression no literal undo can restore. */
|
|
331
|
+
| { ok: true; delta: number; seq?: number; previous?: string | null }
|
|
332
|
+
/** `conflict` = the target no longer held the caller's expected value. */
|
|
333
|
+
| { ok: false; status: number; error: string; conflict?: true };
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Optional expected-current value for a css/attr write (audit 2026-09-13 P1
|
|
337
|
+
* #5). An absent `expected` key keeps the unconditional legacy behaviour; a
|
|
338
|
+
* present one must be a bounded string or `null` ("currently unset").
|
|
339
|
+
*/
|
|
340
|
+
function expectedValueOf(input: {
|
|
341
|
+
expected?: unknown;
|
|
342
|
+
}): { ok: true; expected?: string | null } | { ok: false } {
|
|
343
|
+
if (!Object.hasOwn(input, 'expected') || input.expected === undefined) return { ok: true };
|
|
344
|
+
if (input.expected === null) return { ok: true, expected: null };
|
|
345
|
+
if (typeof input.expected === 'string' && input.expected.length <= 256) {
|
|
346
|
+
return { ok: true, expected: input.expected };
|
|
347
|
+
}
|
|
348
|
+
return { ok: false };
|
|
349
|
+
}
|
|
314
350
|
|
|
315
351
|
/**
|
|
316
352
|
* Phase 12.1 (DDR-138) — result of a node-move reorder. Carries the re-settle
|
|
@@ -371,7 +407,9 @@ export interface Api {
|
|
|
371
407
|
): Promise<Record<string, unknown> | null>;
|
|
372
408
|
// Annotations sidecar (Phase 5 — .design/<slug>.annotations.svg)
|
|
373
409
|
loadAnnotations(file: string): Promise<string | null>;
|
|
374
|
-
saveAnnotations(file: string, svg: string): Promise<boolean>;
|
|
410
|
+
saveAnnotations(file: string, svg: string, writeId?: string, base?: string): Promise<boolean>;
|
|
411
|
+
/** Materialize a document snapshot without publishing it as another user edit. */
|
|
412
|
+
projectAnnotations(file: string, svg: string, isCurrent: () => boolean): Promise<boolean>;
|
|
375
413
|
// Phase 23 — content-addressed binary image write (drag-drop / paste / picker)
|
|
376
414
|
saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
|
|
377
415
|
/** Stage F1 — list content-addressed image/video assets for the AssetPicker. */
|
|
@@ -422,6 +460,10 @@ export interface Api {
|
|
|
422
460
|
width?: unknown;
|
|
423
461
|
height?: unknown;
|
|
424
462
|
}): Promise<CreateCanvasResult>;
|
|
463
|
+
// Duplicate a canvas beside itself ("<name> copy") — POST /_api/canvas
|
|
464
|
+
// { duplicateOf }. Source, meta and the whiteboard layer; not comments or
|
|
465
|
+
// history, which belong to the original.
|
|
466
|
+
duplicateCanvas(input: { file?: unknown }): Promise<CreateCanvasResult>;
|
|
425
467
|
// Soft-delete a canvas from the browser (Phase 22 — DELETE /_api/canvas)
|
|
426
468
|
deleteCanvas(input: { file?: unknown }): Promise<DeleteCanvasResult>;
|
|
427
469
|
// feature-file-tree-drag-drop-folders (Task 3) — move/rename a canvas + its
|
|
@@ -439,6 +481,8 @@ export interface Api {
|
|
|
439
481
|
value?: unknown;
|
|
440
482
|
reset?: unknown;
|
|
441
483
|
idIndex?: unknown;
|
|
484
|
+
/** Expected current value (string) or `null` = currently unset. Absent = unconditional. */
|
|
485
|
+
expected?: unknown;
|
|
442
486
|
}): Promise<EditOpResult>;
|
|
443
487
|
// Phase 12 (DDR-103) — inline text-content edit (POST /_api/edit-text). Main-origin only.
|
|
444
488
|
editText(input: {
|
|
@@ -456,6 +500,8 @@ export interface Api {
|
|
|
456
500
|
id?: unknown;
|
|
457
501
|
attr?: unknown;
|
|
458
502
|
value?: unknown;
|
|
503
|
+
reset?: unknown;
|
|
504
|
+
expected?: unknown;
|
|
459
505
|
}): Promise<EditOpResult>;
|
|
460
506
|
// Phase 12.1 (DDR-138) — node-move reorder (POST /_api/reorder). Main-origin
|
|
461
507
|
// only. Moves the element with data-cd-id `id` to `position` relative to
|
|
@@ -659,6 +705,12 @@ export interface Api {
|
|
|
659
705
|
artboardId?: unknown;
|
|
660
706
|
kind?: unknown;
|
|
661
707
|
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
|
|
708
|
+
/** Rename an artboard (T25/L08 — double-click its name on the canvas). */
|
|
709
|
+
setArtboardLabelOp(input: {
|
|
710
|
+
canvas?: unknown;
|
|
711
|
+
artboardId?: unknown;
|
|
712
|
+
label?: unknown;
|
|
713
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
|
|
662
714
|
/** Generic layout guides (T5) — replace-whole-prop write. */
|
|
663
715
|
setArtboardGuidesOp(input: {
|
|
664
716
|
canvas?: unknown;
|
|
@@ -720,9 +772,39 @@ export interface Api {
|
|
|
720
772
|
}
|
|
721
773
|
|
|
722
774
|
export interface ApiHooks {
|
|
723
|
-
|
|
775
|
+
/**
|
|
776
|
+
* Publish a comments mutation to the SHARED DOC (and the shell's sidebar).
|
|
777
|
+
*
|
|
778
|
+
* `comments` is the post-mutation list, handed over IN MEMORY — issue #111.
|
|
779
|
+
* The hook used to take only `file` and re-read `_comments/<slug>.json`
|
|
780
|
+
* itself, which put a full disk round-trip between the mutation's write and
|
|
781
|
+
* the doc publish. `Room.flush()` fires on ANY doc update (every hub update
|
|
782
|
+
* re-arms it), so on a hub-linked project a flush landed inside that window,
|
|
783
|
+
* projected the PRE-mutation doc back over the file, and the new comment was
|
|
784
|
+
* gone before the hook's own read reached it — "only the first comment
|
|
785
|
+
* stays". Passing the list removes the read, and `publishComments` awaits
|
|
786
|
+
* this hook BEFORE touching disk so the doc is never the stale side.
|
|
787
|
+
*/
|
|
788
|
+
/** `base` — the list this mutation started from (a merge hint for the project). */
|
|
789
|
+
onCommentsChanged: (file: string, comments: Comment[], base?: Comment[]) => void | Promise<void>;
|
|
724
790
|
/** Phase 8 Task 5 — fires after a successful PUT /_api/annotations write. */
|
|
725
|
-
onAnnotationsChanged?: (file: string, svg: string) => void;
|
|
791
|
+
onAnnotationsChanged?: (file: string, svg: string, writeId?: string, base?: string) => void;
|
|
792
|
+
/**
|
|
793
|
+
* Accepted-revisions mode (DDR-241): propose a folder operation as ONE
|
|
794
|
+
* project action before touching disk. Absent, or answering `null`, means
|
|
795
|
+
* the project is not in that mode and the local operation is the whole story.
|
|
796
|
+
*/
|
|
797
|
+
/**
|
|
798
|
+
* A layout (shared meta) write, with the file text it replaced — accepted
|
|
799
|
+
* revisions propose it with that base instead of inferring one.
|
|
800
|
+
*/
|
|
801
|
+
onMetaChanged?: (file: string, text: string, baseText: string | null) => void;
|
|
802
|
+
proposeFolder?: (
|
|
803
|
+
op:
|
|
804
|
+
| { op: 'dir.create'; path: string }
|
|
805
|
+
| { op: 'dir.delete'; path: string }
|
|
806
|
+
| { op: 'dir.move'; from: string; to: string }
|
|
807
|
+
) => Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null;
|
|
726
808
|
/**
|
|
727
809
|
* feature-file-tree-drag-drop-folders (Task 3) — is a collab room pinned
|
|
728
810
|
* (a shared-doc hub provider attached, DDR-064)? `moveCanvas` refuses the
|
|
@@ -993,7 +1075,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
993
1075
|
// stores in its undo record. Ephemeral by design: a server restart drops it
|
|
994
1076
|
// (undo answers 404 and the canvas stack entry is a no-op, honest failure).
|
|
995
1077
|
const REORDER_LOG_CAP = 50;
|
|
996
|
-
const reorderLog = new Map<
|
|
1078
|
+
const reorderLog = new Map<
|
|
1079
|
+
number,
|
|
1080
|
+
{ abs: string; before: string; after: string; undoActionId?: string }
|
|
1081
|
+
>();
|
|
997
1082
|
let reorderSeq = 0;
|
|
998
1083
|
|
|
999
1084
|
// DDR-150 dogfood #1 — the SAME whole-file log backs the Timeline clip ops
|
|
@@ -1095,7 +1180,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1095
1180
|
// Phase 6 — default-fill `author` / `thread` / `mentions` for legacy
|
|
1096
1181
|
// rows. No write-back here; the on-disk shape stays stable until the
|
|
1097
1182
|
// next mutation persists the upgraded record.
|
|
1098
|
-
|
|
1183
|
+
//
|
|
1184
|
+
// Deduped by comment identity first (issue #112). This is the read half
|
|
1185
|
+
// of the repair: a project whose `_comments/<slug>.json` was already
|
|
1186
|
+
// doubled by the pre-fix sync lane heals the moment it is loaded, and no
|
|
1187
|
+
// consumer — pins, sidebar, `/_comments`, the /design:edit agent — ever
|
|
1188
|
+
// sees the same comment eight times. First occurrence wins, matching the
|
|
1189
|
+
// rule the codec and the cold-start union use.
|
|
1190
|
+
return dedupeCommentsById(arr).map(backfillComment);
|
|
1099
1191
|
} catch {
|
|
1100
1192
|
return [];
|
|
1101
1193
|
}
|
|
@@ -1135,7 +1227,39 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1135
1227
|
}
|
|
1136
1228
|
|
|
1137
1229
|
async function saveCommentsForFile(file: string, list: Comment[]) {
|
|
1138
|
-
|
|
1230
|
+
// The write half of the #112 repair, and the reason it belongs HERE: this
|
|
1231
|
+
// is the one choke point every writer passes through — the API mutations,
|
|
1232
|
+
// and the collab room's `persistJson`, which materializes the Y.Array
|
|
1233
|
+
// straight to disk. Deduping at the door means a doc caught mid-convergence
|
|
1234
|
+
// (or a peer still running the old wholesale write) cannot persist a
|
|
1235
|
+
// duplicated list as the canvas's new truth, which is how the doubling
|
|
1236
|
+
// survived restarts and compounded.
|
|
1237
|
+
await Bun.write(commentsPath(file), JSON.stringify(dedupeCommentsById(list), null, 2));
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* Land one comments mutation: SHARED DOC FIRST, disk second (issue #111).
|
|
1242
|
+
*
|
|
1243
|
+
* The order is the fix, not a style choice. Under DDR-064 the room's Y.Doc is
|
|
1244
|
+
* the shared object and `_comments/<slug>.json` is its projection — and
|
|
1245
|
+
* `Room.flush()` projects doc→file on an 800 ms trailing debounce that ANY
|
|
1246
|
+
* doc update re-arms, hub traffic included. So on a linked project a flush is
|
|
1247
|
+
* almost always pending, and any window in which the doc is behind the file
|
|
1248
|
+
* is a window in which that flush silently writes the older list back.
|
|
1249
|
+
*
|
|
1250
|
+
* Publishing to the doc first removes the window instead of narrowing it: a
|
|
1251
|
+
* flush firing at any point from here on carries a doc that already holds the
|
|
1252
|
+
* mutation. Both halves are awaited, so the HTTP response cannot report a
|
|
1253
|
+
* comment the doc never received.
|
|
1254
|
+
*
|
|
1255
|
+
* The list is deduped ONCE here so the doc and the file are handed byte-equal
|
|
1256
|
+
* content — otherwise the file→doc import that follows the write would see a
|
|
1257
|
+
* difference and re-enter the loop.
|
|
1258
|
+
*/
|
|
1259
|
+
async function publishComments(file: string, list: Comment[], base?: Comment[]): Promise<void> {
|
|
1260
|
+
const settled = dedupeCommentsById(list);
|
|
1261
|
+
await onCommentsChanged(file, settled, base ? dedupeCommentsById(base) : undefined);
|
|
1262
|
+
await saveCommentsForFile(file, settled);
|
|
1139
1263
|
}
|
|
1140
1264
|
|
|
1141
1265
|
async function loadAllComments(): Promise<Record<string, Comment[]>> {
|
|
@@ -1266,6 +1390,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1266
1390
|
if (!payload || typeof payload.file !== 'string' || !payload.file) return null;
|
|
1267
1391
|
if (typeof payload.text !== 'string' || !payload.text.trim()) return null;
|
|
1268
1392
|
const list = await loadCommentsForFile(payload.file);
|
|
1393
|
+
const base = structuredClone(list);
|
|
1269
1394
|
const text = String(payload.text).trim().slice(0, 4000);
|
|
1270
1395
|
const author =
|
|
1271
1396
|
typeof payload.author === 'string' && payload.author.trim()
|
|
@@ -1314,8 +1439,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1314
1439
|
if (anchor.clipStableId != null || anchor.frame != null) c.timeline = anchor;
|
|
1315
1440
|
}
|
|
1316
1441
|
list.push(c);
|
|
1317
|
-
await
|
|
1318
|
-
onCommentsChanged(payload.file);
|
|
1442
|
+
await publishComments(payload.file, list, base);
|
|
1319
1443
|
return c;
|
|
1320
1444
|
}
|
|
1321
1445
|
|
|
@@ -1330,6 +1454,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1330
1454
|
if (i < 0) continue;
|
|
1331
1455
|
const entry = list[i];
|
|
1332
1456
|
if (!entry) continue;
|
|
1457
|
+
const base = structuredClone(list);
|
|
1333
1458
|
const body = payload.body.trim().slice(0, 4000);
|
|
1334
1459
|
const author =
|
|
1335
1460
|
typeof payload.author === 'string' && payload.author.trim()
|
|
@@ -1343,31 +1468,39 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1343
1468
|
};
|
|
1344
1469
|
entry.thread = [...entry.thread, reply];
|
|
1345
1470
|
entry.mentions = mentionsUnion(entry);
|
|
1346
|
-
await
|
|
1347
|
-
onCommentsChanged(file);
|
|
1471
|
+
await publishComments(file, list, base);
|
|
1348
1472
|
return entry;
|
|
1349
1473
|
}
|
|
1350
1474
|
return null;
|
|
1351
1475
|
}
|
|
1352
1476
|
|
|
1477
|
+
// Mutations are ID-TOTAL, not first-match (issue #112). `loadAllComments`
|
|
1478
|
+
// dedupes, so under normal operation there is exactly one entry per id and
|
|
1479
|
+
// these behave as they always did. They stay total as defense in depth: the
|
|
1480
|
+
// reporter's second symptom — "I can't then close or resolve the comments" —
|
|
1481
|
+
// was a `findIndex` + `return` marking copy 1 of 8 resolved while the overlay
|
|
1482
|
+
// (which filters `status !== 'resolved'`) kept drawing the other seven, and a
|
|
1483
|
+
// delete removing one copy per click. A duplicated list can still reach these
|
|
1484
|
+
// from an older peer mid-upgrade; resolve must resolve either way.
|
|
1353
1485
|
async function commentsPatch(id: string, patch: Partial<Comment>) {
|
|
1354
1486
|
const all = await loadAllComments();
|
|
1355
1487
|
for (const [file, list] of Object.entries(all)) {
|
|
1356
|
-
const
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1488
|
+
const matches = list.filter((c) => c.id === id);
|
|
1489
|
+
const first = matches[0];
|
|
1490
|
+
if (!first) continue;
|
|
1491
|
+
const base = structuredClone(list);
|
|
1492
|
+
for (const entry of matches) {
|
|
1493
|
+
if (patch.status === 'resolved' || patch.status === 'open') {
|
|
1494
|
+
entry.status = patch.status;
|
|
1495
|
+
entry.resolved_at = patch.status === 'resolved' ? new Date().toISOString() : null;
|
|
1496
|
+
}
|
|
1497
|
+
if (typeof patch.text === 'string' && patch.text.trim()) {
|
|
1498
|
+
entry.text = patch.text.trim().slice(0, 4000);
|
|
1499
|
+
entry.mentions = mentionsUnion(entry);
|
|
1500
|
+
}
|
|
1367
1501
|
}
|
|
1368
|
-
await
|
|
1369
|
-
|
|
1370
|
-
return entry;
|
|
1502
|
+
await publishComments(file, list, base);
|
|
1503
|
+
return first;
|
|
1371
1504
|
}
|
|
1372
1505
|
return null;
|
|
1373
1506
|
}
|
|
@@ -1375,11 +1508,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1375
1508
|
async function commentsDelete(id: string): Promise<boolean> {
|
|
1376
1509
|
const all = await loadAllComments();
|
|
1377
1510
|
for (const [file, list] of Object.entries(all)) {
|
|
1378
|
-
const
|
|
1379
|
-
if (
|
|
1380
|
-
|
|
1381
|
-
await saveCommentsForFile(file, list);
|
|
1382
|
-
onCommentsChanged(file);
|
|
1511
|
+
const remaining = list.filter((c) => c.id !== id);
|
|
1512
|
+
if (remaining.length === list.length) continue;
|
|
1513
|
+
await publishComments(file, remaining, list);
|
|
1383
1514
|
return true;
|
|
1384
1515
|
}
|
|
1385
1516
|
return false;
|
|
@@ -1887,7 +2018,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1887
2018
|
next.last_modified = new Date().toISOString();
|
|
1888
2019
|
// Trailing newline — consistent with canvas-create.ts + sync/codec.ts
|
|
1889
2020
|
// (mergeSharedMetaIntoLocal), so a layout edit doesn't churn the newline.
|
|
1890
|
-
|
|
2021
|
+
const baseText = Object.keys(current).length ? JSON.stringify(current) : null;
|
|
2022
|
+
const nextText = `${JSON.stringify(next, null, 2)}\n`;
|
|
2023
|
+
await Bun.write(metaAbs, nextText);
|
|
2024
|
+
hooks.onMetaChanged?.(file, nextText, baseText);
|
|
1891
2025
|
// Same reason as the annotations sidecar below, and the same bug: this
|
|
1892
2026
|
// lane writes the FILE and nothing else, so in a cell — where there is no
|
|
1893
2027
|
// `fs.watch` — the layout never entered the doc and never reached a peer.
|
|
@@ -1927,7 +2061,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1927
2061
|
}
|
|
1928
2062
|
}
|
|
1929
2063
|
|
|
1930
|
-
async function saveAnnotations(
|
|
2064
|
+
async function saveAnnotations(
|
|
2065
|
+
file: string,
|
|
2066
|
+
svg: string,
|
|
2067
|
+
writeId?: string,
|
|
2068
|
+
base?: string
|
|
2069
|
+
): Promise<boolean> {
|
|
1931
2070
|
if (typeof svg !== 'string') return false;
|
|
1932
2071
|
if (svg.length > 1024 * 1024) return false;
|
|
1933
2072
|
// Cheap content gate — must look like an <svg> document. Avoids accidental
|
|
@@ -1945,8 +2084,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1945
2084
|
// (strokesToSvg) is purely presentational — path/rect/ellipse/g/line/
|
|
1946
2085
|
// polyline/text — so stripping executable constructs is zero-regression.
|
|
1947
2086
|
const clean = sanitizeAnnotationSvg(svg);
|
|
2087
|
+
// The edit's base travels only as a merge hint for the project — bounded
|
|
2088
|
+
// and sanitized like the value itself, never written anywhere.
|
|
2089
|
+
const cleanBase =
|
|
2090
|
+
typeof base === 'string' &&
|
|
2091
|
+
base.length <= 1024 * 1024 &&
|
|
2092
|
+
(base === '' || /^\s*<svg[\s>]/i.test(base))
|
|
2093
|
+
? base === ''
|
|
2094
|
+
? ''
|
|
2095
|
+
: sanitizeAnnotationSvg(base)
|
|
2096
|
+
: undefined;
|
|
1948
2097
|
await Bun.write(annotationsPath(file), clean);
|
|
1949
|
-
onAnnotationsChanged?.(file, clean);
|
|
2098
|
+
onAnnotationsChanged?.(file, clean, writeId, cleanBase);
|
|
1950
2099
|
// Annotations reach OTHER VIEWERS over the collab room, which is why this
|
|
1951
2100
|
// never needed an `fs:any`. But the file is also a versioned, file-plane
|
|
1952
2101
|
// sidecar (DDR-115), and the file plane learns about a cell's own writes
|
|
@@ -1957,6 +2106,32 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1957
2106
|
return true;
|
|
1958
2107
|
}
|
|
1959
2108
|
|
|
2109
|
+
async function projectAnnotations(
|
|
2110
|
+
file: string,
|
|
2111
|
+
svg: string,
|
|
2112
|
+
isCurrent: () => boolean
|
|
2113
|
+
): Promise<boolean> {
|
|
2114
|
+
if (typeof svg !== 'string' || svg.length > 1024 * 1024 || !/^\s*<svg[\s>]/i.test(svg))
|
|
2115
|
+
return false;
|
|
2116
|
+
if (!isCurrent()) return false;
|
|
2117
|
+
const clean = sanitizeAnnotationSvg(svg);
|
|
2118
|
+
// Runtime scratch stays out of the file plane. The async IO must not touch
|
|
2119
|
+
// the serving file until we recheck the document; another edit may have
|
|
2120
|
+
// arrived while Bun.write was pending. Check + rename have no await gap.
|
|
2121
|
+
const scratch = path.join(paths.designRoot, '_state');
|
|
2122
|
+
await mkdir(scratch, { recursive: true });
|
|
2123
|
+
const temp = path.join(scratch, `annotations-${crypto.randomUUID()}.tmp`);
|
|
2124
|
+
try {
|
|
2125
|
+
await Bun.write(temp, clean);
|
|
2126
|
+
if (!isCurrent()) return false;
|
|
2127
|
+
renameSync(temp, annotationsPath(file));
|
|
2128
|
+
announceWritten(`${fileSlug(file)}.annotations.svg`);
|
|
2129
|
+
return true;
|
|
2130
|
+
} finally {
|
|
2131
|
+
await rm(temp, { force: true });
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
|
|
1960
2135
|
// Phase 23 — content-addressed asset write. Reachable from the (potentially
|
|
1961
2136
|
// untrusted, DDR-054) canvas origin, so every cap is load-bearing, NOT
|
|
1962
2137
|
// optional (DDR Task 9):
|
|
@@ -2695,6 +2870,91 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2695
2870
|
return { ok: true, file: path.posix.join(paths.designRel, rel), rel, slug };
|
|
2696
2871
|
}
|
|
2697
2872
|
|
|
2873
|
+
async function duplicateCanvas(input: { file?: unknown }): Promise<CreateCanvasResult> {
|
|
2874
|
+
const raw = input?.file;
|
|
2875
|
+
if (typeof raw !== 'string' || !raw.trim()) {
|
|
2876
|
+
return { ok: false, status: 400, error: 'file is required' };
|
|
2877
|
+
}
|
|
2878
|
+
let rel = raw.trim();
|
|
2879
|
+
try {
|
|
2880
|
+
rel = decodeURIComponent(rel);
|
|
2881
|
+
} catch {
|
|
2882
|
+
/* leave as-is */
|
|
2883
|
+
}
|
|
2884
|
+
rel = rel.replace(/^\/+/, '');
|
|
2885
|
+
const drPrefix = paths.designRel.replace(/^\/+|\/+$/g, '');
|
|
2886
|
+
if (rel.startsWith(`${drPrefix}/`)) rel = rel.slice(drPrefix.length + 1);
|
|
2887
|
+
if (rel.includes('..') || !/\.tsx$/i.test(rel)) {
|
|
2888
|
+
return { ok: false, status: 400, error: 'invalid path' };
|
|
2889
|
+
}
|
|
2890
|
+
const fileAbs = path.resolve(path.join(paths.designRoot, rel));
|
|
2891
|
+
const dirAbs = path.dirname(fileAbs);
|
|
2892
|
+
const groups = cfg.canvasGroups.filter(
|
|
2893
|
+
(g) => g.label !== 'Design system' && !/^system(\/|$)/.test(g.path)
|
|
2894
|
+
);
|
|
2895
|
+
const inGroup = groups.some((g) => {
|
|
2896
|
+
const gAbs = path.resolve(path.join(paths.designRoot, g.path));
|
|
2897
|
+
return dirAbs === gAbs || dirAbs.startsWith(`${gAbs}${path.sep}`);
|
|
2898
|
+
});
|
|
2899
|
+
if (!inGroup) {
|
|
2900
|
+
return {
|
|
2901
|
+
ok: false,
|
|
2902
|
+
status: 400,
|
|
2903
|
+
error: 'only canvases under a managed canvas group can be duplicated',
|
|
2904
|
+
};
|
|
2905
|
+
}
|
|
2906
|
+
if (!(await assertRealpathContained(dirAbs))) {
|
|
2907
|
+
return { ok: false, status: 400, error: 'source path escapes the design root via a symlink' };
|
|
2908
|
+
}
|
|
2909
|
+
if (!(await Bun.file(fileAbs).exists())) {
|
|
2910
|
+
return { ok: false, status: 404, error: 'canvas not found' };
|
|
2911
|
+
}
|
|
2912
|
+
const base = path.basename(rel).replace(/\.tsx$/i, '');
|
|
2913
|
+
const dir = path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel);
|
|
2914
|
+
// "<name> copy", then "<name> copy 2", … — the first free, valid name.
|
|
2915
|
+
let name: string | null = null;
|
|
2916
|
+
for (let n = 1; n < 100 && !name; n++) {
|
|
2917
|
+
const suffix = n === 1 ? ' copy' : ` copy ${n}`;
|
|
2918
|
+
const v = validateCanvasName(`${base.slice(0, 60 - suffix.length)}${suffix}`);
|
|
2919
|
+
if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
|
|
2920
|
+
const candidate = path.posix.join(dir, `${v.name}.tsx`);
|
|
2921
|
+
if (await Bun.file(path.join(paths.designRoot, candidate)).exists()) continue;
|
|
2922
|
+
if (await fileForSlug(fileSlug(candidate))) continue;
|
|
2923
|
+
name = v.name;
|
|
2924
|
+
}
|
|
2925
|
+
if (!name) return { ok: false, status: 409, error: 'too many copies of this canvas' };
|
|
2926
|
+
const toRel = path.posix.join(dir, `${name}.tsx`);
|
|
2927
|
+
const toAbs = path.join(paths.designRoot, toRel);
|
|
2928
|
+
await Bun.write(toAbs, await Bun.file(fileAbs).arrayBuffer());
|
|
2929
|
+
const metaAbs = fileAbs.replace(/\.tsx$/i, '.meta.json');
|
|
2930
|
+
if (await Bun.file(metaAbs).exists()) {
|
|
2931
|
+
let text = await Bun.file(metaAbs).text();
|
|
2932
|
+
try {
|
|
2933
|
+
const meta = JSON.parse(text) as Record<string, unknown>;
|
|
2934
|
+
const now = new Date().toISOString();
|
|
2935
|
+
if (typeof meta.title !== 'string' || meta.title === base) meta.title = name;
|
|
2936
|
+
meta.created = now;
|
|
2937
|
+
meta.last_modified = now;
|
|
2938
|
+
text = `${JSON.stringify(meta, null, 2)}\n`;
|
|
2939
|
+
} catch {
|
|
2940
|
+
/* an unreadable meta is copied verbatim, like the source */
|
|
2941
|
+
}
|
|
2942
|
+
await Bun.write(toAbs.replace(/\.tsx$/i, '.meta.json'), text);
|
|
2943
|
+
}
|
|
2944
|
+
const slug = fileSlug(toRel);
|
|
2945
|
+
// The whiteboard layer is slug-keyed at the design root (canvas-artifacts).
|
|
2946
|
+
const annotationsAbs = path.join(paths.designRoot, `${fileSlug(rel)}.annotations.svg`);
|
|
2947
|
+
if (await Bun.file(annotationsAbs).exists()) {
|
|
2948
|
+
await Bun.write(
|
|
2949
|
+
path.join(paths.designRoot, `${slug}.annotations.svg`),
|
|
2950
|
+
await Bun.file(annotationsAbs).arrayBuffer()
|
|
2951
|
+
);
|
|
2952
|
+
}
|
|
2953
|
+
ctx.bus.emit('canvas-list-update', { action: 'added', rel: toRel, slug });
|
|
2954
|
+
ctx.bus.emit('canvas-created', { slug });
|
|
2955
|
+
return { ok: true, file: path.posix.join(paths.designRel, toRel), rel: toRel, slug };
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2698
2958
|
// Phase 22 — SOFT-delete a canvas (DELETE /_api/canvas). Same trust boundary as
|
|
2699
2959
|
// createCanvas: main-origin-only, never the untrusted canvas iframe origin
|
|
2700
2960
|
// (DDR-054). Destructive, so it MOVES the whole sidecar set to
|
|
@@ -2718,6 +2978,13 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2718
2978
|
|
|
2719
2979
|
// Only a real `.tsx` canvas, no traversal.
|
|
2720
2980
|
if (rel.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
|
|
2981
|
+
if (
|
|
2982
|
+
!/\.tsx$/i.test(rel) &&
|
|
2983
|
+
isSupportingFileRel(rel) &&
|
|
2984
|
+
(await isRegularFile(path.join(paths.designRoot, rel)))
|
|
2985
|
+
) {
|
|
2986
|
+
return deleteSupportingFile(rel);
|
|
2987
|
+
}
|
|
2721
2988
|
if (!/\.tsx$/i.test(rel)) {
|
|
2722
2989
|
// feature-file-tree-drag-drop-folders (follow-up) — a non-.tsx target is
|
|
2723
2990
|
// a folder delete (dogfood gap: the tree offered no way to remove a
|
|
@@ -2827,6 +3094,170 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2827
3094
|
// itself (same `_trash/<stamp>__<slug>/` bundle shape, so a folder delete
|
|
2828
3095
|
// is recoverable exactly like a single-canvas delete), then best-effort
|
|
2829
3096
|
// removes whatever's left (empty subdirs, `.gitkeep` markers).
|
|
3097
|
+
// ── Plan T17/L03 — supporting files ──────────────────────────────────────
|
|
3098
|
+
// Notes, styles, data, images, media and fonts that live in a canvas folder
|
|
3099
|
+
// beside the canvases. They move, rename and delete as themselves. One that a
|
|
3100
|
+
// canvas, stylesheet or whiteboard still names is refused with the name of
|
|
3101
|
+
// the file that uses it: a moved or deleted asset must never leave a broken
|
|
3102
|
+
// image behind silently (L17).
|
|
3103
|
+
async function isRegularFile(abs: string): Promise<boolean> {
|
|
3104
|
+
try {
|
|
3105
|
+
return (await statp(abs)).isFile();
|
|
3106
|
+
} catch {
|
|
3107
|
+
return false;
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
async function supportingFileGuard(
|
|
3112
|
+
rel: string
|
|
3113
|
+
): Promise<{ ok: true; abs: string } | { ok: false; status: number; error: string }> {
|
|
3114
|
+
if (rel.includes('..') || rel.startsWith('/') || isRuntimeStateRel(rel)) {
|
|
3115
|
+
return { ok: false, status: 400, error: 'invalid path' };
|
|
3116
|
+
}
|
|
3117
|
+
const abs = path.resolve(path.join(paths.designRoot, rel));
|
|
3118
|
+
const groups = cfg.canvasGroups.filter(
|
|
3119
|
+
(g) => g.label !== 'Design system' && !/^system(\/|$)/.test(g.path)
|
|
3120
|
+
);
|
|
3121
|
+
const inGroup = groups.some((g) =>
|
|
3122
|
+
abs.startsWith(`${path.resolve(path.join(paths.designRoot, g.path))}${path.sep}`)
|
|
3123
|
+
);
|
|
3124
|
+
if (!inGroup) {
|
|
3125
|
+
return {
|
|
3126
|
+
ok: false,
|
|
3127
|
+
status: 400,
|
|
3128
|
+
error: 'only files inside a canvas folder can be changed here',
|
|
3129
|
+
};
|
|
3130
|
+
}
|
|
3131
|
+
if (!(await assertRealpathContained(path.dirname(abs)))) {
|
|
3132
|
+
return { ok: false, status: 400, error: 'path escapes the design root via a symlink' };
|
|
3133
|
+
}
|
|
3134
|
+
if (!(await isRegularFile(abs))) return { ok: false, status: 404, error: 'file not found' };
|
|
3135
|
+
return { ok: true, abs };
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
/** The first project file (canvas, stylesheet, meta, whiteboard) that names `rel`'s file. */
|
|
3139
|
+
async function firstReferenceTo(rel: string): Promise<string | null> {
|
|
3140
|
+
const base = path.posix.basename(rel);
|
|
3141
|
+
const { readdir, readFile } = await import('node:fs/promises');
|
|
3142
|
+
const walk = async (dirAbs: string, depth: number): Promise<string | null> => {
|
|
3143
|
+
if (depth > 12) return null;
|
|
3144
|
+
let entries: import('node:fs').Dirent[];
|
|
3145
|
+
try {
|
|
3146
|
+
entries = await readdir(dirAbs, { withFileTypes: true });
|
|
3147
|
+
} catch {
|
|
3148
|
+
return null;
|
|
3149
|
+
}
|
|
3150
|
+
for (const e of entries) {
|
|
3151
|
+
if (e.name.startsWith('_') || e.name.startsWith('.') || e.name === 'node_modules') continue;
|
|
3152
|
+
const abs = path.join(dirAbs, e.name);
|
|
3153
|
+
if (e.isDirectory()) {
|
|
3154
|
+
const hit = await walk(abs, depth + 1);
|
|
3155
|
+
if (hit) return hit;
|
|
3156
|
+
} else if (/\.(tsx|jsx|css|meta\.json|annotations\.svg)$/i.test(e.name)) {
|
|
3157
|
+
const other = path.relative(paths.designRoot, abs).split(path.sep).join('/');
|
|
3158
|
+
if (other === rel) continue;
|
|
3159
|
+
const text = await readFile(abs, 'utf8').catch(() => '');
|
|
3160
|
+
if (text.includes(base)) return other;
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
return null;
|
|
3164
|
+
};
|
|
3165
|
+
return walk(paths.designRoot, 0);
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
async function moveSupportingFile(
|
|
3169
|
+
rel: string,
|
|
3170
|
+
toDirRaw: unknown,
|
|
3171
|
+
toNameRaw: unknown
|
|
3172
|
+
): Promise<MoveCanvasResult> {
|
|
3173
|
+
const g = await supportingFileGuard(rel);
|
|
3174
|
+
if (!g.ok) return g;
|
|
3175
|
+
const ext = path.posix.extname(rel);
|
|
3176
|
+
let base = path.posix.basename(rel);
|
|
3177
|
+
if (toNameRaw !== undefined && toNameRaw !== null && toNameRaw !== '') {
|
|
3178
|
+
const stem = String(toNameRaw).replace(new RegExp(`${ext.replace('.', '\\.')}$`, 'i'), '');
|
|
3179
|
+
const v = validateCanvasName(stem);
|
|
3180
|
+
if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
|
|
3181
|
+
base = `${v.name}${ext}`;
|
|
3182
|
+
}
|
|
3183
|
+
const drPrefix = paths.designRel.replace(/^\/+|\/+$/g, '');
|
|
3184
|
+
let toDir =
|
|
3185
|
+
typeof toDirRaw === 'string'
|
|
3186
|
+
? toDirRaw.trim().replace(/^\/+|\/+$/g, '')
|
|
3187
|
+
: path.posix.dirname(rel);
|
|
3188
|
+
if (toDir === drPrefix || toDir === '.') toDir = '';
|
|
3189
|
+
else if (toDir.startsWith(`${drPrefix}/`)) toDir = toDir.slice(drPrefix.length + 1);
|
|
3190
|
+
const toRel = path.posix.join(toDir, base);
|
|
3191
|
+
if (toRel === rel)
|
|
3192
|
+
return { ok: false, status: 400, error: 'source and destination are the same' };
|
|
3193
|
+
const dest = await supportingFileGuard(toRel).then((d) =>
|
|
3194
|
+
d.ok
|
|
3195
|
+
? { ok: false as const, status: 409, error: `a file named "${base}" already exists there` }
|
|
3196
|
+
: d
|
|
3197
|
+
);
|
|
3198
|
+
if (!dest.ok && dest.status !== 404) return dest;
|
|
3199
|
+
const toAbs = path.resolve(path.join(paths.designRoot, toRel));
|
|
3200
|
+
if (!(await isRegularFile(path.join(paths.designRoot, rel))))
|
|
3201
|
+
return { ok: false, status: 404, error: 'file not found' };
|
|
3202
|
+
const toDirAbs = path.dirname(toAbs);
|
|
3203
|
+
try {
|
|
3204
|
+
if (!(await statp(toDirAbs)).isDirectory()) throw new Error('not a folder');
|
|
3205
|
+
} catch {
|
|
3206
|
+
return { ok: false, status: 400, error: 'destination folder does not exist' };
|
|
3207
|
+
}
|
|
3208
|
+
const user = await firstReferenceTo(rel);
|
|
3209
|
+
if (user) {
|
|
3210
|
+
return {
|
|
3211
|
+
ok: false,
|
|
3212
|
+
status: 409,
|
|
3213
|
+
error: `${path.posix.basename(rel)} is used by ${user} — change that first`,
|
|
3214
|
+
};
|
|
3215
|
+
}
|
|
3216
|
+
await rename(g.abs, toAbs);
|
|
3217
|
+
ctx.bus.emit('canvas-list-update', {
|
|
3218
|
+
action: 'moved',
|
|
3219
|
+
rel: toRel,
|
|
3220
|
+
slug: fileSlug(toRel),
|
|
3221
|
+
fromRel: rel,
|
|
3222
|
+
fromSlug: fileSlug(rel),
|
|
3223
|
+
});
|
|
3224
|
+
return {
|
|
3225
|
+
ok: true,
|
|
3226
|
+
fromRel: rel,
|
|
3227
|
+
toRel,
|
|
3228
|
+
fromSlug: fileSlug(rel),
|
|
3229
|
+
toSlug: fileSlug(toRel),
|
|
3230
|
+
moved: [toRel],
|
|
3231
|
+
};
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
async function deleteSupportingFile(rel: string): Promise<DeleteCanvasResult> {
|
|
3235
|
+
const g = await supportingFileGuard(rel);
|
|
3236
|
+
if (!g.ok) return g;
|
|
3237
|
+
const user = await firstReferenceTo(rel);
|
|
3238
|
+
if (user) {
|
|
3239
|
+
return {
|
|
3240
|
+
ok: false,
|
|
3241
|
+
status: 409,
|
|
3242
|
+
error: `${path.posix.basename(rel)} is used by ${user} — remove it there first`,
|
|
3243
|
+
};
|
|
3244
|
+
}
|
|
3245
|
+
const slug = fileSlug(rel);
|
|
3246
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
3247
|
+
const trashDir = path.join(paths.designRoot, '_trash', `${stamp}__${slug}`);
|
|
3248
|
+
await mkdir(trashDir, { recursive: true });
|
|
3249
|
+
const trashed = path.join(trashDir, path.posix.basename(rel));
|
|
3250
|
+
await rename(g.abs, trashed);
|
|
3251
|
+
ctx.bus.emit('canvas-list-update', { action: 'removed', rel, slug });
|
|
3252
|
+
return {
|
|
3253
|
+
ok: true,
|
|
3254
|
+
rel,
|
|
3255
|
+
slug,
|
|
3256
|
+
trashed: [path.relative(paths.repoRoot, trashed)],
|
|
3257
|
+
trashDir: path.relative(paths.repoRoot, trashDir),
|
|
3258
|
+
};
|
|
3259
|
+
}
|
|
3260
|
+
|
|
2830
3261
|
async function deleteFolder(relDir: string): Promise<DeleteCanvasResult> {
|
|
2831
3262
|
if (relDir.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
|
|
2832
3263
|
const dirAbs = path.join(paths.designRoot, relDir);
|
|
@@ -2883,6 +3314,21 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2883
3314
|
p.startsWith(`${drPrefix}/`) ? p.slice(drPrefix.length + 1) : p
|
|
2884
3315
|
);
|
|
2885
3316
|
|
|
3317
|
+
// ONE project action for the whole folder (accepted-revisions mode): the
|
|
3318
|
+
// hub deletes every canvas under it and the folder entry together, so a
|
|
3319
|
+
// peer never sees half a folder, and history shows one step.
|
|
3320
|
+
const folderAction = await hooks.proposeFolder?.({
|
|
3321
|
+
op: 'dir.delete',
|
|
3322
|
+
path: relDir.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''),
|
|
3323
|
+
});
|
|
3324
|
+
if (folderAction?.status === 'rejected') {
|
|
3325
|
+
return {
|
|
3326
|
+
ok: false,
|
|
3327
|
+
status: 409,
|
|
3328
|
+
error: `the project did not accept deleting this folder (${folderAction.code ?? 'rejected'})`,
|
|
3329
|
+
};
|
|
3330
|
+
}
|
|
3331
|
+
|
|
2886
3332
|
const trashed: string[] = [];
|
|
2887
3333
|
let lastTrashDir = '';
|
|
2888
3334
|
for (const r of canvasRels) {
|
|
@@ -2946,6 +3392,44 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2946
3392
|
return realTarget === realRoot || realTarget.startsWith(`${realRoot}${path.sep}`);
|
|
2947
3393
|
}
|
|
2948
3394
|
|
|
3395
|
+
/**
|
|
3396
|
+
* Re-root a moved canvas's relative imports for its new folder depth
|
|
3397
|
+
* (issue #114). `fromDir` / `toDir` are POSIX, design-root-relative.
|
|
3398
|
+
*
|
|
3399
|
+
* Runs AFTER the rename, on the file at its destination. Best-effort by
|
|
3400
|
+
* design: the move has already committed, and failing it here would leave a
|
|
3401
|
+
* half-moved tree — strictly worse than the un-rewritten imports that were
|
|
3402
|
+
* the status quo. A no-op rewrite writes nothing.
|
|
3403
|
+
*/
|
|
3404
|
+
async function rewriteCanvasImports(abs: string, fromDir: string, toDir: string): Promise<void> {
|
|
3405
|
+
if (fromDir === toDir) return;
|
|
3406
|
+
try {
|
|
3407
|
+
// NEVER DEREFERENCE A SYMLINK HERE. The move's containment checks cover
|
|
3408
|
+
// the source *directory* (`assertRealpathContained(path.dirname(...))`)
|
|
3409
|
+
// and the destination, but never the source FILE — which was inert while
|
|
3410
|
+
// the move was a bare `rename()`, because renaming a symlink moves the
|
|
3411
|
+
// LINK and no out-of-root byte is ever touched. This function is the
|
|
3412
|
+
// first code on the path that reads and writes the file's contents, so
|
|
3413
|
+
// without this guard a planted `ui/evil.tsx -> /elsewhere/secrets.ts`
|
|
3414
|
+
// would turn an ordinary drag-and-drop into an out-of-root read AND an
|
|
3415
|
+
// out-of-root write. `lstat` does not follow; a link is skipped, not
|
|
3416
|
+
// repaired — its target is not ours to rewrite.
|
|
3417
|
+
const st = await lstat(abs);
|
|
3418
|
+
if (!st.isFile()) return;
|
|
3419
|
+
const source = await readFile(abs, 'utf8');
|
|
3420
|
+
const next = rewriteRelativeImports(source, fromDir, toDir);
|
|
3421
|
+
// atomicWrite, not `writeFile` — this lands on a `.tsx` the fs-mirror is
|
|
3422
|
+
// watching, so a truncate-then-write can surface a partial-content watch
|
|
3423
|
+
// event (the exact hazard atomic-write.ts was built for), and a crash
|
|
3424
|
+
// mid-write would leave a truncated canvas.
|
|
3425
|
+
if (next !== source) atomicWrite(abs, next);
|
|
3426
|
+
} catch (err) {
|
|
3427
|
+
console.warn(
|
|
3428
|
+
`[move] could not rewrite relative imports in ${abs}: ${(err as Error).message}`
|
|
3429
|
+
);
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
|
|
2949
3433
|
// feature-file-tree-drag-drop-folders (Task 3) — move/rename a canvas + its
|
|
2950
3434
|
// full artifact set (POST /_api/fs-move). Same main-origin-only trust
|
|
2951
3435
|
// boundary as createCanvas/deleteCanvas (DDR-054): never reachable from the
|
|
@@ -2954,7 +3438,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2954
3438
|
// never wrong about where the canvas lives) and every relocation is logged
|
|
2955
3439
|
// to `_history/<toSlug>/_move.json` for forensic recovery. See the DDR
|
|
2956
3440
|
// (Task 14) for the accepted-limitation writeup.
|
|
2957
|
-
async function moveCanvas(input: {
|
|
3441
|
+
async function moveCanvas(input: {
|
|
3442
|
+
file?: unknown;
|
|
3443
|
+
toDir?: unknown;
|
|
3444
|
+
toName?: unknown;
|
|
3445
|
+
}): Promise<MoveCanvasResult> {
|
|
2958
3446
|
const raw = input?.file;
|
|
2959
3447
|
if (typeof raw !== 'string' || !raw.trim()) {
|
|
2960
3448
|
return { ok: false, status: 400, error: 'file is required' };
|
|
@@ -2970,17 +3458,38 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2970
3458
|
if (rel.startsWith(`${drPrefix}/`)) rel = rel.slice(drPrefix.length + 1);
|
|
2971
3459
|
if (rel.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
|
|
2972
3460
|
if (!/\.tsx$/i.test(rel)) {
|
|
3461
|
+
// Plan T17/L03 — a supporting file (notes, styles, images, media in a
|
|
3462
|
+
// canvas folder) moves or renames as itself, never as a "folder".
|
|
3463
|
+
if (isSupportingFileRel(rel) && (await isRegularFile(path.join(paths.designRoot, rel)))) {
|
|
3464
|
+
return moveSupportingFile(rel, input?.toDir, input?.toName);
|
|
3465
|
+
}
|
|
2973
3466
|
// feature-file-tree-drag-drop-folders (Task 11) — a non-.tsx source is a
|
|
2974
3467
|
// folder move (dragging a folder onto a folder). moveFolder does its own
|
|
2975
3468
|
// existence + containment validation, so an invalid path still reports
|
|
2976
3469
|
// the right error from there.
|
|
2977
|
-
return moveFolder(rel, input?.toDir);
|
|
2978
|
-
}
|
|
2979
|
-
|
|
2980
|
-
|
|
3470
|
+
return moveFolder(rel, input?.toDir, input?.toName);
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
// A rename is a move in place under a new name (`toName`, same allowlist as
|
|
3474
|
+
// a new canvas's name); `toDir` defaults to the canvas's own folder then.
|
|
3475
|
+
let newBase: string | null = null;
|
|
3476
|
+
if (input?.toName !== undefined && input.toName !== null && input.toName !== '') {
|
|
3477
|
+
const v = validateCanvasName(input.toName);
|
|
3478
|
+
if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
|
|
3479
|
+
newBase = `${v.name}.tsx`;
|
|
3480
|
+
}
|
|
3481
|
+
const toDirInput =
|
|
3482
|
+
typeof input?.toDir === 'string'
|
|
3483
|
+
? input.toDir
|
|
3484
|
+
: newBase
|
|
3485
|
+
? path.posix.dirname(rel) === '.'
|
|
3486
|
+
? ''
|
|
3487
|
+
: path.posix.dirname(rel)
|
|
3488
|
+
: null;
|
|
3489
|
+
if (typeof toDirInput !== 'string') {
|
|
2981
3490
|
return { ok: false, status: 400, error: 'toDir is required' };
|
|
2982
3491
|
}
|
|
2983
|
-
let toDir =
|
|
3492
|
+
let toDir = toDirInput.trim().replace(/^\/+|\/+$/g, '');
|
|
2984
3493
|
try {
|
|
2985
3494
|
toDir = decodeURIComponent(toDir);
|
|
2986
3495
|
} catch {
|
|
@@ -3048,7 +3557,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3048
3557
|
return { ok: false, status: 404, error: 'canvas not found' };
|
|
3049
3558
|
}
|
|
3050
3559
|
|
|
3051
|
-
const base = path.basename(rel);
|
|
3560
|
+
const base = newBase ?? path.basename(rel);
|
|
3052
3561
|
const toRel = path.posix.join(toDir, base);
|
|
3053
3562
|
if (path.posix.normalize(toRel) === path.posix.normalize(rel)) {
|
|
3054
3563
|
return { ok: false, status: 400, error: 'source and destination are the same' };
|
|
@@ -3124,6 +3633,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3124
3633
|
return { ok: false, status: 500, error: err instanceof Error ? err.message : 'move failed' };
|
|
3125
3634
|
}
|
|
3126
3635
|
|
|
3636
|
+
// The canvas is at a new depth now, so its relative specifiers point at the
|
|
3637
|
+
// wrong place (issue #114). Bytes used to move without them, and the canvas
|
|
3638
|
+
// 500s on the next build with `Could not resolve`. Best-effort: the move
|
|
3639
|
+
// itself already succeeded, and a rewrite failure must not leave the tree
|
|
3640
|
+
// half-moved — it leaves a build error the user can fix by hand, which is
|
|
3641
|
+
// exactly where they were before this existed.
|
|
3642
|
+
await rewriteCanvasImports(toAbs, path.posix.dirname(rel), path.posix.dirname(toRel));
|
|
3643
|
+
|
|
3127
3644
|
const moved: string[] = [path.relative(paths.repoRoot, toAbs)];
|
|
3128
3645
|
for (const artifact of canvasArtifacts({ rel, paths })) {
|
|
3129
3646
|
if (artifact.kind === 'primary') continue; // already moved above
|
|
@@ -3208,7 +3725,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3208
3725
|
// nested canvas at once (they live inside the moved directory); only the
|
|
3209
3726
|
// slug-keyed sidecars (flat dirs like `_history/<slug>/`, not nested by
|
|
3210
3727
|
// folder structure) need their own per-canvas relocation afterward.
|
|
3211
|
-
async function moveFolder(
|
|
3728
|
+
async function moveFolder(
|
|
3729
|
+
relDir: string,
|
|
3730
|
+
toDirRaw: unknown,
|
|
3731
|
+
toNameRaw?: unknown
|
|
3732
|
+
): Promise<MoveCanvasResult> {
|
|
3212
3733
|
if (relDir.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
|
|
3213
3734
|
const dirAbs = path.join(paths.designRoot, relDir);
|
|
3214
3735
|
const resolvedDesignRoot = path.resolve(paths.designRoot);
|
|
@@ -3284,7 +3805,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3284
3805
|
return { ok: false, status: 400, error: 'cannot move a folder into itself' };
|
|
3285
3806
|
}
|
|
3286
3807
|
|
|
3287
|
-
|
|
3808
|
+
// A RENAME is a move to a new name in the same (or another) parent — one
|
|
3809
|
+
// manifest action (dir.move), the same as any folder move (plan T17).
|
|
3810
|
+
let base = path.basename(relDir);
|
|
3811
|
+
if (toNameRaw !== undefined && toNameRaw !== null && toNameRaw !== '') {
|
|
3812
|
+
const v = validateFolderName(toNameRaw);
|
|
3813
|
+
if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
|
|
3814
|
+
base = v.name;
|
|
3815
|
+
}
|
|
3288
3816
|
const toRelDir = path.posix.join(toDir, base);
|
|
3289
3817
|
if (path.posix.normalize(toRelDir) === path.posix.normalize(relDir)) {
|
|
3290
3818
|
return { ok: false, status: 400, error: 'source and destination are the same' };
|
|
@@ -3338,6 +3866,22 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3338
3866
|
}
|
|
3339
3867
|
}
|
|
3340
3868
|
|
|
3869
|
+
// ONE project action for the whole folder (accepted-revisions mode) —
|
|
3870
|
+
// canvases, empty sub-folders and all. The per-canvas retire below then
|
|
3871
|
+
// only lets go locally; it proposes nothing of its own.
|
|
3872
|
+
const folderMove = await hooks.proposeFolder?.({
|
|
3873
|
+
op: 'dir.move',
|
|
3874
|
+
from: relDir.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''),
|
|
3875
|
+
to: toRelDir,
|
|
3876
|
+
});
|
|
3877
|
+
if (folderMove?.status === 'rejected') {
|
|
3878
|
+
return {
|
|
3879
|
+
ok: false,
|
|
3880
|
+
status: 409,
|
|
3881
|
+
error: `the project did not accept this move (${folderMove.code ?? 'rejected'})`,
|
|
3882
|
+
};
|
|
3883
|
+
}
|
|
3884
|
+
|
|
3341
3885
|
// Collab guard for every canvas found, BEFORE any disk mutation. Same
|
|
3342
3886
|
// coordinated retire as moveCanvas — refuse only when a pinned room could
|
|
3343
3887
|
// not be retired, and retire even unpinned synced documents so none of
|
|
@@ -3371,6 +3915,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3371
3915
|
const toRel = toRelDir + fromRel.slice(relDir.length);
|
|
3372
3916
|
const fromSlug = fileSlug(fromRel);
|
|
3373
3917
|
const toSlug = fileSlug(toRel);
|
|
3918
|
+
// Same depth change as moveCanvas, once per nested canvas (issue #114).
|
|
3919
|
+
// A folder move relocates every canvas inside it by the same delta, but
|
|
3920
|
+
// each one's own dir differs, so the rewrite is per file, not per folder.
|
|
3921
|
+
await rewriteCanvasImports(
|
|
3922
|
+
path.join(paths.designRoot, toRel),
|
|
3923
|
+
path.posix.dirname(fromRel),
|
|
3924
|
+
path.posix.dirname(toRel)
|
|
3925
|
+
);
|
|
3374
3926
|
for (const artifact of canvasArtifacts({ rel: fromRel, paths })) {
|
|
3375
3927
|
if (artifact.kind !== 'slug-keyed') continue; // primary/siblings already moved with the dir
|
|
3376
3928
|
const dest = relocatedName(artifact, fromRel, toRel, paths);
|
|
@@ -3499,6 +4051,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3499
4051
|
/* good — doesn't exist yet */
|
|
3500
4052
|
}
|
|
3501
4053
|
|
|
4054
|
+
const folderCreate = await hooks.proposeFolder?.({
|
|
4055
|
+
op: 'dir.create',
|
|
4056
|
+
path: path.posix.join(parent, v.name),
|
|
4057
|
+
});
|
|
4058
|
+
if (folderCreate?.status === 'rejected') {
|
|
4059
|
+
return {
|
|
4060
|
+
ok: false,
|
|
4061
|
+
status: 409,
|
|
4062
|
+
error: `the project did not accept this folder (${folderCreate.code ?? 'rejected'})`,
|
|
4063
|
+
};
|
|
4064
|
+
}
|
|
4065
|
+
|
|
3502
4066
|
await mkdir(dirAbs, { recursive: true });
|
|
3503
4067
|
await Bun.write(path.join(dirAbs, '.gitkeep'), '');
|
|
3504
4068
|
|
|
@@ -3576,10 +4140,22 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3576
4140
|
// suppression BEFORE the write so it catches the debounced fs:any, disarm on
|
|
3577
4141
|
// a no-op (delta 0 = no write) or throw so a failed edit can't mute the next
|
|
3578
4142
|
// genuine agent edit's rim.
|
|
4143
|
+
/** T24/T25 — say what a structural edit IS (sync/source-ops), for replay. */
|
|
4144
|
+
function announceOp(
|
|
4145
|
+
abs: string,
|
|
4146
|
+
rel: string,
|
|
4147
|
+
before: string,
|
|
4148
|
+
op: Parameters<typeof describeSourceOp>[2]
|
|
4149
|
+
): void {
|
|
4150
|
+
const described = describeSourceOp(abs, before, op);
|
|
4151
|
+
if (described) ctx.bus.emit('source-op', { rel, op: described });
|
|
4152
|
+
}
|
|
4153
|
+
|
|
3579
4154
|
async function suppressedEdit(
|
|
3580
4155
|
abs: string,
|
|
3581
|
-
run: () => Promise<{ delta: number; changed?: boolean }>,
|
|
3582
|
-
errLabel: string
|
|
4156
|
+
run: () => Promise<{ delta: number; changed?: boolean; previous?: AttributeState }>,
|
|
4157
|
+
errLabel: string,
|
|
4158
|
+
describe?: Parameters<typeof describeSourceOp>[2]
|
|
3583
4159
|
): Promise<EditOpResult> {
|
|
3584
4160
|
const rel = path.relative(paths.designRoot, abs);
|
|
3585
4161
|
ctx.bus.emit('activity:suppress', rel);
|
|
@@ -3588,18 +4164,33 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3588
4164
|
// whole-file undo log (the Timeline replace-src path rides this; other
|
|
3589
4165
|
// inspector edits get an undoable seq for free).
|
|
3590
4166
|
const before = await Bun.file(abs).text();
|
|
4167
|
+
// T24 — what this edit IS, so a lost race re-applies it instead of
|
|
4168
|
+
// turning "we both picked a colour" into a conflict.
|
|
4169
|
+
if (describe) {
|
|
4170
|
+
const op = describeSourceOp(abs, before, describe);
|
|
4171
|
+
if (op) ctx.bus.emit('source-op', { rel, op });
|
|
4172
|
+
}
|
|
3591
4173
|
const res = await run();
|
|
4174
|
+
const previous =
|
|
4175
|
+
res.previous?.kind === 'absent'
|
|
4176
|
+
? { previous: null }
|
|
4177
|
+
: res.previous?.kind === 'literal'
|
|
4178
|
+
? { previous: res.previous.value }
|
|
4179
|
+
: {};
|
|
3592
4180
|
// `changed === false` = the op collapsed to a no-op and nothing hit disk —
|
|
3593
4181
|
// NOT `delta === 0`, which an equal-length replacement also produces.
|
|
3594
4182
|
if (res.changed === false) {
|
|
3595
4183
|
ctx.bus.emit('activity:unsuppress', rel);
|
|
3596
|
-
return { ok: true, delta: res.delta };
|
|
4184
|
+
return { ok: true, delta: res.delta, ...previous };
|
|
3597
4185
|
}
|
|
3598
4186
|
const after = await Bun.file(abs).text();
|
|
3599
4187
|
const seq = after !== before ? logUndo(abs, before, after) : undefined;
|
|
3600
|
-
return { ok: true, delta: res.delta, seq };
|
|
4188
|
+
return { ok: true, delta: res.delta, seq, ...previous };
|
|
3601
4189
|
} catch (err) {
|
|
3602
4190
|
ctx.bus.emit('activity:unsuppress', rel);
|
|
4191
|
+
if (err instanceof CanvasEditError && err.conflict) {
|
|
4192
|
+
return { ok: false, status: 409, error: err.message, conflict: true };
|
|
4193
|
+
}
|
|
3603
4194
|
return {
|
|
3604
4195
|
ok: false,
|
|
3605
4196
|
status: 422,
|
|
@@ -3615,9 +4206,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3615
4206
|
value?: unknown;
|
|
3616
4207
|
reset?: unknown;
|
|
3617
4208
|
idIndex?: unknown;
|
|
4209
|
+
expected?: unknown;
|
|
3618
4210
|
}): Promise<EditOpResult> {
|
|
3619
4211
|
const r = resolveCanvasAbs(input.canvas);
|
|
3620
4212
|
if (!r.ok) return r;
|
|
4213
|
+
const pre = expectedValueOf(input);
|
|
4214
|
+
if (!pre.ok) return { ok: false, status: 400, error: 'invalid expected value' };
|
|
3621
4215
|
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
3622
4216
|
if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
|
|
3623
4217
|
// Stage H3 — optional DOM-occurrence index. Present only for a whole-instance
|
|
@@ -3642,8 +4236,21 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3642
4236
|
if (input.reset === true) {
|
|
3643
4237
|
return suppressedEdit(
|
|
3644
4238
|
r.abs,
|
|
3645
|
-
() =>
|
|
3646
|
-
|
|
4239
|
+
() =>
|
|
4240
|
+
removeAttribute(
|
|
4241
|
+
r.abs,
|
|
4242
|
+
id,
|
|
4243
|
+
`style.${camel}`,
|
|
4244
|
+
idIndex,
|
|
4245
|
+
pre.expected === undefined ? undefined : { expected: pre.expected, next: null }
|
|
4246
|
+
),
|
|
4247
|
+
'reset failed',
|
|
4248
|
+
{
|
|
4249
|
+
kind: 'remove',
|
|
4250
|
+
id,
|
|
4251
|
+
attr: `style.${camel}`,
|
|
4252
|
+
...(idIndex !== undefined ? { occurrence: idIndex } : {}),
|
|
4253
|
+
}
|
|
3647
4254
|
);
|
|
3648
4255
|
}
|
|
3649
4256
|
const value = typeof input.value === 'string' ? input.value : '';
|
|
@@ -3655,8 +4262,23 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3655
4262
|
// `#fff`, `8px`, `700`, `1.5` all ride verbatim.
|
|
3656
4263
|
return suppressedEdit(
|
|
3657
4264
|
r.abs,
|
|
3658
|
-
() =>
|
|
3659
|
-
|
|
4265
|
+
() =>
|
|
4266
|
+
editAttribute(
|
|
4267
|
+
r.abs,
|
|
4268
|
+
id,
|
|
4269
|
+
`style.${camel}`,
|
|
4270
|
+
JSON.stringify(value),
|
|
4271
|
+
idIndex,
|
|
4272
|
+
pre.expected === undefined ? undefined : { expected: pre.expected, next: value }
|
|
4273
|
+
),
|
|
4274
|
+
'edit failed',
|
|
4275
|
+
{
|
|
4276
|
+
kind: 'set',
|
|
4277
|
+
id,
|
|
4278
|
+
attr: `style.${camel}`,
|
|
4279
|
+
value: JSON.stringify(value),
|
|
4280
|
+
...(idIndex !== undefined ? { occurrence: idIndex } : {}),
|
|
4281
|
+
}
|
|
3660
4282
|
);
|
|
3661
4283
|
}
|
|
3662
4284
|
|
|
@@ -3689,7 +4311,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3689
4311
|
return suppressedEdit(
|
|
3690
4312
|
r.abs,
|
|
3691
4313
|
() => runEditText(r.abs, id, text, { occurrence, before }),
|
|
3692
|
-
'edit failed'
|
|
4314
|
+
'edit failed',
|
|
4315
|
+
{ kind: 'text', id, text, ...(occurrence !== undefined ? { occurrence } : {}) }
|
|
3693
4316
|
);
|
|
3694
4317
|
}
|
|
3695
4318
|
|
|
@@ -3699,9 +4322,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3699
4322
|
attr?: unknown;
|
|
3700
4323
|
value?: unknown;
|
|
3701
4324
|
reset?: unknown;
|
|
4325
|
+
expected?: unknown;
|
|
3702
4326
|
}): Promise<EditOpResult> {
|
|
3703
4327
|
const r = resolveCanvasAbs(input.canvas);
|
|
3704
4328
|
if (!r.ok) return r;
|
|
4329
|
+
const pre = expectedValueOf(input);
|
|
4330
|
+
if (!pre.ok) return { ok: false, status: 400, error: 'invalid expected value' };
|
|
3705
4331
|
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
3706
4332
|
if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
|
|
3707
4333
|
const attr = typeof input.attr === 'string' ? input.attr.trim() : '';
|
|
@@ -3719,7 +4345,19 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3719
4345
|
}
|
|
3720
4346
|
// Phase 12.3 — `reset: true` REMOVES the custom attribute. No-op if absent.
|
|
3721
4347
|
if (input.reset === true) {
|
|
3722
|
-
return suppressedEdit(
|
|
4348
|
+
return suppressedEdit(
|
|
4349
|
+
r.abs,
|
|
4350
|
+
() =>
|
|
4351
|
+
removeAttribute(
|
|
4352
|
+
r.abs,
|
|
4353
|
+
id,
|
|
4354
|
+
attr,
|
|
4355
|
+
undefined,
|
|
4356
|
+
pre.expected === undefined ? undefined : { expected: pre.expected, next: null }
|
|
4357
|
+
),
|
|
4358
|
+
'reset failed',
|
|
4359
|
+
{ kind: 'remove', id, attr }
|
|
4360
|
+
);
|
|
3723
4361
|
}
|
|
3724
4362
|
const value = typeof input.value === 'string' ? input.value : '';
|
|
3725
4363
|
if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
|
|
@@ -3740,7 +4378,20 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3740
4378
|
// Pass the value RAW: editStringAttr quotes/escapes it itself (JSON.stringify
|
|
3741
4379
|
// on replace, escapeAttr on insert) — pre-stringifying here double-encoded
|
|
3742
4380
|
// the value (`data-x="\"ok\""`; knob-smoke finding, 2026-06-12).
|
|
3743
|
-
return suppressedEdit(
|
|
4381
|
+
return suppressedEdit(
|
|
4382
|
+
r.abs,
|
|
4383
|
+
() =>
|
|
4384
|
+
editAttribute(
|
|
4385
|
+
r.abs,
|
|
4386
|
+
id,
|
|
4387
|
+
attr,
|
|
4388
|
+
value,
|
|
4389
|
+
undefined,
|
|
4390
|
+
pre.expected === undefined ? undefined : { expected: pre.expected, next: value }
|
|
4391
|
+
),
|
|
4392
|
+
'edit failed',
|
|
4393
|
+
{ kind: 'set', id, attr, value }
|
|
4394
|
+
);
|
|
3744
4395
|
}
|
|
3745
4396
|
|
|
3746
4397
|
// Phase 12.1 (DDR-138) — snapshot stack so a reorder is undoable via
|
|
@@ -3787,6 +4438,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3787
4438
|
ctx.bus.emit('activity:suppress', rel);
|
|
3788
4439
|
try {
|
|
3789
4440
|
const before = await Bun.file(r.abs).text();
|
|
4441
|
+
announceOp(r.abs, rel, before, {
|
|
4442
|
+
kind: 'move',
|
|
4443
|
+
id,
|
|
4444
|
+
refId,
|
|
4445
|
+
position: String(position),
|
|
4446
|
+
...(idIndex !== undefined ? { idIndex } : {}),
|
|
4447
|
+
...(refIndex !== undefined ? { refIndex } : {}),
|
|
4448
|
+
});
|
|
3790
4449
|
const res = await moveElement(r.abs, id, refId, position as MovePosition, idIndex, refIndex);
|
|
3791
4450
|
const after = await Bun.file(r.abs).text();
|
|
3792
4451
|
if (after === before) {
|
|
@@ -4202,6 +4861,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4202
4861
|
ctx.bus.emit('activity:suppress', rel);
|
|
4203
4862
|
try {
|
|
4204
4863
|
const before = await Bun.file(r.abs).text();
|
|
4864
|
+
announceOp(r.abs, rel, before, {
|
|
4865
|
+
kind: 'delete',
|
|
4866
|
+
id,
|
|
4867
|
+
...(idIndex !== undefined ? { occurrence: idIndex } : {}),
|
|
4868
|
+
});
|
|
4205
4869
|
const res = await deleteElement(r.abs, id, idIndex);
|
|
4206
4870
|
const after = await Bun.file(r.abs).text();
|
|
4207
4871
|
if (after === before) {
|
|
@@ -4667,6 +5331,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4667
5331
|
ctx.bus.emit('activity:suppress', rel);
|
|
4668
5332
|
try {
|
|
4669
5333
|
const before = await Bun.file(r.abs).text();
|
|
5334
|
+
announceOp(r.abs, rel, before, {
|
|
5335
|
+
kind: 'artboard',
|
|
5336
|
+
fn: 'resize',
|
|
5337
|
+
artboardId,
|
|
5338
|
+
args: [width, height],
|
|
5339
|
+
});
|
|
4670
5340
|
await resizeArtboard(r.abs, artboardId, width, height);
|
|
4671
5341
|
const after = await Bun.file(r.abs).text();
|
|
4672
5342
|
if (after === before) {
|
|
@@ -4714,6 +5384,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4714
5384
|
ctx.bus.emit('activity:suppress', rel);
|
|
4715
5385
|
try {
|
|
4716
5386
|
const before = await Bun.file(r.abs).text();
|
|
5387
|
+
announceOp(r.abs, rel, before, {
|
|
5388
|
+
kind: 'artboard',
|
|
5389
|
+
fn: 'hug',
|
|
5390
|
+
artboardId,
|
|
5391
|
+
args: [fixed, freezeHeight],
|
|
5392
|
+
});
|
|
4717
5393
|
await setArtboardHug(r.abs, artboardId, fixed, freezeHeight);
|
|
4718
5394
|
const after = await Bun.file(r.abs).text();
|
|
4719
5395
|
if (after === before) {
|
|
@@ -4763,6 +5439,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4763
5439
|
ctx.bus.emit('activity:suppress', rel);
|
|
4764
5440
|
try {
|
|
4765
5441
|
const before = await Bun.file(r.abs).text();
|
|
5442
|
+
announceOp(r.abs, rel, before, { kind: 'artboard', fn: 'kind', artboardId, args: [kind] });
|
|
4766
5443
|
await setArtboardKind(r.abs, artboardId, kind);
|
|
4767
5444
|
const after = await Bun.file(r.abs).text();
|
|
4768
5445
|
if (after === before) {
|
|
@@ -4785,6 +5462,50 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4785
5462
|
}
|
|
4786
5463
|
}
|
|
4787
5464
|
|
|
5465
|
+
/** Rename an artboard (plan T25/L08 — double-click its name). */
|
|
5466
|
+
async function setArtboardLabelOp(input: {
|
|
5467
|
+
canvas?: unknown;
|
|
5468
|
+
artboardId?: unknown;
|
|
5469
|
+
label?: unknown;
|
|
5470
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
|
|
5471
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
5472
|
+
if (!r.ok) return r;
|
|
5473
|
+
if (!takeStructuralToken()) return RATE_LIMITED;
|
|
5474
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
|
|
5475
|
+
if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
|
|
5476
|
+
return { ok: false, status: 400, error: 'invalid artboard id' };
|
|
5477
|
+
}
|
|
5478
|
+
if (typeof input.label !== 'string' || !input.label.trim()) {
|
|
5479
|
+
return { ok: false, status: 400, error: 'name is required' };
|
|
5480
|
+
}
|
|
5481
|
+
const label = input.label;
|
|
5482
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
5483
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
5484
|
+
try {
|
|
5485
|
+
const before = await Bun.file(r.abs).text();
|
|
5486
|
+
announceOp(r.abs, rel, before, { kind: 'artboard', fn: 'label', artboardId, args: [label] });
|
|
5487
|
+
await setArtboardLabel(r.abs, artboardId, label);
|
|
5488
|
+
const after = await Bun.file(r.abs).text();
|
|
5489
|
+
if (after === before) {
|
|
5490
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
5491
|
+
return { ok: true };
|
|
5492
|
+
}
|
|
5493
|
+
try {
|
|
5494
|
+
await history.writeSnapshot(rel, before, 'pre-set-artboard-label');
|
|
5495
|
+
} catch {
|
|
5496
|
+
/* snapshot best-effort */
|
|
5497
|
+
}
|
|
5498
|
+
return { ok: true, seq: logUndo(r.abs, before, after) };
|
|
5499
|
+
} catch (err) {
|
|
5500
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
5501
|
+
return {
|
|
5502
|
+
ok: false,
|
|
5503
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
5504
|
+
error: err instanceof Error ? err.message : 'set-artboard-label failed',
|
|
5505
|
+
};
|
|
5506
|
+
}
|
|
5507
|
+
}
|
|
5508
|
+
|
|
4788
5509
|
const MAX_GUIDES_JSON_BYTES = 4096;
|
|
4789
5510
|
|
|
4790
5511
|
/** Generic layout guides (T5) — Inspector/skill writer. Replace-whole-prop,
|
|
@@ -4818,6 +5539,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4818
5539
|
ctx.bus.emit('activity:suppress', rel);
|
|
4819
5540
|
try {
|
|
4820
5541
|
const before = await Bun.file(r.abs).text();
|
|
5542
|
+
announceOp(r.abs, rel, before, {
|
|
5543
|
+
kind: 'artboard',
|
|
5544
|
+
fn: 'guides',
|
|
5545
|
+
artboardId,
|
|
5546
|
+
args: [guides],
|
|
5547
|
+
});
|
|
4821
5548
|
await setArtboardGuides(r.abs, artboardId, guides);
|
|
4822
5549
|
const after = await Bun.file(r.abs).text();
|
|
4823
5550
|
if (after === before) {
|
|
@@ -4994,6 +5721,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4994
5721
|
ctx.bus.emit('activity:suppress', rel);
|
|
4995
5722
|
try {
|
|
4996
5723
|
const before = await Bun.file(r.abs).text();
|
|
5724
|
+
announceOp(r.abs, rel, before, { kind: 'artboard', fn: 'style', artboardId, args: [patch] });
|
|
4997
5725
|
await setArtboardStyle(r.abs, artboardId, patch);
|
|
4998
5726
|
const after = await Bun.file(r.abs).text();
|
|
4999
5727
|
if (after === before) {
|
|
@@ -5038,6 +5766,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5038
5766
|
ctx.bus.emit('activity:unsuppress', rel);
|
|
5039
5767
|
return { ok: false, status: 413, error: 'canvas source too large to grow' };
|
|
5040
5768
|
}
|
|
5769
|
+
announceOp(r.abs, rel, before, {
|
|
5770
|
+
kind: 'duplicate',
|
|
5771
|
+
id,
|
|
5772
|
+
...(idIndex !== undefined ? { occurrence: idIndex } : {}),
|
|
5773
|
+
});
|
|
5041
5774
|
const res = await duplicateElement(r.abs, id, idIndex);
|
|
5042
5775
|
const after = await Bun.file(r.abs).text();
|
|
5043
5776
|
if (after === before) {
|
|
@@ -5363,6 +6096,36 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5363
6096
|
const current = await Bun.file(r.abs).text();
|
|
5364
6097
|
const expect = dir === 'undo' ? entry.after : entry.before;
|
|
5365
6098
|
const write = dir === 'undo' ? entry.before : entry.after;
|
|
6099
|
+
// T26 — a project that saves through accepted revisions undoes the
|
|
6100
|
+
// ACTION this edit became: effect-aware, so a teammate's later change
|
|
6101
|
+
// elsewhere in the canvas neither blocks the undo nor gets reverted
|
|
6102
|
+
// with it (a whole-file swap could do only one of those).
|
|
6103
|
+
const runtime = ctx.syncControl?.current?.();
|
|
6104
|
+
if (runtime?.acceptedMode?.()) {
|
|
6105
|
+
const rel = path.relative(paths.repoRoot, r.abs);
|
|
6106
|
+
// Undo targets the edit's action; redo targets the undo that
|
|
6107
|
+
// reverted it (reverting a revert re-applies, effect-aware again).
|
|
6108
|
+
const actionId =
|
|
6109
|
+
dir === 'undo'
|
|
6110
|
+
? (runtime.acceptedActionForContent?.(rel, entry.after) ?? null)
|
|
6111
|
+
: (entry.undoActionId ?? null);
|
|
6112
|
+
if (actionId) {
|
|
6113
|
+
const res = await runtime.acceptedUndo?.(actionId, dir === 'redo');
|
|
6114
|
+
if (res?.status === 'accepted' || res?.queued) {
|
|
6115
|
+
if (dir === 'undo') entry.undoActionId = res.actionId;
|
|
6116
|
+
else delete entry.undoActionId;
|
|
6117
|
+
return { ok: true, dir };
|
|
6118
|
+
}
|
|
6119
|
+
return {
|
|
6120
|
+
ok: false,
|
|
6121
|
+
status: 409,
|
|
6122
|
+
error:
|
|
6123
|
+
res?.code === 'base-conflict'
|
|
6124
|
+
? 'someone changed the same thing since — undo skipped'
|
|
6125
|
+
: `undo refused (${res?.code ?? 'unknown'})`,
|
|
6126
|
+
};
|
|
6127
|
+
}
|
|
6128
|
+
}
|
|
5366
6129
|
if (current !== expect) {
|
|
5367
6130
|
return {
|
|
5368
6131
|
ok: false,
|
|
@@ -5797,6 +6560,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5797
6560
|
patchCanvasMeta,
|
|
5798
6561
|
loadAnnotations,
|
|
5799
6562
|
saveAnnotations,
|
|
6563
|
+
projectAnnotations,
|
|
5800
6564
|
saveAsset,
|
|
5801
6565
|
listAssets,
|
|
5802
6566
|
readAssetBytes,
|
|
@@ -5808,6 +6572,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5808
6572
|
saveChatAttachment,
|
|
5809
6573
|
resolveChatAttachment,
|
|
5810
6574
|
createCanvas,
|
|
6575
|
+
duplicateCanvas,
|
|
5811
6576
|
deleteCanvas,
|
|
5812
6577
|
moveCanvas,
|
|
5813
6578
|
createFolder,
|
|
@@ -5832,6 +6597,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5832
6597
|
setArtboardHugOp,
|
|
5833
6598
|
setArtboardStyleOp,
|
|
5834
6599
|
setArtboardKindOp,
|
|
6600
|
+
setArtboardLabelOp,
|
|
5835
6601
|
setArtboardGuidesOp,
|
|
5836
6602
|
setArtboardPrintOp,
|
|
5837
6603
|
deleteArtboardOp,
|