@1agh/maude 0.49.2 → 0.50.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.
Files changed (34) hide show
  1. package/apps/studio/api.ts +751 -52
  2. package/apps/studio/canvas-artifacts.ts +153 -0
  3. package/apps/studio/canvas-create.ts +23 -0
  4. package/apps/studio/canvas-slug.ts +30 -0
  5. package/apps/studio/client/app.jsx +547 -90
  6. package/apps/studio/client/panels/CloudBar.jsx +284 -0
  7. package/apps/studio/client/styles/3-shell-maude.css +26 -0
  8. package/apps/studio/client/tree-row-menu.jsx +132 -0
  9. package/apps/studio/client/use-tree-drag.js +120 -0
  10. package/apps/studio/cloud/endpoints.ts +236 -0
  11. package/apps/studio/collab/registry.ts +23 -0
  12. package/apps/studio/dist/client.bundle.js +960 -958
  13. package/apps/studio/dist/styles.css +1 -1
  14. package/apps/studio/http.ts +113 -0
  15. package/apps/studio/inspect.ts +49 -0
  16. package/apps/studio/server.ts +15 -0
  17. package/apps/studio/test/canvas-artifacts.test.ts +125 -0
  18. package/apps/studio/test/canvas-create-api.test.ts +159 -1
  19. package/apps/studio/test/canvas-move-api.test.ts +556 -0
  20. package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
  21. package/apps/studio/test/fs-mkdir-api.test.ts +248 -0
  22. package/apps/studio/whats-new.json +18 -0
  23. package/cli/bin/maude.mjs +1 -0
  24. package/cli/commands/hub-workspace.mjs +294 -7
  25. package/cli/commands/init.mjs +33 -2
  26. package/cli/commands/kg.mjs +129 -4
  27. package/cli/commands/share.mjs +184 -0
  28. package/cli/lib/ddr-to-kgai.mjs +310 -82
  29. package/cli/lib/ddr-to-kgai.test.mjs +20 -0
  30. package/cli/lib/share-plan.mjs +96 -0
  31. package/cli/lib/share-plan.test.mjs +57 -0
  32. package/cli/lib/workspace-plan.mjs +130 -12
  33. package/cli/lib/workspace-plan.test.mjs +160 -0
  34. package/package.json +8 -8
@@ -3,10 +3,28 @@
3
3
 
4
4
  import crypto from 'node:crypto';
5
5
  import type { Dirent } from 'node:fs';
6
- import { lstat, mkdir, readdir, readFile, rename, rm, stat as statp } from 'node:fs/promises';
6
+ import {
7
+ lstat,
8
+ mkdir,
9
+ readdir,
10
+ readFile,
11
+ realpath,
12
+ rename,
13
+ rm,
14
+ stat as statp,
15
+ } from 'node:fs/promises';
7
16
  import path from 'node:path';
8
17
  import { createAssetMirror, s3ConfigFromEnv } from './assets-s3.ts';
9
- import { renderBriefBoard, validateCanvasName } from './canvas-create.ts';
18
+ import { canvasArtifacts, locatorKeyFor, relocatedName } from './canvas-artifacts.ts';
19
+ import { renderBriefBoard, validateCanvasName, validateFolderName } from './canvas-create.ts';
20
+ import { canvasSlugFromRel } from './canvas-slug.ts';
21
+
22
+ // Re-exported so existing external callers (canvas-list-watch.ts, tests) keep
23
+ // importing it from api.ts — the actual implementation now lives in
24
+ // canvas-slug.ts (a leaf module) so canvas-artifacts.ts can depend on it
25
+ // without a cycle back through api.ts.
26
+ export { canvasSlugFromRel } from './canvas-slug.ts';
27
+
10
28
  import {
11
29
  type AssembleClip,
12
30
  assembleCompSource,
@@ -55,6 +73,7 @@ import {
55
73
  sanitizeReuseText,
56
74
  } from './generation/audio-library.ts';
57
75
  import { createHistory } from './history.ts';
76
+ import { clearLocatorSlug, readLocator, writeLocator } from './locator.ts';
58
77
  import { STICKERS_DIR } from './paths.ts';
59
78
  import { getPaperPreset, MAX_PRINT_MM } from './print/units.ts';
60
79
 
@@ -109,31 +128,19 @@ export async function findHtmlFiles(absRoot: string, prefixUnderRepo: string): P
109
128
  }
110
129
 
111
130
  /**
112
- * Canonical canvas slug from a (repo- or design-root-relative) canvas path.
113
- * Pure — the `fileSlug` closure inside `createApi` delegates here, and the
114
- * external-canvas watcher (`canvas-list-watch.ts`) imports it so both creation
115
- * paths derive identical `canvas-list-update` slugs. Strips an optional
116
- * `<designRel>/` prefix, then `/`→`-`, whitespace→`_`, drops the `.tsx`/`.html`
117
- * extension, and lowercases.
131
+ * `dirsOut`, when passed, accumulates every directory visited (group-relative
132
+ * POSIX paths, same shape as the returned file paths) — INCLUDING empty ones,
133
+ * since a directory is recorded before recursing into it, not after finding a
134
+ * match inside. feature-file-tree-drag-drop-folders (Task 6) reuses this one
135
+ * traversal instead of adding a second full walk of `system/` (the largest
136
+ * group) just to enumerate directories.
118
137
  */
119
- export function canvasSlugFromRel(file: string, designRel: string): string {
120
- let p = String(file).replace(/^\/+|\/+$/g, '');
121
- try {
122
- p = decodeURIComponent(p);
123
- } catch {
124
- /* ignore */
125
- }
126
- const prefix = `${designRel.replace(/^\/+|\/+$/g, '')}/`;
127
- if (p.startsWith(prefix)) p = p.slice(prefix.length);
128
- return p
129
- .replace(/\//g, '-')
130
- .replace(/\s+/g, '_')
131
- .replace(/\.(tsx|html)$/i, '')
132
- .replace(/^\.+/, '')
133
- .toLowerCase();
134
- }
135
-
136
- async function findFiles(absRoot: string, prefix: string, exts: string[]): Promise<string[]> {
138
+ async function findFiles(
139
+ absRoot: string,
140
+ prefix: string,
141
+ exts: string[],
142
+ dirsOut?: string[]
143
+ ): Promise<string[]> {
137
144
  const out: string[] = [];
138
145
  let entries: Dirent[];
139
146
  try {
@@ -148,8 +155,10 @@ async function findFiles(absRoot: string, prefix: string, exts: string[]): Promi
148
155
  if (SKIP_DIRS.has(e.name)) continue;
149
156
  const full = path.join(absRoot, e.name);
150
157
  const rel = path.posix.join(prefix, e.name);
151
- if (e.isDirectory()) out.push(...(await findFiles(full, rel, exts)));
152
- else if (exts.some((x) => e.name.toLowerCase().endsWith(x))) out.push(rel);
158
+ if (e.isDirectory()) {
159
+ dirsOut?.push(rel);
160
+ out.push(...(await findFiles(full, rel, exts, dirsOut)));
161
+ } else if (exts.some((x) => e.name.toLowerCase().endsWith(x))) out.push(rel);
153
162
  }
154
163
  return out;
155
164
  }
@@ -208,6 +217,15 @@ export type DeleteCanvasResult =
208
217
  | { ok: true; rel: string; slug: string; trashed: string[]; trashDir: string }
209
218
  | { ok: false; status: number; error: string };
210
219
 
220
+ // feature-file-tree-drag-drop-folders (Task 3/4).
221
+ export type MoveCanvasResult =
222
+ | { ok: true; fromRel: string; toRel: string; fromSlug: string; toSlug: string; moved: string[] }
223
+ | { ok: false; status: number; error: string };
224
+
225
+ export type CreateFolderResult =
226
+ | { ok: true; dir: string }
227
+ | { ok: false; status: number; error: string };
228
+
211
229
  /** Phase 12 — result of an in-canvas direct edit (`editCss` / `editText`). */
212
230
  export type EditOpResult =
213
231
  | { ok: true; delta: number; seq?: number }
@@ -323,6 +341,12 @@ export interface Api {
323
341
  }): Promise<CreateCanvasResult>;
324
342
  // Soft-delete a canvas from the browser (Phase 22 — DELETE /_api/canvas)
325
343
  deleteCanvas(input: { file?: unknown }): Promise<DeleteCanvasResult>;
344
+ // feature-file-tree-drag-drop-folders (Task 3) — move/rename a canvas + its
345
+ // full artifact set (POST /_api/fs-move).
346
+ moveCanvas(input: { file?: unknown; toDir?: unknown }): Promise<MoveCanvasResult>;
347
+ // feature-file-tree-drag-drop-folders (Task 4) — create an empty folder
348
+ // inside a canvas group, with a `.gitkeep` (POST /_api/fs-mkdir).
349
+ createFolder(input: { parent?: unknown; name?: unknown }): Promise<CreateFolderResult>;
326
350
  // Phase 12 (DDR-103) — single-property inline CSS edit (POST /_api/edit-css).
327
351
  // Main-origin only: writes one key into the element's inline `style={{}}` object.
328
352
  editCss(input: {
@@ -591,6 +615,17 @@ export interface ApiHooks {
591
615
  onCommentsChanged: (file: string) => void;
592
616
  /** Phase 8 Task 5 — fires after a successful PUT /_api/annotations write. */
593
617
  onAnnotationsChanged?: (file: string, svg: string) => void;
618
+ /**
619
+ * feature-file-tree-drag-drop-folders (Task 3) — is a collab room pinned
620
+ * (a shared-doc hub provider attached, DDR-064)? `moveCanvas` refuses the
621
+ * move rather than rename a file out from under a live hub session.
622
+ */
623
+ isRoomPinned?: (slug: string) => boolean;
624
+ /** Flush + force-tear-down a canvas's collab room ahead of a move (best
625
+ * effort — a room may not be live for the slug at all). */
626
+ flushAndDropRoom?: (slug: string) => Promise<void>;
627
+ /** Retarget `_active.json` (active/open_tabs/selected) after a move. */
628
+ retargetActive?: (fromFile: string, toFile: string) => void;
594
629
  }
595
630
 
596
631
  // FigJam v3 — the annotation sanitizer moved to annotations-model.ts (the
@@ -2354,7 +2389,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2354
2389
  // Only a real `.tsx` canvas, no traversal.
2355
2390
  if (rel.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
2356
2391
  if (!/\.tsx$/i.test(rel)) {
2357
- return { ok: false, status: 400, error: 'only .tsx canvases can be deleted' };
2392
+ // feature-file-tree-drag-drop-folders (follow-up) — a non-.tsx target is
2393
+ // a folder delete (dogfood gap: the tree offered no way to remove a
2394
+ // user-created folder at all). deleteFolder does its own validation, so
2395
+ // an invalid path still reports the right error from there.
2396
+ return deleteFolder(rel);
2358
2397
  }
2359
2398
 
2360
2399
  const fileAbs = path.join(paths.designRoot, rel);
@@ -2390,8 +2429,6 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2390
2429
  }
2391
2430
 
2392
2431
  const slug = fileSlug(rel);
2393
- const base = path.basename(rel).replace(/\.tsx$/i, '');
2394
- const groupDir = path.dirname(fileAbs);
2395
2432
  // Filesystem-safe wall-clock stamp (no `:`) so repeated deletes of the same
2396
2433
  // canvas don't collide in _trash.
2397
2434
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
@@ -2413,25 +2450,21 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2413
2450
  }
2414
2451
  };
2415
2452
 
2416
- // Primary + the full sidecar set (annotations, meta, history, canvas-state,
2417
- // comments). Flattened names so the trash dir is a self-contained bundle.
2418
- await moveIfExists(fileAbs, `${base}.tsx`);
2419
- await moveIfExists(path.join(groupDir, `${base}.meta.json`), `${base}.meta.json`);
2420
- await moveIfExists(
2421
- path.join(paths.designRoot, `${slug}.annotations.svg`),
2422
- `${slug}.annotations.svg`
2423
- );
2424
- await moveIfExists(path.join(paths.designRoot, '_history', slug), `_history__${slug}`);
2425
- await moveIfExists(
2426
- path.join(paths.canvasStateDir, `${slug}.json`),
2427
- `_canvas-state__${slug}.json`
2428
- );
2429
- // DDR-115 — the per-machine camera view file.
2430
- await moveIfExists(
2431
- path.join(paths.canvasStateDir, `${slug}.view.json`),
2432
- `_canvas-state__${slug}.view.json`
2433
- );
2434
- await moveIfExists(path.join(paths.commentsDir, `${slug}.json`), `_comments__${slug}.json`);
2453
+ // Every on-disk artifact this canvas owns — primary, same-basename
2454
+ // siblings (.meta.json/.css/.registry.json), and every slug-keyed sidecar
2455
+ // (history, canvas-state incl. the DDR-115 view file, comments, the
2456
+ // .ydoc.bin cache, annotations, footage EDL). Primary/sibling trash names
2457
+ // stay bare basenames (unchanged from before); slug-keyed sidecars flatten
2458
+ // their designRoot-relative path with `__` — both match the pre-existing
2459
+ // naming exactly, so old trash bundles and any restore tooling stay valid.
2460
+ for (const artifact of canvasArtifacts({ rel, paths })) {
2461
+ const relToDesignRoot = path.relative(paths.designRoot, artifact.abs);
2462
+ const destName =
2463
+ artifact.kind === 'slug-keyed'
2464
+ ? relToDesignRoot.split(path.sep).join('__')
2465
+ : path.basename(artifact.abs);
2466
+ await moveIfExists(artifact.abs, destName);
2467
+ }
2435
2468
 
2436
2469
  await Bun.write(
2437
2470
  path.join(trashDir, '_trash-manifest.json'),
@@ -2449,6 +2482,664 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2449
2482
  };
2450
2483
  }
2451
2484
 
2485
+ // feature-file-tree-drag-drop-folders (dogfood follow-up) — delete a whole
2486
+ // FOLDER. `relDir` is already designRel-stripped (deleteCanvas's caller did
2487
+ // that normalization). Trashes every nested canvas through deleteCanvas
2488
+ // itself (same `_trash/<stamp>__<slug>/` bundle shape, so a folder delete
2489
+ // is recoverable exactly like a single-canvas delete), then best-effort
2490
+ // removes whatever's left (empty subdirs, `.gitkeep` markers).
2491
+ async function deleteFolder(relDir: string): Promise<DeleteCanvasResult> {
2492
+ if (relDir.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
2493
+ const dirAbs = path.join(paths.designRoot, relDir);
2494
+ const resolvedDesignRoot = path.resolve(paths.designRoot);
2495
+ const resolvedDir = path.resolve(dirAbs);
2496
+ if (
2497
+ resolvedDir !== resolvedDesignRoot &&
2498
+ !resolvedDir.startsWith(`${resolvedDesignRoot}${path.sep}`)
2499
+ ) {
2500
+ return { ok: false, status: 400, error: 'path escapes the design root' };
2501
+ }
2502
+ try {
2503
+ const st = await statp(dirAbs);
2504
+ if (!st.isDirectory()) {
2505
+ return { ok: false, status: 400, error: 'only .tsx canvases or folders can be deleted' };
2506
+ }
2507
+ } catch {
2508
+ return { ok: false, status: 404, error: 'folder not found' };
2509
+ }
2510
+
2511
+ const deletable = cfg.canvasGroups.filter(
2512
+ (g) => g.label !== 'Design system' && !/^system(\/|$)/.test(g.path)
2513
+ );
2514
+ const inGroup = deletable.some((g) => {
2515
+ const gAbs = path.resolve(path.join(paths.designRoot, g.path));
2516
+ return resolvedDir === gAbs || resolvedDir.startsWith(`${gAbs}${path.sep}`);
2517
+ });
2518
+ if (!inGroup) {
2519
+ return {
2520
+ ok: false,
2521
+ status: 400,
2522
+ error: 'only folders under a managed canvas group can be deleted',
2523
+ };
2524
+ }
2525
+ if (deletable.some((g) => path.resolve(path.join(paths.designRoot, g.path)) === resolvedDir)) {
2526
+ return { ok: false, status: 400, error: 'cannot delete a canvas group root' };
2527
+ }
2528
+ if (!(await assertRealpathContained(resolvedDir))) {
2529
+ return { ok: false, status: 400, error: 'path escapes the design root via a symlink' };
2530
+ }
2531
+
2532
+ const drPrefix = paths.designRel.replace(/^\/+|\/+$/g, '');
2533
+ const dirRelPosix = path.posix.join(paths.designRel, relDir);
2534
+ const found = (await findHtmlFiles(dirAbs, dirRelPosix)).filter((p) => /\.tsx$/i.test(p));
2535
+ const MAX_BATCH = 50;
2536
+ if (found.length > MAX_BATCH) {
2537
+ return {
2538
+ ok: false,
2539
+ status: 400,
2540
+ error: `folder has ${found.length} canvases — the batch delete cap is ${MAX_BATCH}; delete a smaller subset`,
2541
+ };
2542
+ }
2543
+ const canvasRels = found.map((p) =>
2544
+ p.startsWith(`${drPrefix}/`) ? p.slice(drPrefix.length + 1) : p
2545
+ );
2546
+
2547
+ const trashed: string[] = [];
2548
+ let lastTrashDir = '';
2549
+ for (const r of canvasRels) {
2550
+ const result = await deleteCanvas({ file: r });
2551
+ if (result.ok) {
2552
+ trashed.push(...result.trashed);
2553
+ lastTrashDir = result.trashDir;
2554
+ }
2555
+ // best-effort — a canvas that fails to trash shouldn't abort the whole
2556
+ // folder delete; the final rm below still clears whatever's left.
2557
+ }
2558
+
2559
+ try {
2560
+ await rm(dirAbs, { recursive: true, force: true });
2561
+ } catch {
2562
+ /* best-effort — a leftover empty dir isn't worth failing the op over */
2563
+ }
2564
+
2565
+ ctx.bus.emit('canvas-list-update', { action: 'removed-folder', dir: dirRelPosix });
2566
+ return { ok: true, rel: relDir, slug: '', trashed, trashDir: lastTrashDir };
2567
+ }
2568
+
2569
+ // feature-file-tree-drag-drop-folders (security review finding) — the
2570
+ // string-based `resolvedX.startsWith(designRoot)` containment check (used
2571
+ // throughout this file, inherited from createCanvas's original pattern)
2572
+ // does NOT follow symlinks: `path.resolve()` normalizes `..` segments but
2573
+ // never dereferences a symlink. A symlink planted INSIDE a canvas group —
2574
+ // via a malicious git peer, a hub-synced project, or just an accident —
2575
+ // pointing outside designRoot would pass every existing containment check
2576
+ // while `rename()`/`mkdir()` follow it at the OS level, writing (or
2577
+ // reading) outside the project entirely. Verified exploitable against
2578
+ // moveCanvas pre-fix: a canvas moved "into" a symlinked destination folder
2579
+ // landed on disk outside designRoot. Walks up to the deepest EXISTING
2580
+ // ancestor (a not-yet-created leaf is safe by construction — mkdir cannot
2581
+ // traverse a symlink that doesn't exist yet) and verifies ITS realpath
2582
+ // stays inside designRoot's OWN realpath — resolving BOTH sides matters:
2583
+ // on macOS `$TMPDIR` (and every sandbox test using it) sits under
2584
+ // `/var/folders/...`, itself a symlink to `/private/var/folders/...`, so
2585
+ // comparing a realpath'd candidate against a merely `path.resolve()`d root
2586
+ // false-positives on every path in a temp-dir sandbox (caught by this
2587
+ // function's own test coverage). Same symlink gap likely pre-exists in
2588
+ // createCanvas/deleteCanvas's original containment checks — out of scope
2589
+ // for this feature to fix retroactively, flagged in DDR-198 as a follow-up.
2590
+ async function realpathOfDeepestExisting(targetAbs: string): Promise<string | null> {
2591
+ let probe = targetAbs;
2592
+ for (;;) {
2593
+ try {
2594
+ return path.resolve(await realpath(probe));
2595
+ } catch {
2596
+ const parent = path.dirname(probe);
2597
+ if (parent === probe) return null; // walked to the filesystem root, found nothing
2598
+ probe = parent;
2599
+ }
2600
+ }
2601
+ }
2602
+
2603
+ async function assertRealpathContained(targetAbs: string): Promise<boolean> {
2604
+ const realRoot = await realpathOfDeepestExisting(path.resolve(paths.designRoot));
2605
+ const realTarget = await realpathOfDeepestExisting(targetAbs);
2606
+ if (realRoot === null || realTarget === null) return false;
2607
+ return realTarget === realRoot || realTarget.startsWith(`${realRoot}${path.sep}`);
2608
+ }
2609
+
2610
+ // feature-file-tree-drag-drop-folders (Task 3) — move/rename a canvas + its
2611
+ // full artifact set (POST /_api/fs-move). Same main-origin-only trust
2612
+ // boundary as createCanvas/deleteCanvas (DDR-054): never reachable from the
2613
+ // untrusted canvas iframe. NOT atomic across artifacts — a crash mid-loop
2614
+ // leaves a partial state; the primary `.tsx` moves FIRST (so the tree is
2615
+ // never wrong about where the canvas lives) and every relocation is logged
2616
+ // to `_history/<toSlug>/_move.json` for forensic recovery. See the DDR
2617
+ // (Task 14) for the accepted-limitation writeup.
2618
+ async function moveCanvas(input: { file?: unknown; toDir?: unknown }): Promise<MoveCanvasResult> {
2619
+ const raw = input?.file;
2620
+ if (typeof raw !== 'string' || !raw.trim()) {
2621
+ return { ok: false, status: 400, error: 'file is required' };
2622
+ }
2623
+ let rel = raw.trim();
2624
+ try {
2625
+ rel = decodeURIComponent(rel);
2626
+ } catch {
2627
+ /* leave as-is */
2628
+ }
2629
+ rel = rel.replace(/^\/+/, '');
2630
+ const drPrefix = paths.designRel.replace(/^\/+|\/+$/g, '');
2631
+ if (rel.startsWith(`${drPrefix}/`)) rel = rel.slice(drPrefix.length + 1);
2632
+ if (rel.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
2633
+ if (!/\.tsx$/i.test(rel)) {
2634
+ // feature-file-tree-drag-drop-folders (Task 11) — a non-.tsx source is a
2635
+ // folder move (dragging a folder onto a folder). moveFolder does its own
2636
+ // existence + containment validation, so an invalid path still reports
2637
+ // the right error from there.
2638
+ return moveFolder(rel, input?.toDir);
2639
+ }
2640
+
2641
+ if (typeof input?.toDir !== 'string') {
2642
+ return { ok: false, status: 400, error: 'toDir is required' };
2643
+ }
2644
+ let toDir = input.toDir.trim().replace(/^\/+|\/+$/g, '');
2645
+ try {
2646
+ toDir = decodeURIComponent(toDir);
2647
+ } catch {
2648
+ /* leave as-is */
2649
+ }
2650
+ if (toDir === drPrefix) toDir = '';
2651
+ else if (toDir.startsWith(`${drPrefix}/`)) toDir = toDir.slice(drPrefix.length + 1);
2652
+ if (toDir.includes('..')) return { ok: false, status: 400, error: 'invalid destination' };
2653
+
2654
+ const fileAbs = path.join(paths.designRoot, rel);
2655
+ const resolvedDesignRoot = path.resolve(paths.designRoot);
2656
+ const resolvedFile = path.resolve(fileAbs);
2657
+ if (
2658
+ resolvedFile !== resolvedDesignRoot &&
2659
+ !resolvedFile.startsWith(`${resolvedDesignRoot}${path.sep}`)
2660
+ ) {
2661
+ return { ok: false, status: 400, error: 'source path escapes the design root' };
2662
+ }
2663
+
2664
+ // Two-layer containment + non-DS group membership, for BOTH source and
2665
+ // destination — the exact predicate deleteCanvas uses for the source.
2666
+ const deletable = cfg.canvasGroups.filter(
2667
+ (g) => g.label !== 'Design system' && !/^system(\/|$)/.test(g.path)
2668
+ );
2669
+ const withinAGroup = (abs: string) =>
2670
+ deletable.some((g) => {
2671
+ const gAbs = path.resolve(path.join(paths.designRoot, g.path));
2672
+ return abs === gAbs || abs.startsWith(`${gAbs}${path.sep}`);
2673
+ });
2674
+ if (!withinAGroup(path.dirname(resolvedFile))) {
2675
+ return {
2676
+ ok: false,
2677
+ status: 400,
2678
+ error: 'only canvases under a managed canvas group can be moved',
2679
+ };
2680
+ }
2681
+ if (!(await assertRealpathContained(path.dirname(resolvedFile)))) {
2682
+ return { ok: false, status: 400, error: 'source path escapes the design root via a symlink' };
2683
+ }
2684
+
2685
+ const toDirAbs = path.join(paths.designRoot, toDir);
2686
+ const resolvedToDir = path.resolve(toDirAbs);
2687
+ if (
2688
+ resolvedToDir !== resolvedDesignRoot &&
2689
+ !resolvedToDir.startsWith(`${resolvedDesignRoot}${path.sep}`)
2690
+ ) {
2691
+ return { ok: false, status: 400, error: 'destination resolves outside the design root' };
2692
+ }
2693
+ if (!withinAGroup(resolvedToDir)) {
2694
+ return {
2695
+ ok: false,
2696
+ status: 400,
2697
+ error: 'destination must be inside a managed canvas group',
2698
+ };
2699
+ }
2700
+ if (!(await assertRealpathContained(resolvedToDir))) {
2701
+ return {
2702
+ ok: false,
2703
+ status: 400,
2704
+ error: 'destination resolves outside the design root via a symlink',
2705
+ };
2706
+ }
2707
+
2708
+ if (!(await Bun.file(fileAbs).exists())) {
2709
+ return { ok: false, status: 404, error: 'canvas not found' };
2710
+ }
2711
+
2712
+ const base = path.basename(rel);
2713
+ const toRel = path.posix.join(toDir, base);
2714
+ if (path.posix.normalize(toRel) === path.posix.normalize(rel)) {
2715
+ return { ok: false, status: 400, error: 'source and destination are the same' };
2716
+ }
2717
+ const toAbs = path.join(paths.designRoot, toRel);
2718
+ if (await Bun.file(toAbs).exists()) {
2719
+ return {
2720
+ ok: false,
2721
+ status: 409,
2722
+ error: `a canvas named "${base.replace(/\.tsx$/i, '')}" already exists in ${toDir || '.'}`,
2723
+ };
2724
+ }
2725
+
2726
+ const fromSlug = fileSlug(rel);
2727
+ const toSlug = fileSlug(toRel);
2728
+
2729
+ // Slug-collision guard (security review finding) — canvasSlugFromRel's
2730
+ // `/`→`-` flattening is not injective: "ui/a-b.tsx" and "ui/a/b.tsx" both
2731
+ // hash to "ui-a-b". Without this check, moving a canvas into a directory
2732
+ // that happens to collide with another canvas's slug would silently
2733
+ // clobber that OTHER canvas's history/comments/annotations/camera the
2734
+ // moment the primary rename lands (the "primary moves first" ordering
2735
+ // that makes a crash mid-move recoverable also means the file path check
2736
+ // above — which only looks at the DESTINATION FILE PATH, not the slug —
2737
+ // is not sufficient on its own). Reuses the same authoritative
2738
+ // slug→file resolver `fileForSlug` uses for comment routing (DDR-064).
2739
+ const slugCollision = await fileForSlug(toSlug);
2740
+ if (slugCollision) {
2741
+ let collisionRel = slugCollision;
2742
+ if (collisionRel.startsWith(`${drPrefix}/`))
2743
+ collisionRel = collisionRel.slice(drPrefix.length + 1);
2744
+ if (collisionRel !== rel) {
2745
+ return {
2746
+ ok: false,
2747
+ status: 409,
2748
+ error: `moving here would collide with "${collisionRel}"'s slug ("${toSlug}") — rename one of them first`,
2749
+ };
2750
+ }
2751
+ }
2752
+
2753
+ // Collab guard — refuse a move while a shared-doc hub provider is pinned
2754
+ // to this slug's room (DDR-064); otherwise flush + force-drop so the room
2755
+ // isn't left keyed to a slug that no longer resolves to a file.
2756
+ if (hooks.isRoomPinned?.(fromSlug)) {
2757
+ return {
2758
+ ok: false,
2759
+ status: 409,
2760
+ error: 'canvas has a live shared session — cannot move while pinned to the hub',
2761
+ };
2762
+ }
2763
+ await hooks.flushAndDropRoom?.(fromSlug);
2764
+
2765
+ // Primary `.tsx` moves FIRST — abort the whole op if this fails so the
2766
+ // tree is never wrong about where the canvas lives.
2767
+ await mkdir(toDirAbs, { recursive: true });
2768
+ try {
2769
+ await rename(fileAbs, toAbs);
2770
+ } catch (err) {
2771
+ return { ok: false, status: 500, error: err instanceof Error ? err.message : 'move failed' };
2772
+ }
2773
+
2774
+ const moved: string[] = [path.relative(paths.repoRoot, toAbs)];
2775
+ for (const artifact of canvasArtifacts({ rel, paths })) {
2776
+ if (artifact.kind === 'primary') continue; // already moved above
2777
+ const dest = relocatedName(artifact, rel, toRel, paths);
2778
+ if (path.resolve(dest) === path.resolve(artifact.abs)) continue;
2779
+ try {
2780
+ await statp(artifact.abs); // throws if absent — best-effort, most sidecars won't exist
2781
+ } catch {
2782
+ continue;
2783
+ }
2784
+ try {
2785
+ await mkdir(path.dirname(dest), { recursive: true });
2786
+ await rename(artifact.abs, dest);
2787
+ moved.push(path.relative(paths.repoRoot, dest));
2788
+ } catch {
2789
+ /* best-effort — a sidecar that can't relocate shouldn't abort the move */
2790
+ }
2791
+ }
2792
+
2793
+ // Re-key the `_locator.json` entry (the DIVERGENT slug shape) under its
2794
+ // own per-path mutex.
2795
+ const locatorAbs = path.join(paths.designRoot, '_locator.json');
2796
+ const fromKey = locatorKeyFor(rel);
2797
+ const toKey = locatorKeyFor(toRel);
2798
+ try {
2799
+ const map = await readLocator(locatorAbs, fromKey);
2800
+ if (map) {
2801
+ await writeLocator(locatorAbs, toKey, map);
2802
+ await clearLocatorSlug(locatorAbs, fromKey);
2803
+ }
2804
+ } catch {
2805
+ /* best-effort — a stale locator entry re-derives on next transpile */
2806
+ }
2807
+
2808
+ // Retarget `_active.json` (active canvas / open tabs / selection) if this
2809
+ // canvas was referenced anywhere in it.
2810
+ const fromFile = path.posix.join(paths.designRel, rel);
2811
+ const toFile = path.posix.join(paths.designRel, toRel);
2812
+ hooks.retargetActive?.(fromFile, toFile);
2813
+
2814
+ // Forensic log for the non-atomic move — see the doc comment above.
2815
+ try {
2816
+ const toHistoryDir = path.join(paths.historyDir, toSlug);
2817
+ await mkdir(toHistoryDir, { recursive: true });
2818
+ await Bun.write(
2819
+ path.join(toHistoryDir, '_move.json'),
2820
+ `${JSON.stringify(
2821
+ { fromRel: rel, toRel, fromSlug, toSlug, moved, movedAt: new Date().toISOString() },
2822
+ null,
2823
+ 2
2824
+ )}\n`
2825
+ );
2826
+ } catch {
2827
+ /* forensic log only — never block the move on it */
2828
+ }
2829
+
2830
+ ctx.bus.emit('canvas-list-update', {
2831
+ action: 'moved',
2832
+ rel: toRel,
2833
+ slug: toSlug,
2834
+ fromRel: rel,
2835
+ fromSlug,
2836
+ });
2837
+
2838
+ return { ok: true, fromRel: rel, toRel, fromSlug, toSlug, moved };
2839
+ }
2840
+
2841
+ // feature-file-tree-drag-drop-folders (Task 11) — move a whole FOLDER
2842
+ // (dragging a folder onto a folder). `relDir` is already designRel-stripped
2843
+ // (moveCanvas's caller did that normalization). One native directory
2844
+ // `rename()` relocates the primary `.tsx` + same-dir siblings for EVERY
2845
+ // nested canvas at once (they live inside the moved directory); only the
2846
+ // slug-keyed sidecars (flat dirs like `_history/<slug>/`, not nested by
2847
+ // folder structure) need their own per-canvas relocation afterward.
2848
+ async function moveFolder(relDir: string, toDirRaw: unknown): Promise<MoveCanvasResult> {
2849
+ if (relDir.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
2850
+ const dirAbs = path.join(paths.designRoot, relDir);
2851
+ const resolvedDesignRoot = path.resolve(paths.designRoot);
2852
+ const resolvedDir = path.resolve(dirAbs);
2853
+ if (
2854
+ resolvedDir !== resolvedDesignRoot &&
2855
+ !resolvedDir.startsWith(`${resolvedDesignRoot}${path.sep}`)
2856
+ ) {
2857
+ return { ok: false, status: 400, error: 'source path escapes the design root' };
2858
+ }
2859
+ try {
2860
+ const st = await statp(dirAbs);
2861
+ if (!st.isDirectory()) {
2862
+ return { ok: false, status: 400, error: 'only .tsx canvases or folders can be moved' };
2863
+ }
2864
+ } catch {
2865
+ return { ok: false, status: 404, error: 'folder not found' };
2866
+ }
2867
+
2868
+ const deletable = cfg.canvasGroups.filter(
2869
+ (g) => g.label !== 'Design system' && !/^system(\/|$)/.test(g.path)
2870
+ );
2871
+ const withinAGroup = (abs: string) =>
2872
+ deletable.some((g) => {
2873
+ const gAbs = path.resolve(path.join(paths.designRoot, g.path));
2874
+ return abs === gAbs || abs.startsWith(`${gAbs}${path.sep}`);
2875
+ });
2876
+ if (!withinAGroup(resolvedDir)) {
2877
+ return {
2878
+ ok: false,
2879
+ status: 400,
2880
+ error: 'only folders under a managed canvas group can be moved',
2881
+ };
2882
+ }
2883
+ if (deletable.some((g) => path.resolve(path.join(paths.designRoot, g.path)) === resolvedDir)) {
2884
+ return { ok: false, status: 400, error: 'cannot move a canvas group root' };
2885
+ }
2886
+ if (!(await assertRealpathContained(resolvedDir))) {
2887
+ return { ok: false, status: 400, error: 'source path escapes the design root via a symlink' };
2888
+ }
2889
+
2890
+ const drPrefix = paths.designRel.replace(/^\/+|\/+$/g, '');
2891
+ let toDir = typeof toDirRaw === 'string' ? toDirRaw.trim().replace(/^\/+|\/+$/g, '') : '';
2892
+ try {
2893
+ toDir = decodeURIComponent(toDir);
2894
+ } catch {
2895
+ /* leave as-is */
2896
+ }
2897
+ if (toDir === drPrefix) toDir = '';
2898
+ else if (toDir.startsWith(`${drPrefix}/`)) toDir = toDir.slice(drPrefix.length + 1);
2899
+ if (toDir.includes('..')) return { ok: false, status: 400, error: 'invalid destination' };
2900
+
2901
+ const toDirAbs = path.join(paths.designRoot, toDir);
2902
+ const resolvedToDir = path.resolve(toDirAbs);
2903
+ if (
2904
+ resolvedToDir !== resolvedDesignRoot &&
2905
+ !resolvedToDir.startsWith(`${resolvedDesignRoot}${path.sep}`)
2906
+ ) {
2907
+ return { ok: false, status: 400, error: 'destination resolves outside the design root' };
2908
+ }
2909
+ if (!withinAGroup(resolvedToDir)) {
2910
+ return { ok: false, status: 400, error: 'destination must be inside a managed canvas group' };
2911
+ }
2912
+ if (!(await assertRealpathContained(resolvedToDir))) {
2913
+ return {
2914
+ ok: false,
2915
+ status: 400,
2916
+ error: 'destination resolves outside the design root via a symlink',
2917
+ };
2918
+ }
2919
+ // Refuse a move into itself or a descendant of itself.
2920
+ if (resolvedToDir === resolvedDir || resolvedToDir.startsWith(`${resolvedDir}${path.sep}`)) {
2921
+ return { ok: false, status: 400, error: 'cannot move a folder into itself' };
2922
+ }
2923
+
2924
+ const base = path.basename(relDir);
2925
+ const toRelDir = path.posix.join(toDir, base);
2926
+ if (path.posix.normalize(toRelDir) === path.posix.normalize(relDir)) {
2927
+ return { ok: false, status: 400, error: 'source and destination are the same' };
2928
+ }
2929
+ const toDirFinalAbs = path.join(paths.designRoot, toRelDir);
2930
+ try {
2931
+ await statp(toDirFinalAbs);
2932
+ return { ok: false, status: 409, error: `"${base}" already exists in ${toDir || '.'}` };
2933
+ } catch {
2934
+ /* good — doesn't exist yet */
2935
+ }
2936
+
2937
+ // Enumerate every .tsx canvas under this folder (recursive) BEFORE the
2938
+ // rename, so each one's from/to rel is computed against the OLD tree
2939
+ // shape. `.html` legacy canvases are out of scope, same as moveCanvas.
2940
+ const dirRelPosix = path.posix.join(paths.designRel, relDir);
2941
+ const found = (await findHtmlFiles(dirAbs, dirRelPosix)).filter((p) => /\.tsx$/i.test(p));
2942
+ const MAX_BATCH = 50;
2943
+ if (found.length > MAX_BATCH) {
2944
+ return {
2945
+ ok: false,
2946
+ status: 400,
2947
+ error: `folder has ${found.length} canvases — the batch move cap is ${MAX_BATCH}; move a smaller subset`,
2948
+ };
2949
+ }
2950
+ const canvasRels = found.map((p) =>
2951
+ p.startsWith(`${drPrefix}/`) ? p.slice(drPrefix.length + 1) : p
2952
+ );
2953
+
2954
+ // Slug-collision guard, per nested canvas — same rationale as
2955
+ // moveCanvas's (canvasSlugFromRel's `/`→`-` flattening is not injective).
2956
+ // Excludes collisions AMONG the batch itself (those canvases are moving
2957
+ // together, not being clobbered by an outsider) but refuses if any
2958
+ // destination slug already belongs to a canvas OUTSIDE this move.
2959
+ const batchRelSet = new Set(canvasRels);
2960
+ for (const r of canvasRels) {
2961
+ const toRel = toRelDir + r.slice(relDir.length);
2962
+ const toSlug = fileSlug(toRel);
2963
+ const collision = await fileForSlug(toSlug);
2964
+ if (collision) {
2965
+ let collisionRel = collision;
2966
+ if (collisionRel.startsWith(`${drPrefix}/`))
2967
+ collisionRel = collisionRel.slice(drPrefix.length + 1);
2968
+ if (!batchRelSet.has(collisionRel)) {
2969
+ return {
2970
+ ok: false,
2971
+ status: 409,
2972
+ error: `moving "${path.basename(r)}" would collide with "${collisionRel}"'s slug ("${toSlug}") — rename one of them first`,
2973
+ };
2974
+ }
2975
+ }
2976
+ }
2977
+
2978
+ // Collab guard for every canvas found, BEFORE any disk mutation — refuse
2979
+ // the whole batch if even one room is pinned (a shared-doc hub provider
2980
+ // attached, DDR-064).
2981
+ for (const r of canvasRels) {
2982
+ if (hooks.isRoomPinned?.(fileSlug(r))) {
2983
+ return {
2984
+ ok: false,
2985
+ status: 409,
2986
+ error: `"${path.basename(r)}" has a live shared session — cannot move the folder while it's pinned to the hub`,
2987
+ };
2988
+ }
2989
+ }
2990
+ for (const r of canvasRels) {
2991
+ await hooks.flushAndDropRoom?.(fileSlug(r));
2992
+ }
2993
+
2994
+ await mkdir(toDirAbs, { recursive: true });
2995
+ try {
2996
+ await rename(dirAbs, toDirFinalAbs);
2997
+ } catch (err) {
2998
+ return { ok: false, status: 500, error: err instanceof Error ? err.message : 'move failed' };
2999
+ }
3000
+
3001
+ const moved: string[] = [path.relative(paths.repoRoot, toDirFinalAbs)];
3002
+ const locatorAbs = path.join(paths.designRoot, '_locator.json');
3003
+ for (const fromRel of canvasRels) {
3004
+ const toRel = toRelDir + fromRel.slice(relDir.length);
3005
+ const fromSlug = fileSlug(fromRel);
3006
+ const toSlug = fileSlug(toRel);
3007
+ for (const artifact of canvasArtifacts({ rel: fromRel, paths })) {
3008
+ if (artifact.kind !== 'slug-keyed') continue; // primary/siblings already moved with the dir
3009
+ const dest = relocatedName(artifact, fromRel, toRel, paths);
3010
+ if (path.resolve(dest) === path.resolve(artifact.abs)) continue;
3011
+ try {
3012
+ await statp(artifact.abs);
3013
+ } catch {
3014
+ continue;
3015
+ }
3016
+ try {
3017
+ await mkdir(path.dirname(dest), { recursive: true });
3018
+ await rename(artifact.abs, dest);
3019
+ moved.push(path.relative(paths.repoRoot, dest));
3020
+ } catch {
3021
+ /* best-effort */
3022
+ }
3023
+ }
3024
+ const fromKey = locatorKeyFor(fromRel);
3025
+ const toKey = locatorKeyFor(toRel);
3026
+ try {
3027
+ const map = await readLocator(locatorAbs, fromKey);
3028
+ if (map) {
3029
+ await writeLocator(locatorAbs, toKey, map);
3030
+ await clearLocatorSlug(locatorAbs, fromKey);
3031
+ }
3032
+ } catch {
3033
+ /* best-effort */
3034
+ }
3035
+ const fromFile = path.posix.join(paths.designRel, fromRel);
3036
+ const toFile = path.posix.join(paths.designRel, toRel);
3037
+ hooks.retargetActive?.(fromFile, toFile);
3038
+ try {
3039
+ const toHistoryDir = path.join(paths.historyDir, toSlug);
3040
+ await mkdir(toHistoryDir, { recursive: true });
3041
+ await Bun.write(
3042
+ path.join(toHistoryDir, '_move.json'),
3043
+ `${JSON.stringify(
3044
+ {
3045
+ fromRel,
3046
+ toRel,
3047
+ fromSlug,
3048
+ toSlug,
3049
+ folderMove: true,
3050
+ movedAt: new Date().toISOString(),
3051
+ },
3052
+ null,
3053
+ 2
3054
+ )}\n`
3055
+ );
3056
+ } catch {
3057
+ /* forensic log only */
3058
+ }
3059
+ ctx.bus.emit('canvas-list-update', {
3060
+ action: 'moved',
3061
+ rel: toRel,
3062
+ slug: toSlug,
3063
+ fromRel,
3064
+ fromSlug,
3065
+ });
3066
+ }
3067
+
3068
+ return { ok: true, fromRel: relDir, toRel: toRelDir, fromSlug: '', toSlug: '', moved };
3069
+ }
3070
+
3071
+ // feature-file-tree-drag-drop-folders (Task 4) — create an empty folder
3072
+ // inside a canvas group (POST /_api/fs-mkdir). Git can't track an empty
3073
+ // directory, so a `.gitkeep` goes in immediately — without it a
3074
+ // collaborator's `git pull` never materializes the folder. Same
3075
+ // main-origin-only / non-DS-group / two-layer-containment posture as
3076
+ // createCanvas/moveCanvas.
3077
+ async function createFolder(input: {
3078
+ parent?: unknown;
3079
+ name?: unknown;
3080
+ }): Promise<CreateFolderResult> {
3081
+ const v = validateFolderName(input?.name);
3082
+ if (!v.ok || !v.name) {
3083
+ return { ok: false, status: 400, error: v.error ?? 'invalid name' };
3084
+ }
3085
+
3086
+ let parent = typeof input?.parent === 'string' ? input.parent.trim() : '';
3087
+ parent = parent.replace(/^\/+|\/+$/g, '');
3088
+ const drPrefix = paths.designRel.replace(/^\/+|\/+$/g, '');
3089
+ if (parent === drPrefix) parent = '';
3090
+ else if (parent.startsWith(`${drPrefix}/`)) parent = parent.slice(drPrefix.length + 1);
3091
+ if (parent.includes('..')) return { ok: false, status: 400, error: 'invalid parent' };
3092
+
3093
+ const parentAbs = path.join(paths.designRoot, parent);
3094
+ const resolvedDesignRoot = path.resolve(paths.designRoot);
3095
+ const resolvedParent = path.resolve(parentAbs);
3096
+ if (
3097
+ resolvedParent !== resolvedDesignRoot &&
3098
+ !resolvedParent.startsWith(`${resolvedDesignRoot}${path.sep}`)
3099
+ ) {
3100
+ return { ok: false, status: 400, error: 'parent resolves outside the design root' };
3101
+ }
3102
+ const deletable = cfg.canvasGroups.filter(
3103
+ (g) => g.label !== 'Design system' && !/^system(\/|$)/.test(g.path)
3104
+ );
3105
+ const withinAGroup = deletable.some((g) => {
3106
+ const gAbs = path.resolve(path.join(paths.designRoot, g.path));
3107
+ return resolvedParent === gAbs || resolvedParent.startsWith(`${gAbs}${path.sep}`);
3108
+ });
3109
+ if (!withinAGroup) {
3110
+ return { ok: false, status: 400, error: 'parent must be inside a managed canvas group' };
3111
+ }
3112
+ if (!(await assertRealpathContained(resolvedParent))) {
3113
+ return {
3114
+ ok: false,
3115
+ status: 400,
3116
+ error: 'parent resolves outside the design root via a symlink',
3117
+ };
3118
+ }
3119
+
3120
+ const dirAbs = path.join(parentAbs, v.name);
3121
+ const resolvedDir = path.resolve(dirAbs);
3122
+ if (
3123
+ resolvedDir !== path.join(resolvedParent, v.name) ||
3124
+ !resolvedDir.startsWith(`${resolvedParent}${path.sep}`)
3125
+ ) {
3126
+ return { ok: false, status: 400, error: 'resolved path escapes the parent folder' };
3127
+ }
3128
+ try {
3129
+ await statp(dirAbs); // throws if absent (works for files AND dirs)
3130
+ return { ok: false, status: 409, error: `"${v.name}" already exists` };
3131
+ } catch {
3132
+ /* good — doesn't exist yet */
3133
+ }
3134
+
3135
+ await mkdir(dirAbs, { recursive: true });
3136
+ await Bun.write(path.join(dirAbs, '.gitkeep'), '');
3137
+
3138
+ const dirRel = path.posix.join(paths.designRel, parent, v.name);
3139
+ ctx.bus.emit('canvas-list-update', { action: 'mkdir', dir: dirRel });
3140
+ return { ok: true, dir: dirRel };
3141
+ }
3142
+
2452
3143
  // Phase 12 (DDR-103) — resolve a v2 canvas slug (`selected.canvas`: POSIX,
2453
3144
  // extension-less, designRoot-relative — matches `_locator.json` keys + the
2454
3145
  // `canvasSlug()` shape) to its absolute `.tsx` path, with a containment
@@ -4200,9 +4891,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4200
4891
  // Always include canvas sidecars (`.meta.json`, `.css`, `.registry.json`)
4201
4892
  // so the client can nest them under their primary `.tsx` / `.html`. DS
4202
4893
  // groups additionally surface `.md` for README + SKILL docs.
4894
+ // feature-file-tree-drag-drop-folders (Task 6) — `dirs` accumulates
4895
+ // every directory in this group (incl. empty ones) via the SAME
4896
+ // traversal, so a freshly `mkdir`'d folder with no files yet is still
4897
+ // representable in the tree.
4898
+ const dirs: string[] = [];
4203
4899
  const filePaths = isDs
4204
- ? await findFiles(groupAbs, groupRel, ['.tsx', '.html', '.md', '.css', '.json'])
4205
- : await findFiles(groupAbs, groupRel, ['.tsx', '.html', '.css', '.json']);
4900
+ ? await findFiles(groupAbs, groupRel, ['.tsx', '.html', '.md', '.css', '.json'], dirs)
4901
+ : await findFiles(groupAbs, groupRel, ['.tsx', '.html', '.css', '.json'], dirs);
4206
4902
  // DDR-093 — record each `.tsx` canvas's design system. canvasUrl() only
4207
4903
  // injects tokens for `.tsx`, so skip everything else. Path-owned DS wins
4208
4904
  // (system/<ds>/…); otherwise the sidecar's `meta.designSystem`, defaulting
@@ -4247,6 +4943,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4247
4943
  stripPrefix: `${paths.designRel}/${g.path}/`,
4248
4944
  kind: 'canvas' as const,
4249
4945
  dsFolders: dsFolders.length ? dsFolders : undefined,
4946
+ dirs,
4250
4947
  });
4251
4948
  }
4252
4949
 
@@ -4509,6 +5206,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4509
5206
  resolveChatAttachment,
4510
5207
  createCanvas,
4511
5208
  deleteCanvas,
5209
+ moveCanvas,
5210
+ createFolder,
4512
5211
  editCss,
4513
5212
  editText,
4514
5213
  editAttr,