@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.
- package/apps/studio/api.ts +751 -52
- package/apps/studio/canvas-artifacts.ts +153 -0
- package/apps/studio/canvas-create.ts +23 -0
- package/apps/studio/canvas-slug.ts +30 -0
- package/apps/studio/client/app.jsx +547 -90
- package/apps/studio/client/panels/CloudBar.jsx +284 -0
- package/apps/studio/client/styles/3-shell-maude.css +26 -0
- package/apps/studio/client/tree-row-menu.jsx +132 -0
- package/apps/studio/client/use-tree-drag.js +120 -0
- package/apps/studio/cloud/endpoints.ts +236 -0
- package/apps/studio/collab/registry.ts +23 -0
- package/apps/studio/dist/client.bundle.js +960 -958
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +113 -0
- package/apps/studio/inspect.ts +49 -0
- package/apps/studio/server.ts +15 -0
- package/apps/studio/test/canvas-artifacts.test.ts +125 -0
- package/apps/studio/test/canvas-create-api.test.ts +159 -1
- package/apps/studio/test/canvas-move-api.test.ts +556 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
- package/apps/studio/test/fs-mkdir-api.test.ts +248 -0
- package/apps/studio/whats-new.json +18 -0
- package/cli/bin/maude.mjs +1 -0
- package/cli/commands/hub-workspace.mjs +294 -7
- package/cli/commands/init.mjs +33 -2
- package/cli/commands/kg.mjs +129 -4
- package/cli/commands/share.mjs +184 -0
- package/cli/lib/ddr-to-kgai.mjs +310 -82
- package/cli/lib/ddr-to-kgai.test.mjs +20 -0
- package/cli/lib/share-plan.mjs +96 -0
- package/cli/lib/share-plan.test.mjs +57 -0
- package/cli/lib/workspace-plan.mjs +130 -12
- package/cli/lib/workspace-plan.test.mjs +160 -0
- package/package.json +8 -8
package/apps/studio/api.ts
CHANGED
|
@@ -3,10 +3,28 @@
|
|
|
3
3
|
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import type { Dirent } from 'node:fs';
|
|
6
|
-
import {
|
|
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 {
|
|
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
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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())
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2417
|
-
//
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
)
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
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,
|