@1agh/maude 1.2.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/apps/studio/acp/index.ts +43 -7
- 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 +732 -38
- package/apps/studio/bin/annotate.mjs +3 -1
- package/apps/studio/bin/server-up.sh +17 -2
- package/apps/studio/canvas-build.ts +49 -8
- package/apps/studio/canvas-edit.ts +468 -7
- package/apps/studio/canvas-lib.tsx +82 -18
- package/apps/studio/canvas-list-watch.ts +33 -2
- 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 +783 -245
- package/apps/studio/client/apply-edit-request.ts +55 -0
- package/apps/studio/client/canvas-url.js +39 -1
- package/apps/studio/client/export-center.jsx +111 -61
- 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 +113 -0
- 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/share-dialog.jsx +91 -0
- package/apps/studio/client/share-link.js +94 -0
- package/apps/studio/client/styles/3-shell-maude.css +36 -18
- package/apps/studio/client/styles/4-components.css +18 -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/awareness-bridge.ts +65 -14
- package/apps/studio/collab/index.ts +4 -0
- package/apps/studio/collab/persistence.ts +5 -1
- package/apps/studio/collab/registry.ts +9 -3
- package/apps/studio/collab/room.ts +13 -2
- package/apps/studio/context.ts +59 -0
- package/apps/studio/dist/client.bundle.js +1594 -1562
- package/apps/studio/dist/comment-mount.js +2 -2
- 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/git/accepted-guard.ts +36 -0
- package/apps/studio/hmr-broadcast.ts +34 -1
- package/apps/studio/http.ts +300 -18
- package/apps/studio/inspect.ts +19 -0
- package/apps/studio/managed-projects.ts +172 -0
- package/apps/studio/notifications.tsx +292 -0
- package/apps/studio/runtime-bundle.ts +2 -0
- package/apps/studio/server.ts +59 -3
- 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 +12 -60
- package/apps/studio/sync/cell-file-events.ts +3 -0
- package/apps/studio/sync/codec.ts +73 -6
- package/apps/studio/sync/ctl-provider.ts +15 -2
- package/apps/studio/sync/document-discovery.ts +81 -0
- package/apps/studio/sync/file-membership.ts +10 -2
- package/apps/studio/sync/file-plane.ts +309 -26
- package/apps/studio/sync/index.ts +1174 -21
- package/apps/studio/sync/migrate-seed.ts +110 -19
- package/apps/studio/sync/poke.ts +4 -2
- package/apps/studio/sync/presentation.ts +315 -2
- package/apps/studio/sync/projection.ts +820 -23
- package/apps/studio/sync/repeated-module.ts +73 -0
- package/apps/studio/sync/revision-barrier.ts +129 -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 +83 -1
- package/apps/studio/sync/transaction-client.ts +486 -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/use-tool-mode.tsx +44 -0
- package/apps/studio/whats-new.json +137 -0
- package/cli/lib/harness/codex-runtime.mjs +4 -1
- package/package.json +9 -8
- package/plugins/design/dependencies.json +3 -3
- package/plugins/design/templates/_shell.html +95 -7
- package/plugins/flow/dependencies.json +3 -3
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,
|
|
@@ -21,6 +21,7 @@ import { rewriteRelativeImports } from './canvas-imports.ts';
|
|
|
21
21
|
import { canvasSlugFromRel } from './canvas-slug.ts';
|
|
22
22
|
import { atomicWrite } from './sync/atomic-write.ts';
|
|
23
23
|
import { dedupeCommentsById } from './sync/comment-identity.ts';
|
|
24
|
+
import { isRuntimeStateRel } from './sync/file-membership.ts';
|
|
24
25
|
|
|
25
26
|
// Re-exported so existing external callers (canvas-list-watch.ts, tests) keep
|
|
26
27
|
// importing it from api.ts — the actual implementation now lives in
|
|
@@ -28,8 +29,19 @@ import { dedupeCommentsById } from './sync/comment-identity.ts';
|
|
|
28
29
|
// without a cycle back through api.ts.
|
|
29
30
|
export { canvasSlugFromRel } from './canvas-slug.ts';
|
|
30
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
|
+
|
|
31
42
|
import {
|
|
32
43
|
type AssembleClip,
|
|
44
|
+
type AttributeState,
|
|
33
45
|
assembleCompSource,
|
|
34
46
|
CanvasEditError,
|
|
35
47
|
type ClipInfo,
|
|
@@ -63,6 +75,7 @@ import {
|
|
|
63
75
|
setArtboardGuides,
|
|
64
76
|
setArtboardHug,
|
|
65
77
|
setArtboardKind,
|
|
78
|
+
setArtboardLabel,
|
|
66
79
|
setArtboardPrint,
|
|
67
80
|
setArtboardStyle,
|
|
68
81
|
toggleClipHidden,
|
|
@@ -102,6 +115,7 @@ import { clearLocatorSlug, readLocator, writeLocator } from './locator.ts';
|
|
|
102
115
|
import { STICKERS_DIR } from './paths.ts';
|
|
103
116
|
import { getPaperPreset, MAX_PRINT_MM } from './print/units.ts';
|
|
104
117
|
import { sessionDir } from './session-scope.ts';
|
|
118
|
+
import { describeSourceOp } from './sync/source-ops.ts';
|
|
105
119
|
import { isWorkspaceMode } from './workspace-mode.ts';
|
|
106
120
|
|
|
107
121
|
// Directories that never hold user-facing canvases. Exported so the
|
|
@@ -127,7 +141,7 @@ const HIDDEN_OK = new Set(['.ai', '.claude', '.design']);
|
|
|
127
141
|
// an explicit enumerated list rather than a broad pattern so it can never
|
|
128
142
|
// accidentally widen to swallow runtime JSON — findFiles already excludes
|
|
129
143
|
// `_`-prefixed entries before this list is even consulted.
|
|
130
|
-
const PREVIEW_ASSET_EXTS = [
|
|
144
|
+
export const PREVIEW_ASSET_EXTS = [
|
|
131
145
|
'.svg',
|
|
132
146
|
'.png',
|
|
133
147
|
'.jpg',
|
|
@@ -157,6 +171,30 @@ const PREVIEW_ASSET_EXTS = [
|
|
|
157
171
|
* (`system/<ds>/preview/*.html`) intentionally stay `.html` and travel via
|
|
158
172
|
* the DS-aware `findFiles()` path below.
|
|
159
173
|
*/
|
|
174
|
+
/**
|
|
175
|
+
* True when `abs` is a directory tree holding nothing but folder placeholders
|
|
176
|
+
* (`.gitkeep`, `.DS_Store`) — what a folder projection leaves before any
|
|
177
|
+
* content arrives. False when it is absent, is not a directory, or holds
|
|
178
|
+
* anything real (a symlink counts as real: never followed, never removed).
|
|
179
|
+
*/
|
|
180
|
+
async function onlyFolderPlaceholders(abs: string, depth = 0): Promise<boolean> {
|
|
181
|
+
if (depth > 16) return false;
|
|
182
|
+
let entries: Dirent[];
|
|
183
|
+
try {
|
|
184
|
+
entries = await readdir(abs, { withFileTypes: true });
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
for (const e of entries) {
|
|
189
|
+
if (e.isDirectory()) {
|
|
190
|
+
if (!(await onlyFolderPlaceholders(path.join(abs, e.name), depth + 1))) return false;
|
|
191
|
+
} else if (!(e.isFile() && (e.name === '.gitkeep' || e.name === '.DS_Store'))) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
160
198
|
export async function findHtmlFiles(absRoot: string, prefixUnderRepo: string): Promise<string[]> {
|
|
161
199
|
const out: string[] = [];
|
|
162
200
|
let entries: Dirent[];
|
|
@@ -189,7 +227,7 @@ export async function findHtmlFiles(absRoot: string, prefixUnderRepo: string): P
|
|
|
189
227
|
* traversal instead of adding a second full walk of `system/` (the largest
|
|
190
228
|
* group) just to enumerate directories.
|
|
191
229
|
*/
|
|
192
|
-
async function findFiles(
|
|
230
|
+
export async function findFiles(
|
|
193
231
|
absRoot: string,
|
|
194
232
|
prefix: string,
|
|
195
233
|
exts: string[],
|
|
@@ -312,8 +350,27 @@ export type CreateFolderResult =
|
|
|
312
350
|
|
|
313
351
|
/** Phase 12 — result of an in-canvas direct edit (`editCss` / `editText`). */
|
|
314
352
|
export type EditOpResult =
|
|
315
|
-
|
|
316
|
-
|
|
353
|
+
/** `previous` — what a css/attr write replaced (`null` = was unset); absent
|
|
354
|
+
* when it was an expression no literal undo can restore. */
|
|
355
|
+
| { ok: true; delta: number; seq?: number; previous?: string | null }
|
|
356
|
+
/** `conflict` = the target no longer held the caller's expected value. */
|
|
357
|
+
| { ok: false; status: number; error: string; conflict?: true };
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Optional expected-current value for a css/attr write (audit 2026-09-13 P1
|
|
361
|
+
* #5). An absent `expected` key keeps the unconditional legacy behaviour; a
|
|
362
|
+
* present one must be a bounded string or `null` ("currently unset").
|
|
363
|
+
*/
|
|
364
|
+
function expectedValueOf(input: {
|
|
365
|
+
expected?: unknown;
|
|
366
|
+
}): { ok: true; expected?: string | null } | { ok: false } {
|
|
367
|
+
if (!Object.hasOwn(input, 'expected') || input.expected === undefined) return { ok: true };
|
|
368
|
+
if (input.expected === null) return { ok: true, expected: null };
|
|
369
|
+
if (typeof input.expected === 'string' && input.expected.length <= 256) {
|
|
370
|
+
return { ok: true, expected: input.expected };
|
|
371
|
+
}
|
|
372
|
+
return { ok: false };
|
|
373
|
+
}
|
|
317
374
|
|
|
318
375
|
/**
|
|
319
376
|
* Phase 12.1 (DDR-138) — result of a node-move reorder. Carries the re-settle
|
|
@@ -374,7 +431,9 @@ export interface Api {
|
|
|
374
431
|
): Promise<Record<string, unknown> | null>;
|
|
375
432
|
// Annotations sidecar (Phase 5 — .design/<slug>.annotations.svg)
|
|
376
433
|
loadAnnotations(file: string): Promise<string | null>;
|
|
377
|
-
saveAnnotations(file: string, svg: string): Promise<boolean>;
|
|
434
|
+
saveAnnotations(file: string, svg: string, writeId?: string, base?: string): Promise<boolean>;
|
|
435
|
+
/** Materialize a document snapshot without publishing it as another user edit. */
|
|
436
|
+
projectAnnotations(file: string, svg: string, isCurrent: () => boolean): Promise<boolean>;
|
|
378
437
|
// Phase 23 — content-addressed binary image write (drag-drop / paste / picker)
|
|
379
438
|
saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
|
|
380
439
|
/** Stage F1 — list content-addressed image/video assets for the AssetPicker. */
|
|
@@ -425,6 +484,10 @@ export interface Api {
|
|
|
425
484
|
width?: unknown;
|
|
426
485
|
height?: unknown;
|
|
427
486
|
}): Promise<CreateCanvasResult>;
|
|
487
|
+
// Duplicate a canvas beside itself ("<name> copy") — POST /_api/canvas
|
|
488
|
+
// { duplicateOf }. Source, meta and the whiteboard layer; not comments or
|
|
489
|
+
// history, which belong to the original.
|
|
490
|
+
duplicateCanvas(input: { file?: unknown }): Promise<CreateCanvasResult>;
|
|
428
491
|
// Soft-delete a canvas from the browser (Phase 22 — DELETE /_api/canvas)
|
|
429
492
|
deleteCanvas(input: { file?: unknown }): Promise<DeleteCanvasResult>;
|
|
430
493
|
// feature-file-tree-drag-drop-folders (Task 3) — move/rename a canvas + its
|
|
@@ -442,6 +505,8 @@ export interface Api {
|
|
|
442
505
|
value?: unknown;
|
|
443
506
|
reset?: unknown;
|
|
444
507
|
idIndex?: unknown;
|
|
508
|
+
/** Expected current value (string) or `null` = currently unset. Absent = unconditional. */
|
|
509
|
+
expected?: unknown;
|
|
445
510
|
}): Promise<EditOpResult>;
|
|
446
511
|
// Phase 12 (DDR-103) — inline text-content edit (POST /_api/edit-text). Main-origin only.
|
|
447
512
|
editText(input: {
|
|
@@ -459,6 +524,8 @@ export interface Api {
|
|
|
459
524
|
id?: unknown;
|
|
460
525
|
attr?: unknown;
|
|
461
526
|
value?: unknown;
|
|
527
|
+
reset?: unknown;
|
|
528
|
+
expected?: unknown;
|
|
462
529
|
}): Promise<EditOpResult>;
|
|
463
530
|
// Phase 12.1 (DDR-138) — node-move reorder (POST /_api/reorder). Main-origin
|
|
464
531
|
// only. Moves the element with data-cd-id `id` to `position` relative to
|
|
@@ -662,6 +729,12 @@ export interface Api {
|
|
|
662
729
|
artboardId?: unknown;
|
|
663
730
|
kind?: unknown;
|
|
664
731
|
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
|
|
732
|
+
/** Rename an artboard (T25/L08 — double-click its name on the canvas). */
|
|
733
|
+
setArtboardLabelOp(input: {
|
|
734
|
+
canvas?: unknown;
|
|
735
|
+
artboardId?: unknown;
|
|
736
|
+
label?: unknown;
|
|
737
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
|
|
665
738
|
/** Generic layout guides (T5) — replace-whole-prop write. */
|
|
666
739
|
setArtboardGuidesOp(input: {
|
|
667
740
|
canvas?: unknown;
|
|
@@ -736,9 +809,26 @@ export interface ApiHooks {
|
|
|
736
809
|
* stays". Passing the list removes the read, and `publishComments` awaits
|
|
737
810
|
* this hook BEFORE touching disk so the doc is never the stale side.
|
|
738
811
|
*/
|
|
739
|
-
|
|
812
|
+
/** `base` — the list this mutation started from (a merge hint for the project). */
|
|
813
|
+
onCommentsChanged: (file: string, comments: Comment[], base?: Comment[]) => void | Promise<void>;
|
|
740
814
|
/** Phase 8 Task 5 — fires after a successful PUT /_api/annotations write. */
|
|
741
|
-
onAnnotationsChanged?: (file: string, svg: string) => void;
|
|
815
|
+
onAnnotationsChanged?: (file: string, svg: string, writeId?: string, base?: string) => void;
|
|
816
|
+
/**
|
|
817
|
+
* Accepted-revisions mode (DDR-241): propose a folder operation as ONE
|
|
818
|
+
* project action before touching disk. Absent, or answering `null`, means
|
|
819
|
+
* the project is not in that mode and the local operation is the whole story.
|
|
820
|
+
*/
|
|
821
|
+
/**
|
|
822
|
+
* A layout (shared meta) write, with the file text it replaced — accepted
|
|
823
|
+
* revisions propose it with that base instead of inferring one.
|
|
824
|
+
*/
|
|
825
|
+
onMetaChanged?: (file: string, text: string, baseText: string | null) => void;
|
|
826
|
+
proposeFolder?: (
|
|
827
|
+
op:
|
|
828
|
+
| { op: 'dir.create'; path: string }
|
|
829
|
+
| { op: 'dir.delete'; path: string }
|
|
830
|
+
| { op: 'dir.move'; from: string; to: string }
|
|
831
|
+
) => Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null;
|
|
742
832
|
/**
|
|
743
833
|
* feature-file-tree-drag-drop-folders (Task 3) — is a collab room pinned
|
|
744
834
|
* (a shared-doc hub provider attached, DDR-064)? `moveCanvas` refuses the
|
|
@@ -1009,7 +1099,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1009
1099
|
// stores in its undo record. Ephemeral by design: a server restart drops it
|
|
1010
1100
|
// (undo answers 404 and the canvas stack entry is a no-op, honest failure).
|
|
1011
1101
|
const REORDER_LOG_CAP = 50;
|
|
1012
|
-
const reorderLog = new Map<
|
|
1102
|
+
const reorderLog = new Map<
|
|
1103
|
+
number,
|
|
1104
|
+
{ abs: string; before: string; after: string; undoActionId?: string }
|
|
1105
|
+
>();
|
|
1013
1106
|
let reorderSeq = 0;
|
|
1014
1107
|
|
|
1015
1108
|
// DDR-150 dogfood #1 — the SAME whole-file log backs the Timeline clip ops
|
|
@@ -1187,9 +1280,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1187
1280
|
* content — otherwise the file→doc import that follows the write would see a
|
|
1188
1281
|
* difference and re-enter the loop.
|
|
1189
1282
|
*/
|
|
1190
|
-
async function publishComments(file: string, list: Comment[]): Promise<void> {
|
|
1283
|
+
async function publishComments(file: string, list: Comment[], base?: Comment[]): Promise<void> {
|
|
1191
1284
|
const settled = dedupeCommentsById(list);
|
|
1192
|
-
await onCommentsChanged(file, settled);
|
|
1285
|
+
await onCommentsChanged(file, settled, base ? dedupeCommentsById(base) : undefined);
|
|
1193
1286
|
await saveCommentsForFile(file, settled);
|
|
1194
1287
|
}
|
|
1195
1288
|
|
|
@@ -1321,6 +1414,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1321
1414
|
if (!payload || typeof payload.file !== 'string' || !payload.file) return null;
|
|
1322
1415
|
if (typeof payload.text !== 'string' || !payload.text.trim()) return null;
|
|
1323
1416
|
const list = await loadCommentsForFile(payload.file);
|
|
1417
|
+
const base = structuredClone(list);
|
|
1324
1418
|
const text = String(payload.text).trim().slice(0, 4000);
|
|
1325
1419
|
const author =
|
|
1326
1420
|
typeof payload.author === 'string' && payload.author.trim()
|
|
@@ -1369,7 +1463,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1369
1463
|
if (anchor.clipStableId != null || anchor.frame != null) c.timeline = anchor;
|
|
1370
1464
|
}
|
|
1371
1465
|
list.push(c);
|
|
1372
|
-
await publishComments(payload.file, list);
|
|
1466
|
+
await publishComments(payload.file, list, base);
|
|
1373
1467
|
return c;
|
|
1374
1468
|
}
|
|
1375
1469
|
|
|
@@ -1384,6 +1478,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1384
1478
|
if (i < 0) continue;
|
|
1385
1479
|
const entry = list[i];
|
|
1386
1480
|
if (!entry) continue;
|
|
1481
|
+
const base = structuredClone(list);
|
|
1387
1482
|
const body = payload.body.trim().slice(0, 4000);
|
|
1388
1483
|
const author =
|
|
1389
1484
|
typeof payload.author === 'string' && payload.author.trim()
|
|
@@ -1397,7 +1492,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1397
1492
|
};
|
|
1398
1493
|
entry.thread = [...entry.thread, reply];
|
|
1399
1494
|
entry.mentions = mentionsUnion(entry);
|
|
1400
|
-
await publishComments(file, list);
|
|
1495
|
+
await publishComments(file, list, base);
|
|
1401
1496
|
return entry;
|
|
1402
1497
|
}
|
|
1403
1498
|
return null;
|
|
@@ -1417,6 +1512,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1417
1512
|
const matches = list.filter((c) => c.id === id);
|
|
1418
1513
|
const first = matches[0];
|
|
1419
1514
|
if (!first) continue;
|
|
1515
|
+
const base = structuredClone(list);
|
|
1420
1516
|
for (const entry of matches) {
|
|
1421
1517
|
if (patch.status === 'resolved' || patch.status === 'open') {
|
|
1422
1518
|
entry.status = patch.status;
|
|
@@ -1427,7 +1523,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1427
1523
|
entry.mentions = mentionsUnion(entry);
|
|
1428
1524
|
}
|
|
1429
1525
|
}
|
|
1430
|
-
await publishComments(file, list);
|
|
1526
|
+
await publishComments(file, list, base);
|
|
1431
1527
|
return first;
|
|
1432
1528
|
}
|
|
1433
1529
|
return null;
|
|
@@ -1438,7 +1534,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1438
1534
|
for (const [file, list] of Object.entries(all)) {
|
|
1439
1535
|
const remaining = list.filter((c) => c.id !== id);
|
|
1440
1536
|
if (remaining.length === list.length) continue;
|
|
1441
|
-
await publishComments(file, remaining);
|
|
1537
|
+
await publishComments(file, remaining, list);
|
|
1442
1538
|
return true;
|
|
1443
1539
|
}
|
|
1444
1540
|
return false;
|
|
@@ -1946,7 +2042,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1946
2042
|
next.last_modified = new Date().toISOString();
|
|
1947
2043
|
// Trailing newline — consistent with canvas-create.ts + sync/codec.ts
|
|
1948
2044
|
// (mergeSharedMetaIntoLocal), so a layout edit doesn't churn the newline.
|
|
1949
|
-
|
|
2045
|
+
const baseText = Object.keys(current).length ? JSON.stringify(current) : null;
|
|
2046
|
+
const nextText = `${JSON.stringify(next, null, 2)}\n`;
|
|
2047
|
+
await Bun.write(metaAbs, nextText);
|
|
2048
|
+
hooks.onMetaChanged?.(file, nextText, baseText);
|
|
1950
2049
|
// Same reason as the annotations sidecar below, and the same bug: this
|
|
1951
2050
|
// lane writes the FILE and nothing else, so in a cell — where there is no
|
|
1952
2051
|
// `fs.watch` — the layout never entered the doc and never reached a peer.
|
|
@@ -1986,7 +2085,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1986
2085
|
}
|
|
1987
2086
|
}
|
|
1988
2087
|
|
|
1989
|
-
async function saveAnnotations(
|
|
2088
|
+
async function saveAnnotations(
|
|
2089
|
+
file: string,
|
|
2090
|
+
svg: string,
|
|
2091
|
+
writeId?: string,
|
|
2092
|
+
base?: string
|
|
2093
|
+
): Promise<boolean> {
|
|
1990
2094
|
if (typeof svg !== 'string') return false;
|
|
1991
2095
|
if (svg.length > 1024 * 1024) return false;
|
|
1992
2096
|
// Cheap content gate — must look like an <svg> document. Avoids accidental
|
|
@@ -2004,8 +2108,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2004
2108
|
// (strokesToSvg) is purely presentational — path/rect/ellipse/g/line/
|
|
2005
2109
|
// polyline/text — so stripping executable constructs is zero-regression.
|
|
2006
2110
|
const clean = sanitizeAnnotationSvg(svg);
|
|
2111
|
+
// The edit's base travels only as a merge hint for the project — bounded
|
|
2112
|
+
// and sanitized like the value itself, never written anywhere.
|
|
2113
|
+
const cleanBase =
|
|
2114
|
+
typeof base === 'string' &&
|
|
2115
|
+
base.length <= 1024 * 1024 &&
|
|
2116
|
+
(base === '' || /^\s*<svg[\s>]/i.test(base))
|
|
2117
|
+
? base === ''
|
|
2118
|
+
? ''
|
|
2119
|
+
: sanitizeAnnotationSvg(base)
|
|
2120
|
+
: undefined;
|
|
2007
2121
|
await Bun.write(annotationsPath(file), clean);
|
|
2008
|
-
onAnnotationsChanged?.(file, clean);
|
|
2122
|
+
onAnnotationsChanged?.(file, clean, writeId, cleanBase);
|
|
2009
2123
|
// Annotations reach OTHER VIEWERS over the collab room, which is why this
|
|
2010
2124
|
// never needed an `fs:any`. But the file is also a versioned, file-plane
|
|
2011
2125
|
// sidecar (DDR-115), and the file plane learns about a cell's own writes
|
|
@@ -2016,6 +2130,32 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2016
2130
|
return true;
|
|
2017
2131
|
}
|
|
2018
2132
|
|
|
2133
|
+
async function projectAnnotations(
|
|
2134
|
+
file: string,
|
|
2135
|
+
svg: string,
|
|
2136
|
+
isCurrent: () => boolean
|
|
2137
|
+
): Promise<boolean> {
|
|
2138
|
+
if (typeof svg !== 'string' || svg.length > 1024 * 1024 || !/^\s*<svg[\s>]/i.test(svg))
|
|
2139
|
+
return false;
|
|
2140
|
+
if (!isCurrent()) return false;
|
|
2141
|
+
const clean = sanitizeAnnotationSvg(svg);
|
|
2142
|
+
// Runtime scratch stays out of the file plane. The async IO must not touch
|
|
2143
|
+
// the serving file until we recheck the document; another edit may have
|
|
2144
|
+
// arrived while Bun.write was pending. Check + rename have no await gap.
|
|
2145
|
+
const scratch = path.join(paths.designRoot, '_state');
|
|
2146
|
+
await mkdir(scratch, { recursive: true });
|
|
2147
|
+
const temp = path.join(scratch, `annotations-${crypto.randomUUID()}.tmp`);
|
|
2148
|
+
try {
|
|
2149
|
+
await Bun.write(temp, clean);
|
|
2150
|
+
if (!isCurrent()) return false;
|
|
2151
|
+
renameSync(temp, annotationsPath(file));
|
|
2152
|
+
announceWritten(`${fileSlug(file)}.annotations.svg`);
|
|
2153
|
+
return true;
|
|
2154
|
+
} finally {
|
|
2155
|
+
await rm(temp, { force: true });
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2019
2159
|
// Phase 23 — content-addressed asset write. Reachable from the (potentially
|
|
2020
2160
|
// untrusted, DDR-054) canvas origin, so every cap is load-bearing, NOT
|
|
2021
2161
|
// optional (DDR Task 9):
|
|
@@ -2754,6 +2894,91 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2754
2894
|
return { ok: true, file: path.posix.join(paths.designRel, rel), rel, slug };
|
|
2755
2895
|
}
|
|
2756
2896
|
|
|
2897
|
+
async function duplicateCanvas(input: { file?: unknown }): Promise<CreateCanvasResult> {
|
|
2898
|
+
const raw = input?.file;
|
|
2899
|
+
if (typeof raw !== 'string' || !raw.trim()) {
|
|
2900
|
+
return { ok: false, status: 400, error: 'file is required' };
|
|
2901
|
+
}
|
|
2902
|
+
let rel = raw.trim();
|
|
2903
|
+
try {
|
|
2904
|
+
rel = decodeURIComponent(rel);
|
|
2905
|
+
} catch {
|
|
2906
|
+
/* leave as-is */
|
|
2907
|
+
}
|
|
2908
|
+
rel = rel.replace(/^\/+/, '');
|
|
2909
|
+
const drPrefix = paths.designRel.replace(/^\/+|\/+$/g, '');
|
|
2910
|
+
if (rel.startsWith(`${drPrefix}/`)) rel = rel.slice(drPrefix.length + 1);
|
|
2911
|
+
if (rel.includes('..') || !/\.tsx$/i.test(rel)) {
|
|
2912
|
+
return { ok: false, status: 400, error: 'invalid path' };
|
|
2913
|
+
}
|
|
2914
|
+
const fileAbs = path.resolve(path.join(paths.designRoot, rel));
|
|
2915
|
+
const dirAbs = path.dirname(fileAbs);
|
|
2916
|
+
const groups = cfg.canvasGroups.filter(
|
|
2917
|
+
(g) => g.label !== 'Design system' && !/^system(\/|$)/.test(g.path)
|
|
2918
|
+
);
|
|
2919
|
+
const inGroup = groups.some((g) => {
|
|
2920
|
+
const gAbs = path.resolve(path.join(paths.designRoot, g.path));
|
|
2921
|
+
return dirAbs === gAbs || dirAbs.startsWith(`${gAbs}${path.sep}`);
|
|
2922
|
+
});
|
|
2923
|
+
if (!inGroup) {
|
|
2924
|
+
return {
|
|
2925
|
+
ok: false,
|
|
2926
|
+
status: 400,
|
|
2927
|
+
error: 'only canvases under a managed canvas group can be duplicated',
|
|
2928
|
+
};
|
|
2929
|
+
}
|
|
2930
|
+
if (!(await assertRealpathContained(dirAbs))) {
|
|
2931
|
+
return { ok: false, status: 400, error: 'source path escapes the design root via a symlink' };
|
|
2932
|
+
}
|
|
2933
|
+
if (!(await Bun.file(fileAbs).exists())) {
|
|
2934
|
+
return { ok: false, status: 404, error: 'canvas not found' };
|
|
2935
|
+
}
|
|
2936
|
+
const base = path.basename(rel).replace(/\.tsx$/i, '');
|
|
2937
|
+
const dir = path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel);
|
|
2938
|
+
// "<name> copy", then "<name> copy 2", … — the first free, valid name.
|
|
2939
|
+
let name: string | null = null;
|
|
2940
|
+
for (let n = 1; n < 100 && !name; n++) {
|
|
2941
|
+
const suffix = n === 1 ? ' copy' : ` copy ${n}`;
|
|
2942
|
+
const v = validateCanvasName(`${base.slice(0, 60 - suffix.length)}${suffix}`);
|
|
2943
|
+
if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
|
|
2944
|
+
const candidate = path.posix.join(dir, `${v.name}.tsx`);
|
|
2945
|
+
if (await Bun.file(path.join(paths.designRoot, candidate)).exists()) continue;
|
|
2946
|
+
if (await fileForSlug(fileSlug(candidate))) continue;
|
|
2947
|
+
name = v.name;
|
|
2948
|
+
}
|
|
2949
|
+
if (!name) return { ok: false, status: 409, error: 'too many copies of this canvas' };
|
|
2950
|
+
const toRel = path.posix.join(dir, `${name}.tsx`);
|
|
2951
|
+
const toAbs = path.join(paths.designRoot, toRel);
|
|
2952
|
+
await Bun.write(toAbs, await Bun.file(fileAbs).arrayBuffer());
|
|
2953
|
+
const metaAbs = fileAbs.replace(/\.tsx$/i, '.meta.json');
|
|
2954
|
+
if (await Bun.file(metaAbs).exists()) {
|
|
2955
|
+
let text = await Bun.file(metaAbs).text();
|
|
2956
|
+
try {
|
|
2957
|
+
const meta = JSON.parse(text) as Record<string, unknown>;
|
|
2958
|
+
const now = new Date().toISOString();
|
|
2959
|
+
if (typeof meta.title !== 'string' || meta.title === base) meta.title = name;
|
|
2960
|
+
meta.created = now;
|
|
2961
|
+
meta.last_modified = now;
|
|
2962
|
+
text = `${JSON.stringify(meta, null, 2)}\n`;
|
|
2963
|
+
} catch {
|
|
2964
|
+
/* an unreadable meta is copied verbatim, like the source */
|
|
2965
|
+
}
|
|
2966
|
+
await Bun.write(toAbs.replace(/\.tsx$/i, '.meta.json'), text);
|
|
2967
|
+
}
|
|
2968
|
+
const slug = fileSlug(toRel);
|
|
2969
|
+
// The whiteboard layer is slug-keyed at the design root (canvas-artifacts).
|
|
2970
|
+
const annotationsAbs = path.join(paths.designRoot, `${fileSlug(rel)}.annotations.svg`);
|
|
2971
|
+
if (await Bun.file(annotationsAbs).exists()) {
|
|
2972
|
+
await Bun.write(
|
|
2973
|
+
path.join(paths.designRoot, `${slug}.annotations.svg`),
|
|
2974
|
+
await Bun.file(annotationsAbs).arrayBuffer()
|
|
2975
|
+
);
|
|
2976
|
+
}
|
|
2977
|
+
ctx.bus.emit('canvas-list-update', { action: 'added', rel: toRel, slug });
|
|
2978
|
+
ctx.bus.emit('canvas-created', { slug });
|
|
2979
|
+
return { ok: true, file: path.posix.join(paths.designRel, toRel), rel: toRel, slug };
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2757
2982
|
// Phase 22 — SOFT-delete a canvas (DELETE /_api/canvas). Same trust boundary as
|
|
2758
2983
|
// createCanvas: main-origin-only, never the untrusted canvas iframe origin
|
|
2759
2984
|
// (DDR-054). Destructive, so it MOVES the whole sidecar set to
|
|
@@ -2777,6 +3002,13 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2777
3002
|
|
|
2778
3003
|
// Only a real `.tsx` canvas, no traversal.
|
|
2779
3004
|
if (rel.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
|
|
3005
|
+
if (
|
|
3006
|
+
!/\.tsx$/i.test(rel) &&
|
|
3007
|
+
isSupportingFileRel(rel) &&
|
|
3008
|
+
(await isRegularFile(path.join(paths.designRoot, rel)))
|
|
3009
|
+
) {
|
|
3010
|
+
return deleteSupportingFile(rel);
|
|
3011
|
+
}
|
|
2780
3012
|
if (!/\.tsx$/i.test(rel)) {
|
|
2781
3013
|
// feature-file-tree-drag-drop-folders (follow-up) — a non-.tsx target is
|
|
2782
3014
|
// a folder delete (dogfood gap: the tree offered no way to remove a
|
|
@@ -2886,6 +3118,176 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2886
3118
|
// itself (same `_trash/<stamp>__<slug>/` bundle shape, so a folder delete
|
|
2887
3119
|
// is recoverable exactly like a single-canvas delete), then best-effort
|
|
2888
3120
|
// removes whatever's left (empty subdirs, `.gitkeep` markers).
|
|
3121
|
+
// ── Plan T17/L03 — supporting files ──────────────────────────────────────
|
|
3122
|
+
// Notes, styles, data, images, media and fonts that live in a canvas folder
|
|
3123
|
+
// beside the canvases. They move, rename and delete as themselves. One that a
|
|
3124
|
+
// canvas, stylesheet or whiteboard still names is refused with the name of
|
|
3125
|
+
// the file that uses it: a moved or deleted asset must never leave a broken
|
|
3126
|
+
// image behind silently (L17).
|
|
3127
|
+
async function isRegularFile(abs: string): Promise<boolean> {
|
|
3128
|
+
try {
|
|
3129
|
+
return (await statp(abs)).isFile();
|
|
3130
|
+
} catch {
|
|
3131
|
+
return false;
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
async function supportingFileGuard(
|
|
3136
|
+
rel: string
|
|
3137
|
+
): Promise<{ ok: true; abs: string } | { ok: false; status: number; error: string }> {
|
|
3138
|
+
if (rel.includes('..') || rel.startsWith('/') || isRuntimeStateRel(rel)) {
|
|
3139
|
+
return { ok: false, status: 400, error: 'invalid path' };
|
|
3140
|
+
}
|
|
3141
|
+
const abs = path.resolve(path.join(paths.designRoot, rel));
|
|
3142
|
+
const groups = cfg.canvasGroups.filter(
|
|
3143
|
+
(g) => g.label !== 'Design system' && !/^system(\/|$)/.test(g.path)
|
|
3144
|
+
);
|
|
3145
|
+
const inGroup = groups.some((g) =>
|
|
3146
|
+
abs.startsWith(`${path.resolve(path.join(paths.designRoot, g.path))}${path.sep}`)
|
|
3147
|
+
);
|
|
3148
|
+
if (!inGroup) {
|
|
3149
|
+
return {
|
|
3150
|
+
ok: false,
|
|
3151
|
+
status: 400,
|
|
3152
|
+
error: 'only files inside a canvas folder can be changed here',
|
|
3153
|
+
};
|
|
3154
|
+
}
|
|
3155
|
+
if (!(await assertRealpathContained(path.dirname(abs)))) {
|
|
3156
|
+
return { ok: false, status: 400, error: 'path escapes the design root via a symlink' };
|
|
3157
|
+
}
|
|
3158
|
+
if (!(await isRegularFile(abs))) return { ok: false, status: 404, error: 'file not found' };
|
|
3159
|
+
return { ok: true, abs };
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
/** The first project file (canvas, stylesheet, meta, whiteboard) that names `rel`'s file. */
|
|
3163
|
+
async function firstReferenceTo(rel: string): Promise<string | null> {
|
|
3164
|
+
const base = path.posix.basename(rel);
|
|
3165
|
+
const { readdir, readFile } = await import('node:fs/promises');
|
|
3166
|
+
const walk = async (dirAbs: string, depth: number): Promise<string | null> => {
|
|
3167
|
+
if (depth > 12) return null;
|
|
3168
|
+
let entries: import('node:fs').Dirent[];
|
|
3169
|
+
try {
|
|
3170
|
+
entries = await readdir(dirAbs, { withFileTypes: true });
|
|
3171
|
+
} catch {
|
|
3172
|
+
return null;
|
|
3173
|
+
}
|
|
3174
|
+
for (const e of entries) {
|
|
3175
|
+
if (e.name.startsWith('_') || e.name.startsWith('.') || e.name === 'node_modules') continue;
|
|
3176
|
+
const abs = path.join(dirAbs, e.name);
|
|
3177
|
+
if (e.isDirectory()) {
|
|
3178
|
+
const hit = await walk(abs, depth + 1);
|
|
3179
|
+
if (hit) return hit;
|
|
3180
|
+
} else if (/\.(tsx|jsx|css|meta\.json|annotations\.svg)$/i.test(e.name)) {
|
|
3181
|
+
const other = path.relative(paths.designRoot, abs).split(path.sep).join('/');
|
|
3182
|
+
if (other === rel) continue;
|
|
3183
|
+
const text = await readFile(abs, 'utf8').catch(() => '');
|
|
3184
|
+
if (text.includes(base)) return other;
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
return null;
|
|
3188
|
+
};
|
|
3189
|
+
return walk(paths.designRoot, 0);
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
async function moveSupportingFile(
|
|
3193
|
+
rel: string,
|
|
3194
|
+
toDirRaw: unknown,
|
|
3195
|
+
toNameRaw: unknown
|
|
3196
|
+
): Promise<MoveCanvasResult> {
|
|
3197
|
+
const g = await supportingFileGuard(rel);
|
|
3198
|
+
if (!g.ok) return g;
|
|
3199
|
+
const ext = path.posix.extname(rel);
|
|
3200
|
+
let base = path.posix.basename(rel);
|
|
3201
|
+
if (toNameRaw !== undefined && toNameRaw !== null && toNameRaw !== '') {
|
|
3202
|
+
const stem = String(toNameRaw).replace(new RegExp(`${ext.replace('.', '\\.')}$`, 'i'), '');
|
|
3203
|
+
const v = validateCanvasName(stem);
|
|
3204
|
+
if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
|
|
3205
|
+
base = `${v.name}${ext}`;
|
|
3206
|
+
}
|
|
3207
|
+
const drPrefix = paths.designRel.replace(/^\/+|\/+$/g, '');
|
|
3208
|
+
let toDir =
|
|
3209
|
+
typeof toDirRaw === 'string'
|
|
3210
|
+
? toDirRaw.trim().replace(/^\/+|\/+$/g, '')
|
|
3211
|
+
: path.posix.dirname(rel);
|
|
3212
|
+
if (toDir === drPrefix || toDir === '.') toDir = '';
|
|
3213
|
+
else if (toDir.startsWith(`${drPrefix}/`)) toDir = toDir.slice(drPrefix.length + 1);
|
|
3214
|
+
const toRel = path.posix.join(toDir, base);
|
|
3215
|
+
if (toRel === rel)
|
|
3216
|
+
return { ok: false, status: 400, error: 'source and destination are the same' };
|
|
3217
|
+
const dest = await supportingFileGuard(toRel).then((d) =>
|
|
3218
|
+
d.ok
|
|
3219
|
+
? { ok: false as const, status: 409, error: `a file named "${base}" already exists there` }
|
|
3220
|
+
: d
|
|
3221
|
+
);
|
|
3222
|
+
if (!dest.ok && dest.status !== 404) return dest;
|
|
3223
|
+
const toAbs = path.resolve(path.join(paths.designRoot, toRel));
|
|
3224
|
+
if (!(await isRegularFile(path.join(paths.designRoot, rel))))
|
|
3225
|
+
return { ok: false, status: 404, error: 'file not found' };
|
|
3226
|
+
const toDirAbs = path.dirname(toAbs);
|
|
3227
|
+
try {
|
|
3228
|
+
if (!(await statp(toDirAbs)).isDirectory()) throw new Error('not a folder');
|
|
3229
|
+
} catch {
|
|
3230
|
+
return { ok: false, status: 400, error: 'destination folder does not exist' };
|
|
3231
|
+
}
|
|
3232
|
+
const user = await firstReferenceTo(rel);
|
|
3233
|
+
if (user) {
|
|
3234
|
+
return {
|
|
3235
|
+
ok: false,
|
|
3236
|
+
status: 409,
|
|
3237
|
+
error: `${path.posix.basename(rel)} is used by ${user} — change that first`,
|
|
3238
|
+
};
|
|
3239
|
+
}
|
|
3240
|
+
await rename(g.abs, toAbs);
|
|
3241
|
+
// A hub's own studio has no watcher to see this rename: announce both
|
|
3242
|
+
// ends, or the journal learns of it only from the walk-import belt and a
|
|
3243
|
+
// teammate waits minutes for a rename that took a second here.
|
|
3244
|
+
announceWritten(rel);
|
|
3245
|
+
announceWritten(toRel);
|
|
3246
|
+
ctx.bus.emit('canvas-list-update', {
|
|
3247
|
+
action: 'moved',
|
|
3248
|
+
rel: toRel,
|
|
3249
|
+
slug: fileSlug(toRel),
|
|
3250
|
+
fromRel: rel,
|
|
3251
|
+
fromSlug: fileSlug(rel),
|
|
3252
|
+
});
|
|
3253
|
+
return {
|
|
3254
|
+
ok: true,
|
|
3255
|
+
fromRel: rel,
|
|
3256
|
+
toRel,
|
|
3257
|
+
fromSlug: fileSlug(rel),
|
|
3258
|
+
toSlug: fileSlug(toRel),
|
|
3259
|
+
moved: [toRel],
|
|
3260
|
+
};
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
async function deleteSupportingFile(rel: string): Promise<DeleteCanvasResult> {
|
|
3264
|
+
const g = await supportingFileGuard(rel);
|
|
3265
|
+
if (!g.ok) return g;
|
|
3266
|
+
const user = await firstReferenceTo(rel);
|
|
3267
|
+
if (user) {
|
|
3268
|
+
return {
|
|
3269
|
+
ok: false,
|
|
3270
|
+
status: 409,
|
|
3271
|
+
error: `${path.posix.basename(rel)} is used by ${user} — remove it there first`,
|
|
3272
|
+
};
|
|
3273
|
+
}
|
|
3274
|
+
const slug = fileSlug(rel);
|
|
3275
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
3276
|
+
const trashDir = path.join(paths.designRoot, '_trash', `${stamp}__${slug}`);
|
|
3277
|
+
await mkdir(trashDir, { recursive: true });
|
|
3278
|
+
const trashed = path.join(trashDir, path.posix.basename(rel));
|
|
3279
|
+
await rename(g.abs, trashed);
|
|
3280
|
+
announceWritten(rel);
|
|
3281
|
+
ctx.bus.emit('canvas-list-update', { action: 'removed', rel, slug });
|
|
3282
|
+
return {
|
|
3283
|
+
ok: true,
|
|
3284
|
+
rel,
|
|
3285
|
+
slug,
|
|
3286
|
+
trashed: [path.relative(paths.repoRoot, trashed)],
|
|
3287
|
+
trashDir: path.relative(paths.repoRoot, trashDir),
|
|
3288
|
+
};
|
|
3289
|
+
}
|
|
3290
|
+
|
|
2889
3291
|
async function deleteFolder(relDir: string): Promise<DeleteCanvasResult> {
|
|
2890
3292
|
if (relDir.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
|
|
2891
3293
|
const dirAbs = path.join(paths.designRoot, relDir);
|
|
@@ -2942,6 +3344,21 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2942
3344
|
p.startsWith(`${drPrefix}/`) ? p.slice(drPrefix.length + 1) : p
|
|
2943
3345
|
);
|
|
2944
3346
|
|
|
3347
|
+
// ONE project action for the whole folder (accepted-revisions mode): the
|
|
3348
|
+
// hub deletes every canvas under it and the folder entry together, so a
|
|
3349
|
+
// peer never sees half a folder, and history shows one step.
|
|
3350
|
+
const folderAction = await hooks.proposeFolder?.({
|
|
3351
|
+
op: 'dir.delete',
|
|
3352
|
+
path: relDir.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''),
|
|
3353
|
+
});
|
|
3354
|
+
if (folderAction?.status === 'rejected') {
|
|
3355
|
+
return {
|
|
3356
|
+
ok: false,
|
|
3357
|
+
status: 409,
|
|
3358
|
+
error: `the project did not accept deleting this folder (${folderAction.code ?? 'rejected'})`,
|
|
3359
|
+
};
|
|
3360
|
+
}
|
|
3361
|
+
|
|
2945
3362
|
const trashed: string[] = [];
|
|
2946
3363
|
let lastTrashDir = '';
|
|
2947
3364
|
for (const r of canvasRels) {
|
|
@@ -3051,7 +3468,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3051
3468
|
// never wrong about where the canvas lives) and every relocation is logged
|
|
3052
3469
|
// to `_history/<toSlug>/_move.json` for forensic recovery. See the DDR
|
|
3053
3470
|
// (Task 14) for the accepted-limitation writeup.
|
|
3054
|
-
async function moveCanvas(input: {
|
|
3471
|
+
async function moveCanvas(input: {
|
|
3472
|
+
file?: unknown;
|
|
3473
|
+
toDir?: unknown;
|
|
3474
|
+
toName?: unknown;
|
|
3475
|
+
}): Promise<MoveCanvasResult> {
|
|
3055
3476
|
const raw = input?.file;
|
|
3056
3477
|
if (typeof raw !== 'string' || !raw.trim()) {
|
|
3057
3478
|
return { ok: false, status: 400, error: 'file is required' };
|
|
@@ -3067,17 +3488,38 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3067
3488
|
if (rel.startsWith(`${drPrefix}/`)) rel = rel.slice(drPrefix.length + 1);
|
|
3068
3489
|
if (rel.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
|
|
3069
3490
|
if (!/\.tsx$/i.test(rel)) {
|
|
3491
|
+
// Plan T17/L03 — a supporting file (notes, styles, images, media in a
|
|
3492
|
+
// canvas folder) moves or renames as itself, never as a "folder".
|
|
3493
|
+
if (isSupportingFileRel(rel) && (await isRegularFile(path.join(paths.designRoot, rel)))) {
|
|
3494
|
+
return moveSupportingFile(rel, input?.toDir, input?.toName);
|
|
3495
|
+
}
|
|
3070
3496
|
// feature-file-tree-drag-drop-folders (Task 11) — a non-.tsx source is a
|
|
3071
3497
|
// folder move (dragging a folder onto a folder). moveFolder does its own
|
|
3072
3498
|
// existence + containment validation, so an invalid path still reports
|
|
3073
3499
|
// the right error from there.
|
|
3074
|
-
return moveFolder(rel, input?.toDir);
|
|
3075
|
-
}
|
|
3076
|
-
|
|
3077
|
-
|
|
3500
|
+
return moveFolder(rel, input?.toDir, input?.toName);
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
// A rename is a move in place under a new name (`toName`, same allowlist as
|
|
3504
|
+
// a new canvas's name); `toDir` defaults to the canvas's own folder then.
|
|
3505
|
+
let newBase: string | null = null;
|
|
3506
|
+
if (input?.toName !== undefined && input.toName !== null && input.toName !== '') {
|
|
3507
|
+
const v = validateCanvasName(input.toName);
|
|
3508
|
+
if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
|
|
3509
|
+
newBase = `${v.name}.tsx`;
|
|
3510
|
+
}
|
|
3511
|
+
const toDirInput =
|
|
3512
|
+
typeof input?.toDir === 'string'
|
|
3513
|
+
? input.toDir
|
|
3514
|
+
: newBase
|
|
3515
|
+
? path.posix.dirname(rel) === '.'
|
|
3516
|
+
? ''
|
|
3517
|
+
: path.posix.dirname(rel)
|
|
3518
|
+
: null;
|
|
3519
|
+
if (typeof toDirInput !== 'string') {
|
|
3078
3520
|
return { ok: false, status: 400, error: 'toDir is required' };
|
|
3079
3521
|
}
|
|
3080
|
-
let toDir =
|
|
3522
|
+
let toDir = toDirInput.trim().replace(/^\/+|\/+$/g, '');
|
|
3081
3523
|
try {
|
|
3082
3524
|
toDir = decodeURIComponent(toDir);
|
|
3083
3525
|
} catch {
|
|
@@ -3145,7 +3587,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3145
3587
|
return { ok: false, status: 404, error: 'canvas not found' };
|
|
3146
3588
|
}
|
|
3147
3589
|
|
|
3148
|
-
const base = path.basename(rel);
|
|
3590
|
+
const base = newBase ?? path.basename(rel);
|
|
3149
3591
|
const toRel = path.posix.join(toDir, base);
|
|
3150
3592
|
if (path.posix.normalize(toRel) === path.posix.normalize(rel)) {
|
|
3151
3593
|
return { ok: false, status: 400, error: 'source and destination are the same' };
|
|
@@ -3313,7 +3755,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3313
3755
|
// nested canvas at once (they live inside the moved directory); only the
|
|
3314
3756
|
// slug-keyed sidecars (flat dirs like `_history/<slug>/`, not nested by
|
|
3315
3757
|
// folder structure) need their own per-canvas relocation afterward.
|
|
3316
|
-
async function moveFolder(
|
|
3758
|
+
async function moveFolder(
|
|
3759
|
+
relDir: string,
|
|
3760
|
+
toDirRaw: unknown,
|
|
3761
|
+
toNameRaw?: unknown
|
|
3762
|
+
): Promise<MoveCanvasResult> {
|
|
3317
3763
|
if (relDir.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
|
|
3318
3764
|
const dirAbs = path.join(paths.designRoot, relDir);
|
|
3319
3765
|
const resolvedDesignRoot = path.resolve(paths.designRoot);
|
|
@@ -3389,7 +3835,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3389
3835
|
return { ok: false, status: 400, error: 'cannot move a folder into itself' };
|
|
3390
3836
|
}
|
|
3391
3837
|
|
|
3392
|
-
|
|
3838
|
+
// A RENAME is a move to a new name in the same (or another) parent — one
|
|
3839
|
+
// manifest action (dir.move), the same as any folder move (plan T17).
|
|
3840
|
+
let base = path.basename(relDir);
|
|
3841
|
+
if (toNameRaw !== undefined && toNameRaw !== null && toNameRaw !== '') {
|
|
3842
|
+
const v = validateFolderName(toNameRaw);
|
|
3843
|
+
if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
|
|
3844
|
+
base = v.name;
|
|
3845
|
+
}
|
|
3393
3846
|
const toRelDir = path.posix.join(toDir, base);
|
|
3394
3847
|
if (path.posix.normalize(toRelDir) === path.posix.normalize(relDir)) {
|
|
3395
3848
|
return { ok: false, status: 400, error: 'source and destination are the same' };
|
|
@@ -3443,6 +3896,22 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3443
3896
|
}
|
|
3444
3897
|
}
|
|
3445
3898
|
|
|
3899
|
+
// ONE project action for the whole folder (accepted-revisions mode) —
|
|
3900
|
+
// canvases, empty sub-folders and all. The per-canvas retire below then
|
|
3901
|
+
// only lets go locally; it proposes nothing of its own.
|
|
3902
|
+
const folderMove = await hooks.proposeFolder?.({
|
|
3903
|
+
op: 'dir.move',
|
|
3904
|
+
from: relDir.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''),
|
|
3905
|
+
to: toRelDir,
|
|
3906
|
+
});
|
|
3907
|
+
if (folderMove?.status === 'rejected') {
|
|
3908
|
+
return {
|
|
3909
|
+
ok: false,
|
|
3910
|
+
status: 409,
|
|
3911
|
+
error: `the project did not accept this move (${folderMove.code ?? 'rejected'})`,
|
|
3912
|
+
};
|
|
3913
|
+
}
|
|
3914
|
+
|
|
3446
3915
|
// Collab guard for every canvas found, BEFORE any disk mutation. Same
|
|
3447
3916
|
// coordinated retire as moveCanvas — refuse only when a pinned room could
|
|
3448
3917
|
// not be retired, and retire even unpinned synced documents so none of
|
|
@@ -3464,6 +3933,17 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3464
3933
|
}
|
|
3465
3934
|
|
|
3466
3935
|
await mkdir(toDirAbs, { recursive: true });
|
|
3936
|
+
// OUR OWN ACTION MAY ALREADY BE ON THIS DISK. The destination did not exist
|
|
3937
|
+
// when this move was checked; the project accepted the move since, and its
|
|
3938
|
+
// announcement can reach this machine's sync before the answer reaches this
|
|
3939
|
+
// request — which materializes the destination folders (each with its
|
|
3940
|
+
// `.gitkeep`). Renaming onto that non-empty placeholder failed (ENOTEMPTY),
|
|
3941
|
+
// leaving the old folder in this person's tree while every teammate had
|
|
3942
|
+
// the move (plan T31/L02). A destination holding nothing but placeholders
|
|
3943
|
+
// is that projection; it is replaced, never merged into.
|
|
3944
|
+
if (await onlyFolderPlaceholders(toDirFinalAbs)) {
|
|
3945
|
+
await rm(toDirFinalAbs, { recursive: true, force: true });
|
|
3946
|
+
}
|
|
3467
3947
|
try {
|
|
3468
3948
|
await rename(dirAbs, toDirFinalAbs);
|
|
3469
3949
|
} catch (err) {
|
|
@@ -3612,6 +4092,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3612
4092
|
/* good — doesn't exist yet */
|
|
3613
4093
|
}
|
|
3614
4094
|
|
|
4095
|
+
const folderCreate = await hooks.proposeFolder?.({
|
|
4096
|
+
op: 'dir.create',
|
|
4097
|
+
path: path.posix.join(parent, v.name),
|
|
4098
|
+
});
|
|
4099
|
+
if (folderCreate?.status === 'rejected') {
|
|
4100
|
+
return {
|
|
4101
|
+
ok: false,
|
|
4102
|
+
status: 409,
|
|
4103
|
+
error: `the project did not accept this folder (${folderCreate.code ?? 'rejected'})`,
|
|
4104
|
+
};
|
|
4105
|
+
}
|
|
4106
|
+
|
|
3615
4107
|
await mkdir(dirAbs, { recursive: true });
|
|
3616
4108
|
await Bun.write(path.join(dirAbs, '.gitkeep'), '');
|
|
3617
4109
|
|
|
@@ -3689,10 +4181,22 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3689
4181
|
// suppression BEFORE the write so it catches the debounced fs:any, disarm on
|
|
3690
4182
|
// a no-op (delta 0 = no write) or throw so a failed edit can't mute the next
|
|
3691
4183
|
// genuine agent edit's rim.
|
|
4184
|
+
/** T24/T25 — say what a structural edit IS (sync/source-ops), for replay. */
|
|
4185
|
+
function announceOp(
|
|
4186
|
+
abs: string,
|
|
4187
|
+
rel: string,
|
|
4188
|
+
before: string,
|
|
4189
|
+
op: Parameters<typeof describeSourceOp>[2]
|
|
4190
|
+
): void {
|
|
4191
|
+
const described = describeSourceOp(abs, before, op);
|
|
4192
|
+
if (described) ctx.bus.emit('source-op', { rel, op: described });
|
|
4193
|
+
}
|
|
4194
|
+
|
|
3692
4195
|
async function suppressedEdit(
|
|
3693
4196
|
abs: string,
|
|
3694
|
-
run: () => Promise<{ delta: number; changed?: boolean }>,
|
|
3695
|
-
errLabel: string
|
|
4197
|
+
run: () => Promise<{ delta: number; changed?: boolean; previous?: AttributeState }>,
|
|
4198
|
+
errLabel: string,
|
|
4199
|
+
describe?: Parameters<typeof describeSourceOp>[2]
|
|
3696
4200
|
): Promise<EditOpResult> {
|
|
3697
4201
|
const rel = path.relative(paths.designRoot, abs);
|
|
3698
4202
|
ctx.bus.emit('activity:suppress', rel);
|
|
@@ -3701,18 +4205,33 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3701
4205
|
// whole-file undo log (the Timeline replace-src path rides this; other
|
|
3702
4206
|
// inspector edits get an undoable seq for free).
|
|
3703
4207
|
const before = await Bun.file(abs).text();
|
|
4208
|
+
// T24 — what this edit IS, so a lost race re-applies it instead of
|
|
4209
|
+
// turning "we both picked a colour" into a conflict.
|
|
4210
|
+
if (describe) {
|
|
4211
|
+
const op = describeSourceOp(abs, before, describe);
|
|
4212
|
+
if (op) ctx.bus.emit('source-op', { rel, op });
|
|
4213
|
+
}
|
|
3704
4214
|
const res = await run();
|
|
4215
|
+
const previous =
|
|
4216
|
+
res.previous?.kind === 'absent'
|
|
4217
|
+
? { previous: null }
|
|
4218
|
+
: res.previous?.kind === 'literal'
|
|
4219
|
+
? { previous: res.previous.value }
|
|
4220
|
+
: {};
|
|
3705
4221
|
// `changed === false` = the op collapsed to a no-op and nothing hit disk —
|
|
3706
4222
|
// NOT `delta === 0`, which an equal-length replacement also produces.
|
|
3707
4223
|
if (res.changed === false) {
|
|
3708
4224
|
ctx.bus.emit('activity:unsuppress', rel);
|
|
3709
|
-
return { ok: true, delta: res.delta };
|
|
4225
|
+
return { ok: true, delta: res.delta, ...previous };
|
|
3710
4226
|
}
|
|
3711
4227
|
const after = await Bun.file(abs).text();
|
|
3712
4228
|
const seq = after !== before ? logUndo(abs, before, after) : undefined;
|
|
3713
|
-
return { ok: true, delta: res.delta, seq };
|
|
4229
|
+
return { ok: true, delta: res.delta, seq, ...previous };
|
|
3714
4230
|
} catch (err) {
|
|
3715
4231
|
ctx.bus.emit('activity:unsuppress', rel);
|
|
4232
|
+
if (err instanceof CanvasEditError && err.conflict) {
|
|
4233
|
+
return { ok: false, status: 409, error: err.message, conflict: true };
|
|
4234
|
+
}
|
|
3716
4235
|
return {
|
|
3717
4236
|
ok: false,
|
|
3718
4237
|
status: 422,
|
|
@@ -3728,9 +4247,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3728
4247
|
value?: unknown;
|
|
3729
4248
|
reset?: unknown;
|
|
3730
4249
|
idIndex?: unknown;
|
|
4250
|
+
expected?: unknown;
|
|
3731
4251
|
}): Promise<EditOpResult> {
|
|
3732
4252
|
const r = resolveCanvasAbs(input.canvas);
|
|
3733
4253
|
if (!r.ok) return r;
|
|
4254
|
+
const pre = expectedValueOf(input);
|
|
4255
|
+
if (!pre.ok) return { ok: false, status: 400, error: 'invalid expected value' };
|
|
3734
4256
|
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
3735
4257
|
if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
|
|
3736
4258
|
// Stage H3 — optional DOM-occurrence index. Present only for a whole-instance
|
|
@@ -3755,8 +4277,21 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3755
4277
|
if (input.reset === true) {
|
|
3756
4278
|
return suppressedEdit(
|
|
3757
4279
|
r.abs,
|
|
3758
|
-
() =>
|
|
3759
|
-
|
|
4280
|
+
() =>
|
|
4281
|
+
removeAttribute(
|
|
4282
|
+
r.abs,
|
|
4283
|
+
id,
|
|
4284
|
+
`style.${camel}`,
|
|
4285
|
+
idIndex,
|
|
4286
|
+
pre.expected === undefined ? undefined : { expected: pre.expected, next: null }
|
|
4287
|
+
),
|
|
4288
|
+
'reset failed',
|
|
4289
|
+
{
|
|
4290
|
+
kind: 'remove',
|
|
4291
|
+
id,
|
|
4292
|
+
attr: `style.${camel}`,
|
|
4293
|
+
...(idIndex !== undefined ? { occurrence: idIndex } : {}),
|
|
4294
|
+
}
|
|
3760
4295
|
);
|
|
3761
4296
|
}
|
|
3762
4297
|
const value = typeof input.value === 'string' ? input.value : '';
|
|
@@ -3768,8 +4303,23 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3768
4303
|
// `#fff`, `8px`, `700`, `1.5` all ride verbatim.
|
|
3769
4304
|
return suppressedEdit(
|
|
3770
4305
|
r.abs,
|
|
3771
|
-
() =>
|
|
3772
|
-
|
|
4306
|
+
() =>
|
|
4307
|
+
editAttribute(
|
|
4308
|
+
r.abs,
|
|
4309
|
+
id,
|
|
4310
|
+
`style.${camel}`,
|
|
4311
|
+
JSON.stringify(value),
|
|
4312
|
+
idIndex,
|
|
4313
|
+
pre.expected === undefined ? undefined : { expected: pre.expected, next: value }
|
|
4314
|
+
),
|
|
4315
|
+
'edit failed',
|
|
4316
|
+
{
|
|
4317
|
+
kind: 'set',
|
|
4318
|
+
id,
|
|
4319
|
+
attr: `style.${camel}`,
|
|
4320
|
+
value: JSON.stringify(value),
|
|
4321
|
+
...(idIndex !== undefined ? { occurrence: idIndex } : {}),
|
|
4322
|
+
}
|
|
3773
4323
|
);
|
|
3774
4324
|
}
|
|
3775
4325
|
|
|
@@ -3802,7 +4352,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3802
4352
|
return suppressedEdit(
|
|
3803
4353
|
r.abs,
|
|
3804
4354
|
() => runEditText(r.abs, id, text, { occurrence, before }),
|
|
3805
|
-
'edit failed'
|
|
4355
|
+
'edit failed',
|
|
4356
|
+
{ kind: 'text', id, text, ...(occurrence !== undefined ? { occurrence } : {}) }
|
|
3806
4357
|
);
|
|
3807
4358
|
}
|
|
3808
4359
|
|
|
@@ -3812,9 +4363,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3812
4363
|
attr?: unknown;
|
|
3813
4364
|
value?: unknown;
|
|
3814
4365
|
reset?: unknown;
|
|
4366
|
+
expected?: unknown;
|
|
3815
4367
|
}): Promise<EditOpResult> {
|
|
3816
4368
|
const r = resolveCanvasAbs(input.canvas);
|
|
3817
4369
|
if (!r.ok) return r;
|
|
4370
|
+
const pre = expectedValueOf(input);
|
|
4371
|
+
if (!pre.ok) return { ok: false, status: 400, error: 'invalid expected value' };
|
|
3818
4372
|
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
3819
4373
|
if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
|
|
3820
4374
|
const attr = typeof input.attr === 'string' ? input.attr.trim() : '';
|
|
@@ -3832,7 +4386,19 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3832
4386
|
}
|
|
3833
4387
|
// Phase 12.3 — `reset: true` REMOVES the custom attribute. No-op if absent.
|
|
3834
4388
|
if (input.reset === true) {
|
|
3835
|
-
return suppressedEdit(
|
|
4389
|
+
return suppressedEdit(
|
|
4390
|
+
r.abs,
|
|
4391
|
+
() =>
|
|
4392
|
+
removeAttribute(
|
|
4393
|
+
r.abs,
|
|
4394
|
+
id,
|
|
4395
|
+
attr,
|
|
4396
|
+
undefined,
|
|
4397
|
+
pre.expected === undefined ? undefined : { expected: pre.expected, next: null }
|
|
4398
|
+
),
|
|
4399
|
+
'reset failed',
|
|
4400
|
+
{ kind: 'remove', id, attr }
|
|
4401
|
+
);
|
|
3836
4402
|
}
|
|
3837
4403
|
const value = typeof input.value === 'string' ? input.value : '';
|
|
3838
4404
|
if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
|
|
@@ -3853,7 +4419,20 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3853
4419
|
// Pass the value RAW: editStringAttr quotes/escapes it itself (JSON.stringify
|
|
3854
4420
|
// on replace, escapeAttr on insert) — pre-stringifying here double-encoded
|
|
3855
4421
|
// the value (`data-x="\"ok\""`; knob-smoke finding, 2026-06-12).
|
|
3856
|
-
return suppressedEdit(
|
|
4422
|
+
return suppressedEdit(
|
|
4423
|
+
r.abs,
|
|
4424
|
+
() =>
|
|
4425
|
+
editAttribute(
|
|
4426
|
+
r.abs,
|
|
4427
|
+
id,
|
|
4428
|
+
attr,
|
|
4429
|
+
value,
|
|
4430
|
+
undefined,
|
|
4431
|
+
pre.expected === undefined ? undefined : { expected: pre.expected, next: value }
|
|
4432
|
+
),
|
|
4433
|
+
'edit failed',
|
|
4434
|
+
{ kind: 'set', id, attr, value }
|
|
4435
|
+
);
|
|
3857
4436
|
}
|
|
3858
4437
|
|
|
3859
4438
|
// Phase 12.1 (DDR-138) — snapshot stack so a reorder is undoable via
|
|
@@ -3900,6 +4479,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3900
4479
|
ctx.bus.emit('activity:suppress', rel);
|
|
3901
4480
|
try {
|
|
3902
4481
|
const before = await Bun.file(r.abs).text();
|
|
4482
|
+
announceOp(r.abs, rel, before, {
|
|
4483
|
+
kind: 'move',
|
|
4484
|
+
id,
|
|
4485
|
+
refId,
|
|
4486
|
+
position: String(position),
|
|
4487
|
+
...(idIndex !== undefined ? { idIndex } : {}),
|
|
4488
|
+
...(refIndex !== undefined ? { refIndex } : {}),
|
|
4489
|
+
});
|
|
3903
4490
|
const res = await moveElement(r.abs, id, refId, position as MovePosition, idIndex, refIndex);
|
|
3904
4491
|
const after = await Bun.file(r.abs).text();
|
|
3905
4492
|
if (after === before) {
|
|
@@ -4315,6 +4902,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4315
4902
|
ctx.bus.emit('activity:suppress', rel);
|
|
4316
4903
|
try {
|
|
4317
4904
|
const before = await Bun.file(r.abs).text();
|
|
4905
|
+
announceOp(r.abs, rel, before, {
|
|
4906
|
+
kind: 'delete',
|
|
4907
|
+
id,
|
|
4908
|
+
...(idIndex !== undefined ? { occurrence: idIndex } : {}),
|
|
4909
|
+
});
|
|
4318
4910
|
const res = await deleteElement(r.abs, id, idIndex);
|
|
4319
4911
|
const after = await Bun.file(r.abs).text();
|
|
4320
4912
|
if (after === before) {
|
|
@@ -4780,6 +5372,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4780
5372
|
ctx.bus.emit('activity:suppress', rel);
|
|
4781
5373
|
try {
|
|
4782
5374
|
const before = await Bun.file(r.abs).text();
|
|
5375
|
+
announceOp(r.abs, rel, before, {
|
|
5376
|
+
kind: 'artboard',
|
|
5377
|
+
fn: 'resize',
|
|
5378
|
+
artboardId,
|
|
5379
|
+
args: [width, height],
|
|
5380
|
+
});
|
|
4783
5381
|
await resizeArtboard(r.abs, artboardId, width, height);
|
|
4784
5382
|
const after = await Bun.file(r.abs).text();
|
|
4785
5383
|
if (after === before) {
|
|
@@ -4827,6 +5425,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4827
5425
|
ctx.bus.emit('activity:suppress', rel);
|
|
4828
5426
|
try {
|
|
4829
5427
|
const before = await Bun.file(r.abs).text();
|
|
5428
|
+
announceOp(r.abs, rel, before, {
|
|
5429
|
+
kind: 'artboard',
|
|
5430
|
+
fn: 'hug',
|
|
5431
|
+
artboardId,
|
|
5432
|
+
args: [fixed, freezeHeight],
|
|
5433
|
+
});
|
|
4830
5434
|
await setArtboardHug(r.abs, artboardId, fixed, freezeHeight);
|
|
4831
5435
|
const after = await Bun.file(r.abs).text();
|
|
4832
5436
|
if (after === before) {
|
|
@@ -4876,6 +5480,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4876
5480
|
ctx.bus.emit('activity:suppress', rel);
|
|
4877
5481
|
try {
|
|
4878
5482
|
const before = await Bun.file(r.abs).text();
|
|
5483
|
+
announceOp(r.abs, rel, before, { kind: 'artboard', fn: 'kind', artboardId, args: [kind] });
|
|
4879
5484
|
await setArtboardKind(r.abs, artboardId, kind);
|
|
4880
5485
|
const after = await Bun.file(r.abs).text();
|
|
4881
5486
|
if (after === before) {
|
|
@@ -4898,6 +5503,50 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4898
5503
|
}
|
|
4899
5504
|
}
|
|
4900
5505
|
|
|
5506
|
+
/** Rename an artboard (plan T25/L08 — double-click its name). */
|
|
5507
|
+
async function setArtboardLabelOp(input: {
|
|
5508
|
+
canvas?: unknown;
|
|
5509
|
+
artboardId?: unknown;
|
|
5510
|
+
label?: unknown;
|
|
5511
|
+
}): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
|
|
5512
|
+
const r = resolveCanvasAbs(input.canvas);
|
|
5513
|
+
if (!r.ok) return r;
|
|
5514
|
+
if (!takeStructuralToken()) return RATE_LIMITED;
|
|
5515
|
+
const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
|
|
5516
|
+
if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
|
|
5517
|
+
return { ok: false, status: 400, error: 'invalid artboard id' };
|
|
5518
|
+
}
|
|
5519
|
+
if (typeof input.label !== 'string' || !input.label.trim()) {
|
|
5520
|
+
return { ok: false, status: 400, error: 'name is required' };
|
|
5521
|
+
}
|
|
5522
|
+
const label = input.label;
|
|
5523
|
+
const rel = path.relative(paths.designRoot, r.abs);
|
|
5524
|
+
ctx.bus.emit('activity:suppress', rel);
|
|
5525
|
+
try {
|
|
5526
|
+
const before = await Bun.file(r.abs).text();
|
|
5527
|
+
announceOp(r.abs, rel, before, { kind: 'artboard', fn: 'label', artboardId, args: [label] });
|
|
5528
|
+
await setArtboardLabel(r.abs, artboardId, label);
|
|
5529
|
+
const after = await Bun.file(r.abs).text();
|
|
5530
|
+
if (after === before) {
|
|
5531
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
5532
|
+
return { ok: true };
|
|
5533
|
+
}
|
|
5534
|
+
try {
|
|
5535
|
+
await history.writeSnapshot(rel, before, 'pre-set-artboard-label');
|
|
5536
|
+
} catch {
|
|
5537
|
+
/* snapshot best-effort */
|
|
5538
|
+
}
|
|
5539
|
+
return { ok: true, seq: logUndo(r.abs, before, after) };
|
|
5540
|
+
} catch (err) {
|
|
5541
|
+
ctx.bus.emit('activity:unsuppress', rel);
|
|
5542
|
+
return {
|
|
5543
|
+
ok: false,
|
|
5544
|
+
status: err instanceof CanvasEditError ? 422 : 500,
|
|
5545
|
+
error: err instanceof Error ? err.message : 'set-artboard-label failed',
|
|
5546
|
+
};
|
|
5547
|
+
}
|
|
5548
|
+
}
|
|
5549
|
+
|
|
4901
5550
|
const MAX_GUIDES_JSON_BYTES = 4096;
|
|
4902
5551
|
|
|
4903
5552
|
/** Generic layout guides (T5) — Inspector/skill writer. Replace-whole-prop,
|
|
@@ -4931,6 +5580,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4931
5580
|
ctx.bus.emit('activity:suppress', rel);
|
|
4932
5581
|
try {
|
|
4933
5582
|
const before = await Bun.file(r.abs).text();
|
|
5583
|
+
announceOp(r.abs, rel, before, {
|
|
5584
|
+
kind: 'artboard',
|
|
5585
|
+
fn: 'guides',
|
|
5586
|
+
artboardId,
|
|
5587
|
+
args: [guides],
|
|
5588
|
+
});
|
|
4934
5589
|
await setArtboardGuides(r.abs, artboardId, guides);
|
|
4935
5590
|
const after = await Bun.file(r.abs).text();
|
|
4936
5591
|
if (after === before) {
|
|
@@ -5107,6 +5762,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5107
5762
|
ctx.bus.emit('activity:suppress', rel);
|
|
5108
5763
|
try {
|
|
5109
5764
|
const before = await Bun.file(r.abs).text();
|
|
5765
|
+
announceOp(r.abs, rel, before, { kind: 'artboard', fn: 'style', artboardId, args: [patch] });
|
|
5110
5766
|
await setArtboardStyle(r.abs, artboardId, patch);
|
|
5111
5767
|
const after = await Bun.file(r.abs).text();
|
|
5112
5768
|
if (after === before) {
|
|
@@ -5151,6 +5807,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5151
5807
|
ctx.bus.emit('activity:unsuppress', rel);
|
|
5152
5808
|
return { ok: false, status: 413, error: 'canvas source too large to grow' };
|
|
5153
5809
|
}
|
|
5810
|
+
announceOp(r.abs, rel, before, {
|
|
5811
|
+
kind: 'duplicate',
|
|
5812
|
+
id,
|
|
5813
|
+
...(idIndex !== undefined ? { occurrence: idIndex } : {}),
|
|
5814
|
+
});
|
|
5154
5815
|
const res = await duplicateElement(r.abs, id, idIndex);
|
|
5155
5816
|
const after = await Bun.file(r.abs).text();
|
|
5156
5817
|
if (after === before) {
|
|
@@ -5476,6 +6137,36 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5476
6137
|
const current = await Bun.file(r.abs).text();
|
|
5477
6138
|
const expect = dir === 'undo' ? entry.after : entry.before;
|
|
5478
6139
|
const write = dir === 'undo' ? entry.before : entry.after;
|
|
6140
|
+
// T26 — a project that saves through accepted revisions undoes the
|
|
6141
|
+
// ACTION this edit became: effect-aware, so a teammate's later change
|
|
6142
|
+
// elsewhere in the canvas neither blocks the undo nor gets reverted
|
|
6143
|
+
// with it (a whole-file swap could do only one of those).
|
|
6144
|
+
const runtime = ctx.syncControl?.current?.();
|
|
6145
|
+
if (runtime?.acceptedMode?.()) {
|
|
6146
|
+
const rel = path.relative(paths.repoRoot, r.abs);
|
|
6147
|
+
// Undo targets the edit's action; redo targets the undo that
|
|
6148
|
+
// reverted it (reverting a revert re-applies, effect-aware again).
|
|
6149
|
+
const actionId =
|
|
6150
|
+
dir === 'undo'
|
|
6151
|
+
? (runtime.acceptedActionForContent?.(rel, entry.after) ?? null)
|
|
6152
|
+
: (entry.undoActionId ?? null);
|
|
6153
|
+
if (actionId) {
|
|
6154
|
+
const res = await runtime.acceptedUndo?.(actionId, dir === 'redo');
|
|
6155
|
+
if (res?.status === 'accepted' || res?.queued) {
|
|
6156
|
+
if (dir === 'undo') entry.undoActionId = res.actionId;
|
|
6157
|
+
else delete entry.undoActionId;
|
|
6158
|
+
return { ok: true, dir };
|
|
6159
|
+
}
|
|
6160
|
+
return {
|
|
6161
|
+
ok: false,
|
|
6162
|
+
status: 409,
|
|
6163
|
+
error:
|
|
6164
|
+
res?.code === 'base-conflict'
|
|
6165
|
+
? 'someone changed the same thing since — undo skipped'
|
|
6166
|
+
: `undo refused (${res?.code ?? 'unknown'})`,
|
|
6167
|
+
};
|
|
6168
|
+
}
|
|
6169
|
+
}
|
|
5479
6170
|
if (current !== expect) {
|
|
5480
6171
|
return {
|
|
5481
6172
|
ok: false,
|
|
@@ -5910,6 +6601,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5910
6601
|
patchCanvasMeta,
|
|
5911
6602
|
loadAnnotations,
|
|
5912
6603
|
saveAnnotations,
|
|
6604
|
+
projectAnnotations,
|
|
5913
6605
|
saveAsset,
|
|
5914
6606
|
listAssets,
|
|
5915
6607
|
readAssetBytes,
|
|
@@ -5921,6 +6613,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5921
6613
|
saveChatAttachment,
|
|
5922
6614
|
resolveChatAttachment,
|
|
5923
6615
|
createCanvas,
|
|
6616
|
+
duplicateCanvas,
|
|
5924
6617
|
deleteCanvas,
|
|
5925
6618
|
moveCanvas,
|
|
5926
6619
|
createFolder,
|
|
@@ -5945,6 +6638,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5945
6638
|
setArtboardHugOp,
|
|
5946
6639
|
setArtboardStyleOp,
|
|
5947
6640
|
setArtboardKindOp,
|
|
6641
|
+
setArtboardLabelOp,
|
|
5948
6642
|
setArtboardGuidesOp,
|
|
5949
6643
|
setArtboardPrintOp,
|
|
5950
6644
|
deleteArtboardOp,
|