@1agh/maude 1.2.0 → 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/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 +689 -36
- 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-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 +686 -241
- package/apps/studio/client/apply-edit-request.ts +55 -0
- 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 +35 -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/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 +1586 -1554
- 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/http.ts +289 -18
- package/apps/studio/inspect.ts +19 -0
- package/apps/studio/managed-projects.ts +150 -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 +1078 -19
- package/apps/studio/sync/migrate-seed.ts +110 -19
- package/apps/studio/sync/poke.ts +4 -2
- package/apps/studio/sync/presentation.ts +285 -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 +79 -1
- package/apps/studio/sync/transaction-client.ts +460 -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 +101 -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 +47 -0
- 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
|
|
@@ -312,8 +326,27 @@ export type CreateFolderResult =
|
|
|
312
326
|
|
|
313
327
|
/** Phase 12 — result of an in-canvas direct edit (`editCss` / `editText`). */
|
|
314
328
|
export type EditOpResult =
|
|
315
|
-
|
|
316
|
-
|
|
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
|
+
}
|
|
317
350
|
|
|
318
351
|
/**
|
|
319
352
|
* Phase 12.1 (DDR-138) — result of a node-move reorder. Carries the re-settle
|
|
@@ -374,7 +407,9 @@ export interface Api {
|
|
|
374
407
|
): Promise<Record<string, unknown> | null>;
|
|
375
408
|
// Annotations sidecar (Phase 5 — .design/<slug>.annotations.svg)
|
|
376
409
|
loadAnnotations(file: string): Promise<string | null>;
|
|
377
|
-
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>;
|
|
378
413
|
// Phase 23 — content-addressed binary image write (drag-drop / paste / picker)
|
|
379
414
|
saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
|
|
380
415
|
/** Stage F1 — list content-addressed image/video assets for the AssetPicker. */
|
|
@@ -425,6 +460,10 @@ export interface Api {
|
|
|
425
460
|
width?: unknown;
|
|
426
461
|
height?: unknown;
|
|
427
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>;
|
|
428
467
|
// Soft-delete a canvas from the browser (Phase 22 — DELETE /_api/canvas)
|
|
429
468
|
deleteCanvas(input: { file?: unknown }): Promise<DeleteCanvasResult>;
|
|
430
469
|
// feature-file-tree-drag-drop-folders (Task 3) — move/rename a canvas + its
|
|
@@ -442,6 +481,8 @@ export interface Api {
|
|
|
442
481
|
value?: unknown;
|
|
443
482
|
reset?: unknown;
|
|
444
483
|
idIndex?: unknown;
|
|
484
|
+
/** Expected current value (string) or `null` = currently unset. Absent = unconditional. */
|
|
485
|
+
expected?: unknown;
|
|
445
486
|
}): Promise<EditOpResult>;
|
|
446
487
|
// Phase 12 (DDR-103) — inline text-content edit (POST /_api/edit-text). Main-origin only.
|
|
447
488
|
editText(input: {
|
|
@@ -459,6 +500,8 @@ export interface Api {
|
|
|
459
500
|
id?: unknown;
|
|
460
501
|
attr?: unknown;
|
|
461
502
|
value?: unknown;
|
|
503
|
+
reset?: unknown;
|
|
504
|
+
expected?: unknown;
|
|
462
505
|
}): Promise<EditOpResult>;
|
|
463
506
|
// Phase 12.1 (DDR-138) — node-move reorder (POST /_api/reorder). Main-origin
|
|
464
507
|
// only. Moves the element with data-cd-id `id` to `position` relative to
|
|
@@ -662,6 +705,12 @@ export interface Api {
|
|
|
662
705
|
artboardId?: unknown;
|
|
663
706
|
kind?: unknown;
|
|
664
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 }>;
|
|
665
714
|
/** Generic layout guides (T5) — replace-whole-prop write. */
|
|
666
715
|
setArtboardGuidesOp(input: {
|
|
667
716
|
canvas?: unknown;
|
|
@@ -736,9 +785,26 @@ export interface ApiHooks {
|
|
|
736
785
|
* stays". Passing the list removes the read, and `publishComments` awaits
|
|
737
786
|
* this hook BEFORE touching disk so the doc is never the stale side.
|
|
738
787
|
*/
|
|
739
|
-
|
|
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>;
|
|
740
790
|
/** Phase 8 Task 5 — fires after a successful PUT /_api/annotations write. */
|
|
741
|
-
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;
|
|
742
808
|
/**
|
|
743
809
|
* feature-file-tree-drag-drop-folders (Task 3) — is a collab room pinned
|
|
744
810
|
* (a shared-doc hub provider attached, DDR-064)? `moveCanvas` refuses the
|
|
@@ -1009,7 +1075,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1009
1075
|
// stores in its undo record. Ephemeral by design: a server restart drops it
|
|
1010
1076
|
// (undo answers 404 and the canvas stack entry is a no-op, honest failure).
|
|
1011
1077
|
const REORDER_LOG_CAP = 50;
|
|
1012
|
-
const reorderLog = new Map<
|
|
1078
|
+
const reorderLog = new Map<
|
|
1079
|
+
number,
|
|
1080
|
+
{ abs: string; before: string; after: string; undoActionId?: string }
|
|
1081
|
+
>();
|
|
1013
1082
|
let reorderSeq = 0;
|
|
1014
1083
|
|
|
1015
1084
|
// DDR-150 dogfood #1 — the SAME whole-file log backs the Timeline clip ops
|
|
@@ -1187,9 +1256,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1187
1256
|
* content — otherwise the file→doc import that follows the write would see a
|
|
1188
1257
|
* difference and re-enter the loop.
|
|
1189
1258
|
*/
|
|
1190
|
-
async function publishComments(file: string, list: Comment[]): Promise<void> {
|
|
1259
|
+
async function publishComments(file: string, list: Comment[], base?: Comment[]): Promise<void> {
|
|
1191
1260
|
const settled = dedupeCommentsById(list);
|
|
1192
|
-
await onCommentsChanged(file, settled);
|
|
1261
|
+
await onCommentsChanged(file, settled, base ? dedupeCommentsById(base) : undefined);
|
|
1193
1262
|
await saveCommentsForFile(file, settled);
|
|
1194
1263
|
}
|
|
1195
1264
|
|
|
@@ -1321,6 +1390,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1321
1390
|
if (!payload || typeof payload.file !== 'string' || !payload.file) return null;
|
|
1322
1391
|
if (typeof payload.text !== 'string' || !payload.text.trim()) return null;
|
|
1323
1392
|
const list = await loadCommentsForFile(payload.file);
|
|
1393
|
+
const base = structuredClone(list);
|
|
1324
1394
|
const text = String(payload.text).trim().slice(0, 4000);
|
|
1325
1395
|
const author =
|
|
1326
1396
|
typeof payload.author === 'string' && payload.author.trim()
|
|
@@ -1369,7 +1439,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1369
1439
|
if (anchor.clipStableId != null || anchor.frame != null) c.timeline = anchor;
|
|
1370
1440
|
}
|
|
1371
1441
|
list.push(c);
|
|
1372
|
-
await publishComments(payload.file, list);
|
|
1442
|
+
await publishComments(payload.file, list, base);
|
|
1373
1443
|
return c;
|
|
1374
1444
|
}
|
|
1375
1445
|
|
|
@@ -1384,6 +1454,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1384
1454
|
if (i < 0) continue;
|
|
1385
1455
|
const entry = list[i];
|
|
1386
1456
|
if (!entry) continue;
|
|
1457
|
+
const base = structuredClone(list);
|
|
1387
1458
|
const body = payload.body.trim().slice(0, 4000);
|
|
1388
1459
|
const author =
|
|
1389
1460
|
typeof payload.author === 'string' && payload.author.trim()
|
|
@@ -1397,7 +1468,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1397
1468
|
};
|
|
1398
1469
|
entry.thread = [...entry.thread, reply];
|
|
1399
1470
|
entry.mentions = mentionsUnion(entry);
|
|
1400
|
-
await publishComments(file, list);
|
|
1471
|
+
await publishComments(file, list, base);
|
|
1401
1472
|
return entry;
|
|
1402
1473
|
}
|
|
1403
1474
|
return null;
|
|
@@ -1417,6 +1488,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1417
1488
|
const matches = list.filter((c) => c.id === id);
|
|
1418
1489
|
const first = matches[0];
|
|
1419
1490
|
if (!first) continue;
|
|
1491
|
+
const base = structuredClone(list);
|
|
1420
1492
|
for (const entry of matches) {
|
|
1421
1493
|
if (patch.status === 'resolved' || patch.status === 'open') {
|
|
1422
1494
|
entry.status = patch.status;
|
|
@@ -1427,7 +1499,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1427
1499
|
entry.mentions = mentionsUnion(entry);
|
|
1428
1500
|
}
|
|
1429
1501
|
}
|
|
1430
|
-
await publishComments(file, list);
|
|
1502
|
+
await publishComments(file, list, base);
|
|
1431
1503
|
return first;
|
|
1432
1504
|
}
|
|
1433
1505
|
return null;
|
|
@@ -1438,7 +1510,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1438
1510
|
for (const [file, list] of Object.entries(all)) {
|
|
1439
1511
|
const remaining = list.filter((c) => c.id !== id);
|
|
1440
1512
|
if (remaining.length === list.length) continue;
|
|
1441
|
-
await publishComments(file, remaining);
|
|
1513
|
+
await publishComments(file, remaining, list);
|
|
1442
1514
|
return true;
|
|
1443
1515
|
}
|
|
1444
1516
|
return false;
|
|
@@ -1946,7 +2018,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1946
2018
|
next.last_modified = new Date().toISOString();
|
|
1947
2019
|
// Trailing newline — consistent with canvas-create.ts + sync/codec.ts
|
|
1948
2020
|
// (mergeSharedMetaIntoLocal), so a layout edit doesn't churn the newline.
|
|
1949
|
-
|
|
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);
|
|
1950
2025
|
// Same reason as the annotations sidecar below, and the same bug: this
|
|
1951
2026
|
// lane writes the FILE and nothing else, so in a cell — where there is no
|
|
1952
2027
|
// `fs.watch` — the layout never entered the doc and never reached a peer.
|
|
@@ -1986,7 +2061,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1986
2061
|
}
|
|
1987
2062
|
}
|
|
1988
2063
|
|
|
1989
|
-
async function saveAnnotations(
|
|
2064
|
+
async function saveAnnotations(
|
|
2065
|
+
file: string,
|
|
2066
|
+
svg: string,
|
|
2067
|
+
writeId?: string,
|
|
2068
|
+
base?: string
|
|
2069
|
+
): Promise<boolean> {
|
|
1990
2070
|
if (typeof svg !== 'string') return false;
|
|
1991
2071
|
if (svg.length > 1024 * 1024) return false;
|
|
1992
2072
|
// Cheap content gate — must look like an <svg> document. Avoids accidental
|
|
@@ -2004,8 +2084,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2004
2084
|
// (strokesToSvg) is purely presentational — path/rect/ellipse/g/line/
|
|
2005
2085
|
// polyline/text — so stripping executable constructs is zero-regression.
|
|
2006
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;
|
|
2007
2097
|
await Bun.write(annotationsPath(file), clean);
|
|
2008
|
-
onAnnotationsChanged?.(file, clean);
|
|
2098
|
+
onAnnotationsChanged?.(file, clean, writeId, cleanBase);
|
|
2009
2099
|
// Annotations reach OTHER VIEWERS over the collab room, which is why this
|
|
2010
2100
|
// never needed an `fs:any`. But the file is also a versioned, file-plane
|
|
2011
2101
|
// sidecar (DDR-115), and the file plane learns about a cell's own writes
|
|
@@ -2016,6 +2106,32 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2016
2106
|
return true;
|
|
2017
2107
|
}
|
|
2018
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
|
+
|
|
2019
2135
|
// Phase 23 — content-addressed asset write. Reachable from the (potentially
|
|
2020
2136
|
// untrusted, DDR-054) canvas origin, so every cap is load-bearing, NOT
|
|
2021
2137
|
// optional (DDR Task 9):
|
|
@@ -2754,6 +2870,91 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2754
2870
|
return { ok: true, file: path.posix.join(paths.designRel, rel), rel, slug };
|
|
2755
2871
|
}
|
|
2756
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
|
+
|
|
2757
2958
|
// Phase 22 — SOFT-delete a canvas (DELETE /_api/canvas). Same trust boundary as
|
|
2758
2959
|
// createCanvas: main-origin-only, never the untrusted canvas iframe origin
|
|
2759
2960
|
// (DDR-054). Destructive, so it MOVES the whole sidecar set to
|
|
@@ -2777,6 +2978,13 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2777
2978
|
|
|
2778
2979
|
// Only a real `.tsx` canvas, no traversal.
|
|
2779
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
|
+
}
|
|
2780
2988
|
if (!/\.tsx$/i.test(rel)) {
|
|
2781
2989
|
// feature-file-tree-drag-drop-folders (follow-up) — a non-.tsx target is
|
|
2782
2990
|
// a folder delete (dogfood gap: the tree offered no way to remove a
|
|
@@ -2886,6 +3094,170 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2886
3094
|
// itself (same `_trash/<stamp>__<slug>/` bundle shape, so a folder delete
|
|
2887
3095
|
// is recoverable exactly like a single-canvas delete), then best-effort
|
|
2888
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
|
+
|
|
2889
3261
|
async function deleteFolder(relDir: string): Promise<DeleteCanvasResult> {
|
|
2890
3262
|
if (relDir.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
|
|
2891
3263
|
const dirAbs = path.join(paths.designRoot, relDir);
|
|
@@ -2942,6 +3314,21 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
2942
3314
|
p.startsWith(`${drPrefix}/`) ? p.slice(drPrefix.length + 1) : p
|
|
2943
3315
|
);
|
|
2944
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
|
+
|
|
2945
3332
|
const trashed: string[] = [];
|
|
2946
3333
|
let lastTrashDir = '';
|
|
2947
3334
|
for (const r of canvasRels) {
|
|
@@ -3051,7 +3438,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3051
3438
|
// never wrong about where the canvas lives) and every relocation is logged
|
|
3052
3439
|
// to `_history/<toSlug>/_move.json` for forensic recovery. See the DDR
|
|
3053
3440
|
// (Task 14) for the accepted-limitation writeup.
|
|
3054
|
-
async function moveCanvas(input: {
|
|
3441
|
+
async function moveCanvas(input: {
|
|
3442
|
+
file?: unknown;
|
|
3443
|
+
toDir?: unknown;
|
|
3444
|
+
toName?: unknown;
|
|
3445
|
+
}): Promise<MoveCanvasResult> {
|
|
3055
3446
|
const raw = input?.file;
|
|
3056
3447
|
if (typeof raw !== 'string' || !raw.trim()) {
|
|
3057
3448
|
return { ok: false, status: 400, error: 'file is required' };
|
|
@@ -3067,17 +3458,38 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3067
3458
|
if (rel.startsWith(`${drPrefix}/`)) rel = rel.slice(drPrefix.length + 1);
|
|
3068
3459
|
if (rel.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
|
|
3069
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
|
+
}
|
|
3070
3466
|
// feature-file-tree-drag-drop-folders (Task 11) — a non-.tsx source is a
|
|
3071
3467
|
// folder move (dragging a folder onto a folder). moveFolder does its own
|
|
3072
3468
|
// existence + containment validation, so an invalid path still reports
|
|
3073
3469
|
// the right error from there.
|
|
3074
|
-
return moveFolder(rel, input?.toDir);
|
|
3075
|
-
}
|
|
3076
|
-
|
|
3077
|
-
|
|
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') {
|
|
3078
3490
|
return { ok: false, status: 400, error: 'toDir is required' };
|
|
3079
3491
|
}
|
|
3080
|
-
let toDir =
|
|
3492
|
+
let toDir = toDirInput.trim().replace(/^\/+|\/+$/g, '');
|
|
3081
3493
|
try {
|
|
3082
3494
|
toDir = decodeURIComponent(toDir);
|
|
3083
3495
|
} catch {
|
|
@@ -3145,7 +3557,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3145
3557
|
return { ok: false, status: 404, error: 'canvas not found' };
|
|
3146
3558
|
}
|
|
3147
3559
|
|
|
3148
|
-
const base = path.basename(rel);
|
|
3560
|
+
const base = newBase ?? path.basename(rel);
|
|
3149
3561
|
const toRel = path.posix.join(toDir, base);
|
|
3150
3562
|
if (path.posix.normalize(toRel) === path.posix.normalize(rel)) {
|
|
3151
3563
|
return { ok: false, status: 400, error: 'source and destination are the same' };
|
|
@@ -3313,7 +3725,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3313
3725
|
// nested canvas at once (they live inside the moved directory); only the
|
|
3314
3726
|
// slug-keyed sidecars (flat dirs like `_history/<slug>/`, not nested by
|
|
3315
3727
|
// folder structure) need their own per-canvas relocation afterward.
|
|
3316
|
-
async function moveFolder(
|
|
3728
|
+
async function moveFolder(
|
|
3729
|
+
relDir: string,
|
|
3730
|
+
toDirRaw: unknown,
|
|
3731
|
+
toNameRaw?: unknown
|
|
3732
|
+
): Promise<MoveCanvasResult> {
|
|
3317
3733
|
if (relDir.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
|
|
3318
3734
|
const dirAbs = path.join(paths.designRoot, relDir);
|
|
3319
3735
|
const resolvedDesignRoot = path.resolve(paths.designRoot);
|
|
@@ -3389,7 +3805,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3389
3805
|
return { ok: false, status: 400, error: 'cannot move a folder into itself' };
|
|
3390
3806
|
}
|
|
3391
3807
|
|
|
3392
|
-
|
|
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
|
+
}
|
|
3393
3816
|
const toRelDir = path.posix.join(toDir, base);
|
|
3394
3817
|
if (path.posix.normalize(toRelDir) === path.posix.normalize(relDir)) {
|
|
3395
3818
|
return { ok: false, status: 400, error: 'source and destination are the same' };
|
|
@@ -3443,6 +3866,22 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3443
3866
|
}
|
|
3444
3867
|
}
|
|
3445
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
|
+
|
|
3446
3885
|
// Collab guard for every canvas found, BEFORE any disk mutation. Same
|
|
3447
3886
|
// coordinated retire as moveCanvas — refuse only when a pinned room could
|
|
3448
3887
|
// not be retired, and retire even unpinned synced documents so none of
|
|
@@ -3612,6 +4051,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3612
4051
|
/* good — doesn't exist yet */
|
|
3613
4052
|
}
|
|
3614
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
|
+
|
|
3615
4066
|
await mkdir(dirAbs, { recursive: true });
|
|
3616
4067
|
await Bun.write(path.join(dirAbs, '.gitkeep'), '');
|
|
3617
4068
|
|
|
@@ -3689,10 +4140,22 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3689
4140
|
// suppression BEFORE the write so it catches the debounced fs:any, disarm on
|
|
3690
4141
|
// a no-op (delta 0 = no write) or throw so a failed edit can't mute the next
|
|
3691
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
|
+
|
|
3692
4154
|
async function suppressedEdit(
|
|
3693
4155
|
abs: string,
|
|
3694
|
-
run: () => Promise<{ delta: number; changed?: boolean }>,
|
|
3695
|
-
errLabel: string
|
|
4156
|
+
run: () => Promise<{ delta: number; changed?: boolean; previous?: AttributeState }>,
|
|
4157
|
+
errLabel: string,
|
|
4158
|
+
describe?: Parameters<typeof describeSourceOp>[2]
|
|
3696
4159
|
): Promise<EditOpResult> {
|
|
3697
4160
|
const rel = path.relative(paths.designRoot, abs);
|
|
3698
4161
|
ctx.bus.emit('activity:suppress', rel);
|
|
@@ -3701,18 +4164,33 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3701
4164
|
// whole-file undo log (the Timeline replace-src path rides this; other
|
|
3702
4165
|
// inspector edits get an undoable seq for free).
|
|
3703
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
|
+
}
|
|
3704
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
|
+
: {};
|
|
3705
4180
|
// `changed === false` = the op collapsed to a no-op and nothing hit disk —
|
|
3706
4181
|
// NOT `delta === 0`, which an equal-length replacement also produces.
|
|
3707
4182
|
if (res.changed === false) {
|
|
3708
4183
|
ctx.bus.emit('activity:unsuppress', rel);
|
|
3709
|
-
return { ok: true, delta: res.delta };
|
|
4184
|
+
return { ok: true, delta: res.delta, ...previous };
|
|
3710
4185
|
}
|
|
3711
4186
|
const after = await Bun.file(abs).text();
|
|
3712
4187
|
const seq = after !== before ? logUndo(abs, before, after) : undefined;
|
|
3713
|
-
return { ok: true, delta: res.delta, seq };
|
|
4188
|
+
return { ok: true, delta: res.delta, seq, ...previous };
|
|
3714
4189
|
} catch (err) {
|
|
3715
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
|
+
}
|
|
3716
4194
|
return {
|
|
3717
4195
|
ok: false,
|
|
3718
4196
|
status: 422,
|
|
@@ -3728,9 +4206,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3728
4206
|
value?: unknown;
|
|
3729
4207
|
reset?: unknown;
|
|
3730
4208
|
idIndex?: unknown;
|
|
4209
|
+
expected?: unknown;
|
|
3731
4210
|
}): Promise<EditOpResult> {
|
|
3732
4211
|
const r = resolveCanvasAbs(input.canvas);
|
|
3733
4212
|
if (!r.ok) return r;
|
|
4213
|
+
const pre = expectedValueOf(input);
|
|
4214
|
+
if (!pre.ok) return { ok: false, status: 400, error: 'invalid expected value' };
|
|
3734
4215
|
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
3735
4216
|
if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
|
|
3736
4217
|
// Stage H3 — optional DOM-occurrence index. Present only for a whole-instance
|
|
@@ -3755,8 +4236,21 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3755
4236
|
if (input.reset === true) {
|
|
3756
4237
|
return suppressedEdit(
|
|
3757
4238
|
r.abs,
|
|
3758
|
-
() =>
|
|
3759
|
-
|
|
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
|
+
}
|
|
3760
4254
|
);
|
|
3761
4255
|
}
|
|
3762
4256
|
const value = typeof input.value === 'string' ? input.value : '';
|
|
@@ -3768,8 +4262,23 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3768
4262
|
// `#fff`, `8px`, `700`, `1.5` all ride verbatim.
|
|
3769
4263
|
return suppressedEdit(
|
|
3770
4264
|
r.abs,
|
|
3771
|
-
() =>
|
|
3772
|
-
|
|
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
|
+
}
|
|
3773
4282
|
);
|
|
3774
4283
|
}
|
|
3775
4284
|
|
|
@@ -3802,7 +4311,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3802
4311
|
return suppressedEdit(
|
|
3803
4312
|
r.abs,
|
|
3804
4313
|
() => runEditText(r.abs, id, text, { occurrence, before }),
|
|
3805
|
-
'edit failed'
|
|
4314
|
+
'edit failed',
|
|
4315
|
+
{ kind: 'text', id, text, ...(occurrence !== undefined ? { occurrence } : {}) }
|
|
3806
4316
|
);
|
|
3807
4317
|
}
|
|
3808
4318
|
|
|
@@ -3812,9 +4322,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3812
4322
|
attr?: unknown;
|
|
3813
4323
|
value?: unknown;
|
|
3814
4324
|
reset?: unknown;
|
|
4325
|
+
expected?: unknown;
|
|
3815
4326
|
}): Promise<EditOpResult> {
|
|
3816
4327
|
const r = resolveCanvasAbs(input.canvas);
|
|
3817
4328
|
if (!r.ok) return r;
|
|
4329
|
+
const pre = expectedValueOf(input);
|
|
4330
|
+
if (!pre.ok) return { ok: false, status: 400, error: 'invalid expected value' };
|
|
3818
4331
|
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
3819
4332
|
if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
|
|
3820
4333
|
const attr = typeof input.attr === 'string' ? input.attr.trim() : '';
|
|
@@ -3832,7 +4345,19 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3832
4345
|
}
|
|
3833
4346
|
// Phase 12.3 — `reset: true` REMOVES the custom attribute. No-op if absent.
|
|
3834
4347
|
if (input.reset === true) {
|
|
3835
|
-
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
|
+
);
|
|
3836
4361
|
}
|
|
3837
4362
|
const value = typeof input.value === 'string' ? input.value : '';
|
|
3838
4363
|
if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
|
|
@@ -3853,7 +4378,20 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3853
4378
|
// Pass the value RAW: editStringAttr quotes/escapes it itself (JSON.stringify
|
|
3854
4379
|
// on replace, escapeAttr on insert) — pre-stringifying here double-encoded
|
|
3855
4380
|
// the value (`data-x="\"ok\""`; knob-smoke finding, 2026-06-12).
|
|
3856
|
-
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
|
+
);
|
|
3857
4395
|
}
|
|
3858
4396
|
|
|
3859
4397
|
// Phase 12.1 (DDR-138) — snapshot stack so a reorder is undoable via
|
|
@@ -3900,6 +4438,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3900
4438
|
ctx.bus.emit('activity:suppress', rel);
|
|
3901
4439
|
try {
|
|
3902
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
|
+
});
|
|
3903
4449
|
const res = await moveElement(r.abs, id, refId, position as MovePosition, idIndex, refIndex);
|
|
3904
4450
|
const after = await Bun.file(r.abs).text();
|
|
3905
4451
|
if (after === before) {
|
|
@@ -4315,6 +4861,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4315
4861
|
ctx.bus.emit('activity:suppress', rel);
|
|
4316
4862
|
try {
|
|
4317
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
|
+
});
|
|
4318
4869
|
const res = await deleteElement(r.abs, id, idIndex);
|
|
4319
4870
|
const after = await Bun.file(r.abs).text();
|
|
4320
4871
|
if (after === before) {
|
|
@@ -4780,6 +5331,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4780
5331
|
ctx.bus.emit('activity:suppress', rel);
|
|
4781
5332
|
try {
|
|
4782
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
|
+
});
|
|
4783
5340
|
await resizeArtboard(r.abs, artboardId, width, height);
|
|
4784
5341
|
const after = await Bun.file(r.abs).text();
|
|
4785
5342
|
if (after === before) {
|
|
@@ -4827,6 +5384,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4827
5384
|
ctx.bus.emit('activity:suppress', rel);
|
|
4828
5385
|
try {
|
|
4829
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
|
+
});
|
|
4830
5393
|
await setArtboardHug(r.abs, artboardId, fixed, freezeHeight);
|
|
4831
5394
|
const after = await Bun.file(r.abs).text();
|
|
4832
5395
|
if (after === before) {
|
|
@@ -4876,6 +5439,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4876
5439
|
ctx.bus.emit('activity:suppress', rel);
|
|
4877
5440
|
try {
|
|
4878
5441
|
const before = await Bun.file(r.abs).text();
|
|
5442
|
+
announceOp(r.abs, rel, before, { kind: 'artboard', fn: 'kind', artboardId, args: [kind] });
|
|
4879
5443
|
await setArtboardKind(r.abs, artboardId, kind);
|
|
4880
5444
|
const after = await Bun.file(r.abs).text();
|
|
4881
5445
|
if (after === before) {
|
|
@@ -4898,6 +5462,50 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4898
5462
|
}
|
|
4899
5463
|
}
|
|
4900
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
|
+
|
|
4901
5509
|
const MAX_GUIDES_JSON_BYTES = 4096;
|
|
4902
5510
|
|
|
4903
5511
|
/** Generic layout guides (T5) — Inspector/skill writer. Replace-whole-prop,
|
|
@@ -4931,6 +5539,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4931
5539
|
ctx.bus.emit('activity:suppress', rel);
|
|
4932
5540
|
try {
|
|
4933
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
|
+
});
|
|
4934
5548
|
await setArtboardGuides(r.abs, artboardId, guides);
|
|
4935
5549
|
const after = await Bun.file(r.abs).text();
|
|
4936
5550
|
if (after === before) {
|
|
@@ -5107,6 +5721,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5107
5721
|
ctx.bus.emit('activity:suppress', rel);
|
|
5108
5722
|
try {
|
|
5109
5723
|
const before = await Bun.file(r.abs).text();
|
|
5724
|
+
announceOp(r.abs, rel, before, { kind: 'artboard', fn: 'style', artboardId, args: [patch] });
|
|
5110
5725
|
await setArtboardStyle(r.abs, artboardId, patch);
|
|
5111
5726
|
const after = await Bun.file(r.abs).text();
|
|
5112
5727
|
if (after === before) {
|
|
@@ -5151,6 +5766,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5151
5766
|
ctx.bus.emit('activity:unsuppress', rel);
|
|
5152
5767
|
return { ok: false, status: 413, error: 'canvas source too large to grow' };
|
|
5153
5768
|
}
|
|
5769
|
+
announceOp(r.abs, rel, before, {
|
|
5770
|
+
kind: 'duplicate',
|
|
5771
|
+
id,
|
|
5772
|
+
...(idIndex !== undefined ? { occurrence: idIndex } : {}),
|
|
5773
|
+
});
|
|
5154
5774
|
const res = await duplicateElement(r.abs, id, idIndex);
|
|
5155
5775
|
const after = await Bun.file(r.abs).text();
|
|
5156
5776
|
if (after === before) {
|
|
@@ -5476,6 +6096,36 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5476
6096
|
const current = await Bun.file(r.abs).text();
|
|
5477
6097
|
const expect = dir === 'undo' ? entry.after : entry.before;
|
|
5478
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
|
+
}
|
|
5479
6129
|
if (current !== expect) {
|
|
5480
6130
|
return {
|
|
5481
6131
|
ok: false,
|
|
@@ -5910,6 +6560,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5910
6560
|
patchCanvasMeta,
|
|
5911
6561
|
loadAnnotations,
|
|
5912
6562
|
saveAnnotations,
|
|
6563
|
+
projectAnnotations,
|
|
5913
6564
|
saveAsset,
|
|
5914
6565
|
listAssets,
|
|
5915
6566
|
readAssetBytes,
|
|
@@ -5921,6 +6572,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5921
6572
|
saveChatAttachment,
|
|
5922
6573
|
resolveChatAttachment,
|
|
5923
6574
|
createCanvas,
|
|
6575
|
+
duplicateCanvas,
|
|
5924
6576
|
deleteCanvas,
|
|
5925
6577
|
moveCanvas,
|
|
5926
6578
|
createFolder,
|
|
@@ -5945,6 +6597,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5945
6597
|
setArtboardHugOp,
|
|
5946
6598
|
setArtboardStyleOp,
|
|
5947
6599
|
setArtboardKindOp,
|
|
6600
|
+
setArtboardLabelOp,
|
|
5948
6601
|
setArtboardGuidesOp,
|
|
5949
6602
|
setArtboardPrintOp,
|
|
5950
6603
|
deleteArtboardOp,
|