@1agh/maude 1.2.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/apps/studio/acp/index.ts +43 -7
- package/apps/studio/annotations-layer.tsx +191 -116
- package/apps/studio/annotations-model.ts +39 -0
- package/apps/studio/annotations-sync.ts +50 -0
- package/apps/studio/api.ts +732 -38
- package/apps/studio/bin/annotate.mjs +3 -1
- package/apps/studio/bin/server-up.sh +17 -2
- package/apps/studio/canvas-build.ts +49 -8
- package/apps/studio/canvas-edit.ts +468 -7
- package/apps/studio/canvas-lib.tsx +82 -18
- package/apps/studio/canvas-list-watch.ts +33 -2
- package/apps/studio/canvas-notice-message.ts +16 -0
- package/apps/studio/canvas-notifications.tsx +19 -0
- package/apps/studio/canvas-shell.tsx +57 -6
- package/apps/studio/client/app.jsx +783 -245
- package/apps/studio/client/apply-edit-request.ts +55 -0
- package/apps/studio/client/canvas-url.js +39 -1
- package/apps/studio/client/export-center.jsx +111 -61
- package/apps/studio/client/github.js +38 -1
- package/apps/studio/client/index-loader.ts +76 -0
- package/apps/studio/client/panels/CloudBar.jsx +52 -6
- package/apps/studio/client/panels/GitPanel.jsx +103 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
- package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
- package/apps/studio/client/panels/SyncPanel.jsx +113 -0
- package/apps/studio/client/panels/TeamProjects.jsx +376 -0
- package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
- package/apps/studio/client/photo-knobs.jsx +3 -0
- package/apps/studio/client/share-dialog.jsx +91 -0
- package/apps/studio/client/share-link.js +94 -0
- package/apps/studio/client/styles/3-shell-maude.css +36 -18
- package/apps/studio/client/styles/4-components.css +18 -1
- package/apps/studio/client/tree-row-menu.jsx +48 -3
- package/apps/studio/client/whats-new.jsx +26 -32
- package/apps/studio/cloud/endpoints.ts +132 -0
- package/apps/studio/collab/awareness-bridge.ts +65 -14
- package/apps/studio/collab/index.ts +4 -0
- package/apps/studio/collab/persistence.ts +5 -1
- package/apps/studio/collab/registry.ts +9 -3
- package/apps/studio/collab/room.ts +13 -2
- package/apps/studio/context.ts +59 -0
- package/apps/studio/dist/client.bundle.js +1594 -1562
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/sonner.js +1 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/accepted-guard.ts +36 -0
- package/apps/studio/hmr-broadcast.ts +34 -1
- package/apps/studio/http.ts +300 -18
- package/apps/studio/inspect.ts +19 -0
- package/apps/studio/managed-projects.ts +172 -0
- package/apps/studio/notifications.tsx +292 -0
- package/apps/studio/runtime-bundle.ts +2 -0
- package/apps/studio/server.ts +59 -3
- package/apps/studio/sync/accepted-cold-start.ts +225 -0
- package/apps/studio/sync/accepted-link.ts +320 -0
- package/apps/studio/sync/action-stage.ts +343 -0
- package/apps/studio/sync/agent.ts +12 -60
- package/apps/studio/sync/cell-file-events.ts +3 -0
- package/apps/studio/sync/codec.ts +73 -6
- package/apps/studio/sync/ctl-provider.ts +15 -2
- package/apps/studio/sync/document-discovery.ts +81 -0
- package/apps/studio/sync/file-membership.ts +10 -2
- package/apps/studio/sync/file-plane.ts +309 -26
- package/apps/studio/sync/index.ts +1174 -21
- package/apps/studio/sync/migrate-seed.ts +110 -19
- package/apps/studio/sync/poke.ts +4 -2
- package/apps/studio/sync/presentation.ts +315 -2
- package/apps/studio/sync/projection.ts +820 -23
- package/apps/studio/sync/repeated-module.ts +73 -0
- package/apps/studio/sync/revision-barrier.ts +129 -0
- package/apps/studio/sync/seed-repair.ts +46 -0
- package/apps/studio/sync/source-merge.ts +100 -0
- package/apps/studio/sync/source-ops.ts +289 -0
- package/apps/studio/sync/source-recovery.ts +70 -0
- package/apps/studio/sync/source-validation.ts +56 -0
- package/apps/studio/sync/status.ts +83 -1
- package/apps/studio/sync/transaction-client.ts +486 -0
- package/apps/studio/sync/writer-registry.ts +236 -0
- package/apps/studio/text-caret.ts +35 -0
- package/apps/studio/undo-hud.tsx +9 -87
- package/apps/studio/use-canvas-media-drop.tsx +4 -39
- package/apps/studio/use-tool-mode.tsx +44 -0
- package/apps/studio/whats-new.json +137 -0
- package/cli/lib/harness/codex-runtime.mjs +4 -1
- package/package.json +9 -8
- package/plugins/design/dependencies.json +3 -3
- package/plugins/design/templates/_shell.html +95 -7
- package/plugins/flow/dependencies.json +3 -3
|
@@ -32,7 +32,9 @@ import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
|
|
|
32
32
|
// same "pull only pure logic into the client bundle" shape as the imports
|
|
33
33
|
// above (a type-only SyncStatusSnapshot import that Bun erases).
|
|
34
34
|
import { syncPresentation } from '../sync/presentation.ts';
|
|
35
|
-
import { canvasUrl } from './canvas-url.js';
|
|
35
|
+
import { canvasTokenRefreshDelay, canvasUrl, setLiveCanvasToken } from './canvas-url.js';
|
|
36
|
+
import { applyEditRequest } from './apply-edit-request.ts';
|
|
37
|
+
import { createIndexLoader } from './index-loader.ts';
|
|
36
38
|
import {
|
|
37
39
|
BROWSER_CAPTURE_FORMATS,
|
|
38
40
|
BROWSER_SERVABLE_FORMATS,
|
|
@@ -50,6 +52,8 @@ import GitPanel from './panels/GitPanel.jsx';
|
|
|
50
52
|
import SyncConsentDialog from './panels/SyncConsentDialog.jsx';
|
|
51
53
|
import SyncPanel from './panels/SyncPanel.jsx';
|
|
52
54
|
import CloudBar from './panels/CloudBar.jsx';
|
|
55
|
+
import { buildShareLinks, normalizeOpenPath, readOpenParam, withOpenParam } from './share-link.js';
|
|
56
|
+
import { ShareDialog, copyShareLink } from './share-dialog.jsx';
|
|
53
57
|
import IdentityBar from './panels/IdentityBar.jsx';
|
|
54
58
|
import OnboardingWizard from './panels/OnboardingWizard.jsx';
|
|
55
59
|
import { ReadinessDialog } from './panels/ReadinessList.jsx';
|
|
@@ -119,6 +123,8 @@ import { COLLAB_TOUR } from './tour/collab-tour.js';
|
|
|
119
123
|
import { TourOverlay } from './tour/overlay.jsx';
|
|
120
124
|
import { QUICK_SETUP_TOUR } from './tour/quick-setup-tour.js';
|
|
121
125
|
import { USAGE_TOUR } from './tour/usage-tour.js';
|
|
126
|
+
import { dismissNotice, NotificationHost, notify, notifyCanvasText } from '../notifications.tsx';
|
|
127
|
+
import { acceptCanvasNotice } from '../canvas-notice-message.ts';
|
|
122
128
|
import { ExportBadge, ExportPanel, ExportToast, useExportCenter } from './export-center.jsx';
|
|
123
129
|
import { ReportBugDialog } from './report-bug.jsx';
|
|
124
130
|
import { useWhatsNew, WhatsNewPanel, WhatsNewToast } from './whats-new.jsx';
|
|
@@ -202,6 +208,7 @@ function DockSlot({ side, width, open, ids, activeId, onPick, children, labels =
|
|
|
202
208
|
key={id}
|
|
203
209
|
type="button"
|
|
204
210
|
role="tab"
|
|
211
|
+
data-testid={`dock-tab-${id}`}
|
|
205
212
|
aria-selected={activeId === id}
|
|
206
213
|
className={'st-docktab' + (activeId === id ? ' is-active' : '')}
|
|
207
214
|
onClick={() => onPick(id)}
|
|
@@ -245,6 +252,18 @@ function pathTestIdSlug(p) {
|
|
|
245
252
|
.toLowerCase()
|
|
246
253
|
.replace(/^-+|-+$/g, '');
|
|
247
254
|
}
|
|
255
|
+
// A layers tree's identity: its element ids in document order. Two trees with
|
|
256
|
+
// the same signature are the same DOM as far as positional ids go.
|
|
257
|
+
function layersTreeSig(nodes) {
|
|
258
|
+
const ids = [];
|
|
259
|
+
(function walk(list) {
|
|
260
|
+
for (const n of list || []) {
|
|
261
|
+
ids.push(n?.id ?? '');
|
|
262
|
+
walk(n?.children);
|
|
263
|
+
}
|
|
264
|
+
})(nodes);
|
|
265
|
+
return ids.join(',');
|
|
266
|
+
}
|
|
248
267
|
// Bun's `define` substitutes this at build time (see build.ts); falls back when
|
|
249
268
|
// the bundle is consumed in a context that hasn't run the build.
|
|
250
269
|
const MDCC_VERSION = typeof __MDCC_VERSION__ !== 'undefined' ? __MDCC_VERSION__ : 'dev';
|
|
@@ -331,58 +350,9 @@ function basename(p) {
|
|
|
331
350
|
return p.split('/').pop();
|
|
332
351
|
}
|
|
333
352
|
|
|
334
|
-
//
|
|
335
|
-
// The clip ops used to console.warn their failures (a 422 "series clip can't
|
|
336
|
-
// move" looked like "the button does nothing"). Self-contained DOM (no React
|
|
337
|
-
// state), mirrors the canvas-side showCanvasToast.
|
|
338
|
-
// `action` (feature-file-tree-drag-drop-folders, Task 10) — optional
|
|
339
|
-
// `{ label, onClick }` for an inline undo affordance (the move toast). Absent
|
|
340
|
-
// for every other caller, so their toast stays the plain auto-dismissing text
|
|
341
|
-
// bubble this always was.
|
|
353
|
+
// Keep call sites (including Undo actions) on the shared notification stack.
|
|
342
354
|
function shellToast(message, ok = false, action) {
|
|
343
|
-
|
|
344
|
-
let el = document.getElementById('st-op-toast');
|
|
345
|
-
if (!el) {
|
|
346
|
-
el = document.createElement('div');
|
|
347
|
-
el.id = 'st-op-toast';
|
|
348
|
-
el.setAttribute('role', 'status');
|
|
349
|
-
el.style.cssText =
|
|
350
|
-
'position:fixed;left:50%;bottom:64px;transform:translateX(-50%);z-index:80;' +
|
|
351
|
-
'display:flex;align-items:center;gap:10px;max-width:440px;padding:8px 14px;' +
|
|
352
|
-
'border-radius:8px;font:12px/1.45 var(--font-mono,monospace);' +
|
|
353
|
-
'box-shadow:0 8px 28px rgba(0,0,0,.34);pointer-events:none;opacity:0;transition:opacity 140ms ease;';
|
|
354
|
-
document.body.appendChild(el);
|
|
355
|
-
}
|
|
356
|
-
el.style.background = ok ? '#1d3524' : '#3a1d1d';
|
|
357
|
-
el.style.color = ok ? '#b7e4c0' : '#f1b8b8';
|
|
358
|
-
el.textContent = '';
|
|
359
|
-
const text = document.createElement('span');
|
|
360
|
-
text.textContent = message;
|
|
361
|
-
el.appendChild(text);
|
|
362
|
-
if (action) {
|
|
363
|
-
const btn = document.createElement('button');
|
|
364
|
-
btn.type = 'button';
|
|
365
|
-
btn.textContent = action.label;
|
|
366
|
-
btn.style.cssText =
|
|
367
|
-
'pointer-events:auto;background:none;border:0;text-decoration:underline;' +
|
|
368
|
-
'color:inherit;font:inherit;cursor:pointer;padding:0;';
|
|
369
|
-
btn.onclick = () => {
|
|
370
|
-
action.onClick();
|
|
371
|
-
el.style.opacity = '0';
|
|
372
|
-
el.style.pointerEvents = 'none';
|
|
373
|
-
};
|
|
374
|
-
el.appendChild(btn);
|
|
375
|
-
}
|
|
376
|
-
el.style.opacity = '1';
|
|
377
|
-
el.style.pointerEvents = action ? 'auto' : 'none';
|
|
378
|
-
clearTimeout(shellToast._t);
|
|
379
|
-
shellToast._t = setTimeout(
|
|
380
|
-
() => {
|
|
381
|
-
el.style.opacity = '0';
|
|
382
|
-
el.style.pointerEvents = 'none';
|
|
383
|
-
},
|
|
384
|
-
action ? 6000 : 3200
|
|
385
|
-
);
|
|
355
|
+
notify({ title: message, kind: ok ? 'success' : 'error', action });
|
|
386
356
|
}
|
|
387
357
|
|
|
388
358
|
// DDR-223 (issue #93, supersedes DDR-187's boot half) — one-time first-run
|
|
@@ -402,48 +372,24 @@ function browseFirstRunHint(readOnly = false) {
|
|
|
402
372
|
} catch {
|
|
403
373
|
return;
|
|
404
374
|
}
|
|
405
|
-
if (
|
|
406
|
-
|
|
407
|
-
el.id = 'st-browse-hint';
|
|
408
|
-
el.setAttribute('role', 'status');
|
|
409
|
-
el.style.cssText =
|
|
410
|
-
'position:fixed;left:50%;bottom:64px;transform:translateX(-50%);z-index:81;' +
|
|
411
|
-
'display:flex;align-items:center;gap:10px;max-width:520px;padding:10px 14px;border-radius:10px;' +
|
|
412
|
-
'font:12px/1.5 var(--font-ui,system-ui,sans-serif);background:var(--surface-2,#1b1e24);' +
|
|
413
|
-
'color:var(--text-1,#e7eaf0);border:1px solid var(--border-1,#333a45);' +
|
|
414
|
-
'box-shadow:0 10px 34px rgba(0,0,0,.42);opacity:0;transition:opacity 160ms ease;';
|
|
415
|
-
const kbd =
|
|
416
|
-
'<kbd style="padding:1px 6px;border-radius:5px;border:1px solid var(--border-1,#333a45);' +
|
|
417
|
-
'background:var(--surface-3,#262b33);font-family:var(--font-mono,monospace)">V</kbd>';
|
|
418
|
-
el.innerHTML =
|
|
419
|
-
// Cloud Phase 25 C2 — a viewer selects to inspect, never to edit; the
|
|
420
|
-
// hint must not promise an editor the role doesn't have.
|
|
421
|
-
(readOnly
|
|
422
|
-
? `<span>Your mock is <strong>live</strong> — click things to try it. Press ${kbd} to select & inspect.</span>`
|
|
423
|
-
: '<span>You’re in <strong>Edit</strong> — click selects, like Figma. ' +
|
|
424
|
-
'Switch to <strong>Preview</strong> in the toolbar to use the live mock.</span>') +
|
|
425
|
-
'<button type="button" aria-label="Dismiss" style="background:none;border:none;color:inherit;' +
|
|
426
|
-
'cursor:pointer;font-size:15px;line-height:1;opacity:.65;padding:2px">×</button>';
|
|
375
|
+
if (browseFirstRunHint.active) return;
|
|
376
|
+
browseFirstRunHint.active = true;
|
|
427
377
|
const dismiss = () => {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
} catch {
|
|
431
|
-
/* private mode */
|
|
432
|
-
}
|
|
433
|
-
el.style.opacity = '0';
|
|
434
|
-
setTimeout(() => el.remove(), 200);
|
|
378
|
+
browseFirstRunHint.active = false;
|
|
379
|
+
try { localStorage.setItem(MODE_HINT_SEEN, '1'); } catch {}
|
|
435
380
|
document.removeEventListener('keydown', onV, true);
|
|
436
381
|
};
|
|
437
382
|
const onV = (e) => {
|
|
438
|
-
if ((e.key === 'v' || e.key === 'V') && !e.metaKey && !e.ctrlKey && !e.altKey)
|
|
383
|
+
if ((e.key === 'v' || e.key === 'V') && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
384
|
+
dismissNotice('mode-hint');
|
|
385
|
+
dismiss();
|
|
386
|
+
}
|
|
439
387
|
};
|
|
440
|
-
el.querySelector('button')?.addEventListener('click', dismiss);
|
|
441
388
|
if (readOnly) document.addEventListener('keydown', onV, true);
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
setTimeout(dismiss, 9000);
|
|
389
|
+
notify({ id: 'mode-hint', title: readOnly ? 'Your mock is live' : 'You’re in Edit',
|
|
390
|
+
description: readOnly ? 'Click things to try it. Press V to select & inspect.'
|
|
391
|
+
: 'Click selects, like Figma. Switch to Preview in the toolbar to use the live mock.',
|
|
392
|
+
onDismiss: dismiss });
|
|
447
393
|
}
|
|
448
394
|
|
|
449
395
|
// Strip canvas extensions for display. `Canvas Viewport.tsx` → `Canvas Viewport`.
|
|
@@ -2368,6 +2314,10 @@ function FileRow({
|
|
|
2368
2314
|
// feature-file-tree-drag-drop-folders (Task 9) — the KEYBOARD path for
|
|
2369
2315
|
// "Move to…" (drag-only fails WCAG 2.1.1). Same eligibility as dragging.
|
|
2370
2316
|
const canMove = draggableRow && typeof menu !== 'undefined';
|
|
2317
|
+
// Plan T17/L03 — a supporting file (notes, styles, images, media) in a
|
|
2318
|
+
// canvas folder gets the same ⋯ menu: rename, move, delete.
|
|
2319
|
+
const supporting = !isCanvas && !!pKind && !sidecar && typeof menu !== 'undefined';
|
|
2320
|
+
const canShare = typeof menu !== 'undefined' && kind !== 'runtime';
|
|
2371
2321
|
const fileDir = file.path.split('/').slice(0, -1).join('/');
|
|
2372
2322
|
// Stable hook for the desktop E2E harness (data-testid convention — see the
|
|
2373
2323
|
// `desktop-e2e` skill): canvas rows only, slug derived from the relative path
|
|
@@ -2385,7 +2335,7 @@ function FileRow({
|
|
|
2385
2335
|
<button
|
|
2386
2336
|
type="button"
|
|
2387
2337
|
role="treeitem"
|
|
2388
|
-
data-testid={testId}
|
|
2338
|
+
data-testid={testId ?? (supporting ? `file-row-${pathTestIdSlug(file.path)}` : undefined)}
|
|
2389
2339
|
aria-selected={isSel}
|
|
2390
2340
|
aria-disabled={inert ? 'true' : undefined}
|
|
2391
2341
|
aria-busy={isBusy || undefined}
|
|
@@ -2404,7 +2354,17 @@ function FileRow({
|
|
|
2404
2354
|
else if (pKind) onPreview?.(file.path);
|
|
2405
2355
|
}}
|
|
2406
2356
|
onContextMenu={
|
|
2407
|
-
|
|
2357
|
+
canShare
|
|
2358
|
+
? (e) =>
|
|
2359
|
+
menu.openAt(e, {
|
|
2360
|
+
kind: 'file',
|
|
2361
|
+
path: file.path,
|
|
2362
|
+
dir: fileDir,
|
|
2363
|
+
canMove: canMove || supporting,
|
|
2364
|
+
supporting,
|
|
2365
|
+
name: file.name,
|
|
2366
|
+
})
|
|
2367
|
+
: undefined
|
|
2408
2368
|
}
|
|
2409
2369
|
{...dragHandlers}
|
|
2410
2370
|
>
|
|
@@ -2438,21 +2398,30 @@ function FileRow({
|
|
|
2438
2398
|
{oc > 0 && <span className="st-row-badge">{oc}</span>}
|
|
2439
2399
|
</button>
|
|
2440
2400
|
);
|
|
2441
|
-
if (!canDelete && !
|
|
2401
|
+
if (!canDelete && !canShare) return row;
|
|
2442
2402
|
// Sibling menu/delete buttons (can't nest a button in the row button). The
|
|
2443
2403
|
// wrapper is presentational so the treeitem stays the tree's child for a11y.
|
|
2444
2404
|
return (
|
|
2445
2405
|
<div className="st-row-wrap" role="none">
|
|
2446
2406
|
{row}
|
|
2447
|
-
{
|
|
2407
|
+
{canShare && (
|
|
2448
2408
|
<button
|
|
2449
2409
|
type="button"
|
|
2450
2410
|
className={'st-row-menu-btn' + (canDelete ? ' has-delete-sibling' : '')}
|
|
2451
2411
|
data-testid={`tree-row-menu-${pathTestIdSlug(file.path)}`}
|
|
2452
|
-
title={`
|
|
2412
|
+
title={`Actions for ${label}`}
|
|
2453
2413
|
aria-label={`Actions for ${label}`}
|
|
2454
2414
|
aria-haspopup="menu"
|
|
2455
|
-
onClick={(e) =>
|
|
2415
|
+
onClick={(e) =>
|
|
2416
|
+
menu.openAt(e, {
|
|
2417
|
+
kind: 'file',
|
|
2418
|
+
path: file.path,
|
|
2419
|
+
dir: fileDir,
|
|
2420
|
+
canMove: canMove || supporting,
|
|
2421
|
+
supporting,
|
|
2422
|
+
name: file.name,
|
|
2423
|
+
})
|
|
2424
|
+
}
|
|
2456
2425
|
>
|
|
2457
2426
|
<Icon d="M12 6a1 1 0 100-2 1 1 0 000 2zM12 13a1 1 0 100-2 1 1 0 000 2zM12 20a1 1 0 100-2 1 1 0 000 2z" size={12} />
|
|
2458
2427
|
</button>
|
|
@@ -2526,6 +2495,7 @@ function CanvasRow({
|
|
|
2526
2495
|
const isDragging = drag?.draggedPath === primary.path;
|
|
2527
2496
|
const isBusy = drag?.busyPath === primary.path;
|
|
2528
2497
|
const canMove = draggableRow && typeof menu !== 'undefined';
|
|
2498
|
+
const canShare = typeof menu !== 'undefined' && kind !== 'runtime';
|
|
2529
2499
|
const primaryDir = primary.path.split('/').slice(0, -1).join('/');
|
|
2530
2500
|
const row = (
|
|
2531
2501
|
<button
|
|
@@ -2552,7 +2522,7 @@ function CanvasRow({
|
|
|
2552
2522
|
onOpen(primary.path);
|
|
2553
2523
|
}}
|
|
2554
2524
|
onContextMenu={
|
|
2555
|
-
|
|
2525
|
+
canShare ? (e) => menu.openAt(e, { kind: 'file', path: primary.path, dir: primaryDir, canMove }) : undefined
|
|
2556
2526
|
}
|
|
2557
2527
|
{...dragHandlers}
|
|
2558
2528
|
>
|
|
@@ -2594,17 +2564,17 @@ function CanvasRow({
|
|
|
2594
2564
|
);
|
|
2595
2565
|
return (
|
|
2596
2566
|
<Fragment>
|
|
2597
|
-
{
|
|
2567
|
+
{canShare ? (
|
|
2598
2568
|
<div className="st-row-wrap" role="none">
|
|
2599
2569
|
{row}
|
|
2600
2570
|
<button
|
|
2601
2571
|
type="button"
|
|
2602
2572
|
className="st-row-menu-btn"
|
|
2603
2573
|
data-testid={`tree-row-menu-${pathTestIdSlug(primary.path)}`}
|
|
2604
|
-
title={`
|
|
2574
|
+
title={`Actions for ${displayName(primary.name)}`}
|
|
2605
2575
|
aria-label={`Actions for ${displayName(primary.name)}`}
|
|
2606
2576
|
aria-haspopup="menu"
|
|
2607
|
-
onClick={(e) => menu.openAt(e, { kind: 'file', path: primary.path, dir: primaryDir })}
|
|
2577
|
+
onClick={(e) => menu.openAt(e, { kind: 'file', path: primary.path, dir: primaryDir, canMove })}
|
|
2608
2578
|
>
|
|
2609
2579
|
<Icon d="M12 6a1 1 0 100-2 1 1 0 000 2zM12 13a1 1 0 100-2 1 1 0 000 2zM12 20a1 1 0 100-2 1 1 0 000 2z" size={12} />
|
|
2610
2580
|
</button>
|
|
@@ -2617,6 +2587,7 @@ function CanvasRow({
|
|
|
2617
2587
|
<FileRow
|
|
2618
2588
|
key={sc.path}
|
|
2619
2589
|
file={sc}
|
|
2590
|
+
menu={menu}
|
|
2620
2591
|
activePath={activePath}
|
|
2621
2592
|
previewPath={previewPath}
|
|
2622
2593
|
onOpen={onOpen}
|
|
@@ -2723,6 +2694,7 @@ function Tree({
|
|
|
2723
2694
|
<FileRow
|
|
2724
2695
|
key={entry.primary.path}
|
|
2725
2696
|
file={entry.primary}
|
|
2697
|
+
menu={menu}
|
|
2726
2698
|
activePath={activePath}
|
|
2727
2699
|
previewPath={previewPath}
|
|
2728
2700
|
onOpen={onOpen}
|
|
@@ -2737,6 +2709,7 @@ function Tree({
|
|
|
2737
2709
|
<FileRow
|
|
2738
2710
|
key={entry.primary.path}
|
|
2739
2711
|
file={entry.primary}
|
|
2712
|
+
menu={menu}
|
|
2740
2713
|
activePath={activePath}
|
|
2741
2714
|
previewPath={previewPath}
|
|
2742
2715
|
onOpen={onOpen}
|
|
@@ -2795,7 +2768,7 @@ function Tree({
|
|
|
2795
2768
|
defaultOpen={true}
|
|
2796
2769
|
dirPath={childPath}
|
|
2797
2770
|
drag={drag}
|
|
2798
|
-
menu={menu}
|
|
2771
|
+
menu={drag ? menu : undefined}
|
|
2799
2772
|
>
|
|
2800
2773
|
{childTree}
|
|
2801
2774
|
</DirRow>
|
|
@@ -2868,12 +2841,20 @@ function Sidebar({
|
|
|
2868
2841
|
onMoveCanvas,
|
|
2869
2842
|
onNewFolder,
|
|
2870
2843
|
onDeleteFolder,
|
|
2844
|
+
onRenameFolder,
|
|
2845
|
+
onRenameCanvas,
|
|
2846
|
+
onDuplicateCanvas,
|
|
2847
|
+
onDeleteFile,
|
|
2871
2848
|
// Passed through to CloudBar only — the live `sync:status` payload that makes
|
|
2872
2849
|
// the connect note follow the link instead of freezing at attach time.
|
|
2873
2850
|
syncStatus,
|
|
2874
2851
|
// Passed through to CloudBar only — lifts linkedHub changes to the app shell
|
|
2875
2852
|
// so the GitPanel's cloud-managed posture (DDR-218) reacts live.
|
|
2876
2853
|
onLinkedHub,
|
|
2854
|
+
onLocalProject,
|
|
2855
|
+
onOpenLinkedFile,
|
|
2856
|
+
filesReady,
|
|
2857
|
+
onShare,
|
|
2877
2858
|
// feature-cloud-managed-git-posture — the widened DDR-218 gate, resolved once
|
|
2878
2859
|
// in App and handed down. Withdraws the drafts switcher: a local branch
|
|
2879
2860
|
// switch moves a HEAD the cell knows nothing about.
|
|
@@ -2909,7 +2890,67 @@ function Sidebar({
|
|
|
2909
2890
|
const menuExtra = rowMenu.state?.extra;
|
|
2910
2891
|
const rowMenuRootItems =
|
|
2911
2892
|
menuExtra?.kind === 'file'
|
|
2912
|
-
? [
|
|
2893
|
+
? [
|
|
2894
|
+
{ id: 'share', label: 'Share…', onSelect: () => { rowMenu.close(); onShare(menuExtra.path); } },
|
|
2895
|
+
...(onRenameCanvas && /\.tsx$/i.test(menuExtra.path)
|
|
2896
|
+
? [
|
|
2897
|
+
{
|
|
2898
|
+
id: 'rename-canvas',
|
|
2899
|
+
label: 'Rename…',
|
|
2900
|
+
onSelect: () => {
|
|
2901
|
+
rowMenu.close();
|
|
2902
|
+
const current = displayName(menuExtra.path.split('/').pop());
|
|
2903
|
+
const name = window.prompt('Rename canvas to:', current);
|
|
2904
|
+
if (name?.trim() && name.trim() !== current) onRenameCanvas(menuExtra.path, name.trim());
|
|
2905
|
+
},
|
|
2906
|
+
},
|
|
2907
|
+
]
|
|
2908
|
+
: []),
|
|
2909
|
+
...(onDuplicateCanvas && /\.tsx$/i.test(menuExtra.path)
|
|
2910
|
+
? [
|
|
2911
|
+
{
|
|
2912
|
+
id: 'duplicate-canvas',
|
|
2913
|
+
label: 'Duplicate',
|
|
2914
|
+
onSelect: () => {
|
|
2915
|
+
rowMenu.close();
|
|
2916
|
+
onDuplicateCanvas(menuExtra.path);
|
|
2917
|
+
},
|
|
2918
|
+
},
|
|
2919
|
+
]
|
|
2920
|
+
: []),
|
|
2921
|
+
// Plan T17/L03 — a supporting file renames as itself (the extension
|
|
2922
|
+
// stays: a rename is not a conversion).
|
|
2923
|
+
...(menuExtra.supporting && onRenameCanvas
|
|
2924
|
+
? [
|
|
2925
|
+
{
|
|
2926
|
+
id: 'rename-file',
|
|
2927
|
+
label: 'Rename…',
|
|
2928
|
+
onSelect: () => {
|
|
2929
|
+
rowMenu.close();
|
|
2930
|
+
const n = menuExtra.name ?? menuExtra.path.split('/').pop();
|
|
2931
|
+
const ext = n.includes('.') ? n.slice(n.lastIndexOf('.')) : '';
|
|
2932
|
+
const current = ext ? n.slice(0, -ext.length) : n;
|
|
2933
|
+
const name = window.prompt(`Rename ${n} to:`, current);
|
|
2934
|
+
if (name?.trim() && name.trim() !== current) onRenameCanvas(menuExtra.path, name.trim());
|
|
2935
|
+
},
|
|
2936
|
+
},
|
|
2937
|
+
]
|
|
2938
|
+
: []),
|
|
2939
|
+
...(menuExtra.canMove ? [{ id: 'move-to', label: 'Move to…', onSelect: () => rowMenu.showMoveTo() }] : []),
|
|
2940
|
+
...(menuExtra.supporting && onDeleteFile
|
|
2941
|
+
? [
|
|
2942
|
+
{
|
|
2943
|
+
id: 'delete-file',
|
|
2944
|
+
label: 'Delete',
|
|
2945
|
+
destructive: true,
|
|
2946
|
+
onSelect: () => {
|
|
2947
|
+
rowMenu.close();
|
|
2948
|
+
onDeleteFile(menuExtra.path, menuExtra.name ?? menuExtra.path.split('/').pop());
|
|
2949
|
+
},
|
|
2950
|
+
},
|
|
2951
|
+
]
|
|
2952
|
+
: []),
|
|
2953
|
+
]
|
|
2913
2954
|
: menuExtra?.kind === 'dir'
|
|
2914
2955
|
? [
|
|
2915
2956
|
{
|
|
@@ -2921,6 +2962,16 @@ function Sidebar({
|
|
|
2921
2962
|
if (name?.trim()) onNewFolder(menuExtra.dirPath, name.trim());
|
|
2922
2963
|
},
|
|
2923
2964
|
},
|
|
2965
|
+
{
|
|
2966
|
+
id: 'rename-folder',
|
|
2967
|
+
label: 'Rename folder',
|
|
2968
|
+
onSelect: () => {
|
|
2969
|
+
rowMenu.close();
|
|
2970
|
+
const current = menuExtra.dirPath.split('/').pop();
|
|
2971
|
+
const name = window.prompt('Rename folder to:', current);
|
|
2972
|
+
if (name?.trim() && name.trim() !== current) onRenameFolder(menuExtra.dirPath, name.trim());
|
|
2973
|
+
},
|
|
2974
|
+
},
|
|
2924
2975
|
{
|
|
2925
2976
|
id: 'delete-folder',
|
|
2926
2977
|
label: 'Delete folder',
|
|
@@ -2933,7 +2984,7 @@ function Sidebar({
|
|
|
2933
2984
|
]
|
|
2934
2985
|
: [];
|
|
2935
2986
|
const rowMenuDestinations =
|
|
2936
|
-
menuExtra?.kind === 'file'
|
|
2987
|
+
menuExtra?.kind === 'file' || menuExtra?.kind === 'supporting'
|
|
2937
2988
|
? destinations.filter((d) => d.path !== menuExtra.dir)
|
|
2938
2989
|
: [];
|
|
2939
2990
|
|
|
@@ -3225,7 +3276,7 @@ function Sidebar({
|
|
|
3225
3276
|
canvasKinds={canvasKinds}
|
|
3226
3277
|
dirPath={g.fullPath}
|
|
3227
3278
|
drag={!isDs && g.kind === 'canvas' && !readOnly ? treeDrag : undefined}
|
|
3228
|
-
menu={
|
|
3279
|
+
menu={rowMenu}
|
|
3229
3280
|
/>
|
|
3230
3281
|
) : (
|
|
3231
3282
|
<div className="st-tree-empty">{search ? 'No matches.' : 'Empty.'}</div>
|
|
@@ -3253,9 +3304,9 @@ function Sidebar({
|
|
|
3253
3304
|
and `/_api/git/checkout` keep exactly their old gates, and a terminal
|
|
3254
3305
|
`git checkout` still works (and still flushes into Yjs via DDR-051's
|
|
3255
3306
|
watcher). Absent, not disabled — there is nothing to explain here. */}
|
|
3256
|
-
{
|
|
3257
|
-
|
|
3258
|
-
|
|
3307
|
+
{/* Plan T22 — withdrawn means the BRANCH half. A managed project still
|
|
3308
|
+
needs the way to another project, so the dock stays, project-only. */}
|
|
3309
|
+
<RepoBranchSwitcher project={project} liveBranch={gitBranch} remoteSync={remoteSync} onGetLatest={onGetLatest} projectOnly={savingIsManaged} />
|
|
3259
3310
|
{/* Cloud Phase 23 C3 — Maude Cloud sign-in + remote-project attach, docked
|
|
3260
3311
|
above the GitHub identity. Dev-server-backed, so it works in the desktop
|
|
3261
3312
|
shell AND a plain browser. */}
|
|
@@ -3264,7 +3315,7 @@ function Sidebar({
|
|
|
3264
3315
|
Absent rather than disabled, because unlike the agent chat there is
|
|
3265
3316
|
nothing here to explain — the capability is not missing, it is
|
|
3266
3317
|
already satisfied. */}
|
|
3267
|
-
{cloud === null ? <CloudBar syncStatus={syncStatus} onLinkedHub={onLinkedHub} /> : null}
|
|
3318
|
+
{cloud === null ? <CloudBar syncStatus={syncStatus} onLinkedHub={onLinkedHub} onLocalProject={onLocalProject} onOpenFile={onOpenLinkedFile} filesReady={filesReady} /> : null}
|
|
3268
3319
|
{/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
|
|
3269
3320
|
sign in, connected account + New/Pull/Share, sign out. Self-contained
|
|
3270
3321
|
(owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
|
|
@@ -3973,12 +4024,13 @@ function ToolsDropdown({ onAction, onClose, readOnly = false }) {
|
|
|
3973
4024
|
|
|
3974
4025
|
// Plan C follow-up — File + Edit menus, previously inert. Both dispatch to real
|
|
3975
4026
|
// shell flows (File) or the in-canvas undo stack / selection bridges (Edit).
|
|
3976
|
-
function FileDropdown({ onAction, onClose, hasCanvas, readOnly = false }) {
|
|
4027
|
+
function FileDropdown({ onAction, onClose, hasCanvas, hasSharePath, readOnly = false }) {
|
|
3977
4028
|
// Cloud Phase 25 C2 — a viewer keeps the reads (export, handoff, reload,
|
|
3978
4029
|
// close); create / assemble / generate / settings are absent.
|
|
3979
4030
|
const items = readOnly
|
|
3980
4031
|
? [
|
|
3981
4032
|
{ id: 'export', label: 'Export…', shortcut: '⇧⌘E' },
|
|
4033
|
+
{ id: 'share', label: 'Share link…', disabled: !hasSharePath },
|
|
3982
4034
|
{ id: 'handoff', label: 'Handoff to production', shortcut: '⇧⌘H' },
|
|
3983
4035
|
{ sep: true },
|
|
3984
4036
|
{ id: 'reload', label: 'Reload canvas', shortcut: '⌘R', disabled: !hasCanvas },
|
|
@@ -3991,6 +4043,7 @@ function FileDropdown({ onAction, onClose, hasCanvas, readOnly = false }) {
|
|
|
3991
4043
|
// dropped as reference chips on the active canvas.
|
|
3992
4044
|
{ id: 'assemble', label: 'Assemble dropped clips → video', disabled: !hasCanvas },
|
|
3993
4045
|
{ id: 'export', label: 'Export…', shortcut: '⇧⌘E' },
|
|
4046
|
+
{ id: 'share', label: 'Share link…', disabled: !hasSharePath },
|
|
3994
4047
|
{ id: 'handoff', label: 'Handoff to production', shortcut: '⇧⌘H' },
|
|
3995
4048
|
{ sep: true },
|
|
3996
4049
|
// feature-ai-media-generation (DDR-16x) — BYOK generate action + settings.
|
|
@@ -4052,6 +4105,8 @@ function EditDropdown({ onAction, onClose, hasCanvas, readOnly = false }) {
|
|
|
4052
4105
|
|
|
4053
4106
|
function Menubar({
|
|
4054
4107
|
activePath,
|
|
4108
|
+
sharePath,
|
|
4109
|
+
onShare,
|
|
4055
4110
|
project,
|
|
4056
4111
|
/** `{ dashboardUrl?, projectName }` when this is a cloud tab, else null. */
|
|
4057
4112
|
// Tri-state (see the note where this value is created): `undefined` until
|
|
@@ -4390,6 +4445,7 @@ function Menubar({
|
|
|
4390
4445
|
type="button"
|
|
4391
4446
|
className="st-menu"
|
|
4392
4447
|
role="menuitem"
|
|
4448
|
+
data-testid={`menu-${key}`}
|
|
4393
4449
|
data-tour={key === 'help' ? 'help' : undefined}
|
|
4394
4450
|
aria-haspopup={hasDropdown ? 'menu' : undefined}
|
|
4395
4451
|
aria-expanded={hasDropdown ? open : undefined}
|
|
@@ -4409,10 +4465,12 @@ function Menubar({
|
|
|
4409
4465
|
<FileDropdown
|
|
4410
4466
|
readOnly={readOnly}
|
|
4411
4467
|
hasCanvas={!!activePath}
|
|
4468
|
+
hasSharePath={!!sharePath}
|
|
4412
4469
|
onAction={(id) => {
|
|
4413
4470
|
if (id === 'new') onNewCanvas?.();
|
|
4414
4471
|
else if (id === 'assemble') onAssembleVideo?.();
|
|
4415
4472
|
else if (id === 'export') onOpenExport?.('export');
|
|
4473
|
+
else if (id === 'share') { document.querySelector('[data-testid="menu-file"]')?.focus(); onShare?.(); }
|
|
4416
4474
|
else if (id === 'handoff') onOpenExport?.('handoff');
|
|
4417
4475
|
else if (id === 'generate') onOpenGenerate?.();
|
|
4418
4476
|
else if (id === 'settings') onOpenSettings?.();
|
|
@@ -4556,6 +4614,7 @@ function Menubar({
|
|
|
4556
4614
|
<StIcon name="sparkle" size={15} />
|
|
4557
4615
|
</button>
|
|
4558
4616
|
)}
|
|
4617
|
+
<button type="button" className="st-mb-icon st-share-btn" data-testid="share-btn" data-tip={sharePath ? 'Share link' : 'Open a canvas to share it'} aria-label="Share link" disabled={!sharePath} onClick={onShare}><StIcon name="share" size={15} /></button>
|
|
4559
4618
|
{exportCenter && <ExportBadge center={exportCenter} />}
|
|
4560
4619
|
<button
|
|
4561
4620
|
type="button"
|
|
@@ -4625,6 +4684,28 @@ function Viewport({
|
|
|
4625
4684
|
useEffect(() => {
|
|
4626
4685
|
if (previewPath) previewRef.current?.focus();
|
|
4627
4686
|
}, [previewPath]);
|
|
4687
|
+
// An open canvas keeps the URL it was opened with. The capability in it is
|
|
4688
|
+
// re-minted on a timer (canvas-url.js); letting that change the `src` of a
|
|
4689
|
+
// live iframe would reload every open canvas every few minutes. The fresh
|
|
4690
|
+
// capability reaches an open canvas by message instead, and a frame that is
|
|
4691
|
+
// (re)created — a new tab, a Retry, a config change — is built with it.
|
|
4692
|
+
// Only while the frame is open: a closed tab reopened later is a new frame
|
|
4693
|
+
// and must not inherit a capability that may have expired meanwhile.
|
|
4694
|
+
const srcCache = useRef(new Map());
|
|
4695
|
+
const openPaths = new Set(tabs.map((t) => t.path));
|
|
4696
|
+
for (const [key, entry] of srcCache.current) {
|
|
4697
|
+
if (!openPaths.has(entry.path)) srcCache.current.delete(key);
|
|
4698
|
+
}
|
|
4699
|
+
const stableSrc = (path) => {
|
|
4700
|
+
const bare = canvasUrl(path, { ...cfg, canvasToken: undefined });
|
|
4701
|
+
const key = `${path}#${canvasReloadNonce}|${bare}`;
|
|
4702
|
+
let entry = srcCache.current.get(key);
|
|
4703
|
+
if (!entry) {
|
|
4704
|
+
entry = { path, src: canvasUrl(path, cfg) };
|
|
4705
|
+
srcCache.current.set(key, entry);
|
|
4706
|
+
}
|
|
4707
|
+
return entry.src;
|
|
4708
|
+
};
|
|
4628
4709
|
// One observable word for "what is the canvas pane actually doing" — the
|
|
4629
4710
|
// top-frame signal the #115 E2E scenario waits on, and the only place these
|
|
4630
4711
|
// three states are named together. `ready` is `dgn:'loaded'`-backed, so it
|
|
@@ -4717,7 +4798,7 @@ function Viewport({
|
|
|
4717
4798
|
// path exactly as before.
|
|
4718
4799
|
key={`${t.path}#${canvasReloadNonce}`}
|
|
4719
4800
|
ref={(el) => registerIframe(t.path, el)}
|
|
4720
|
-
src={
|
|
4801
|
+
src={stableSrc(t.path)}
|
|
4721
4802
|
className={t.path === activePath ? 'active' : ''}
|
|
4722
4803
|
data-path={t.path}
|
|
4723
4804
|
data-testid={t.path === activePath ? 'canvas-frame' : undefined}
|
|
@@ -5294,6 +5375,7 @@ function CommentsPanel({
|
|
|
5294
5375
|
type="button"
|
|
5295
5376
|
className={'st-cm-filter' + (filter === 'all' ? ' is-active' : '')}
|
|
5296
5377
|
role="tab"
|
|
5378
|
+
data-testid="comment-filter-all"
|
|
5297
5379
|
aria-selected={filter === 'all'}
|
|
5298
5380
|
onClick={() => setFilter('all')}
|
|
5299
5381
|
>
|
|
@@ -5303,6 +5385,7 @@ function CommentsPanel({
|
|
|
5303
5385
|
type="button"
|
|
5304
5386
|
className={'st-cm-filter' + (filter === 'open' ? ' is-active' : '')}
|
|
5305
5387
|
role="tab"
|
|
5388
|
+
data-testid="comment-filter-open"
|
|
5306
5389
|
aria-selected={filter === 'open'}
|
|
5307
5390
|
onClick={() => setFilter('open')}
|
|
5308
5391
|
>
|
|
@@ -5312,6 +5395,7 @@ function CommentsPanel({
|
|
|
5312
5395
|
type="button"
|
|
5313
5396
|
className={'st-cm-filter' + (filter === 'resolved' ? ' is-active' : '')}
|
|
5314
5397
|
role="tab"
|
|
5398
|
+
data-testid="comment-filter-resolved"
|
|
5315
5399
|
aria-selected={filter === 'resolved'}
|
|
5316
5400
|
onClick={() => setFilter('resolved')}
|
|
5317
5401
|
>
|
|
@@ -5343,6 +5427,7 @@ function CommentsPanel({
|
|
|
5343
5427
|
{g.comments.map((c) => (
|
|
5344
5428
|
<div
|
|
5345
5429
|
key={c.id}
|
|
5430
|
+
data-testid={`comment-item-${c.id}`}
|
|
5346
5431
|
className={
|
|
5347
5432
|
'st-comment' +
|
|
5348
5433
|
(c.status === 'resolved' ? ' is-resolved' : '') +
|
|
@@ -5620,6 +5705,11 @@ const CSS_JUSTIFY = [
|
|
|
5620
5705
|
'space-evenly',
|
|
5621
5706
|
];
|
|
5622
5707
|
const CSS_WEIGHTS = ['300', '400', '500', '600', '700', '800'];
|
|
5708
|
+
/** What an edit route says the write replaced (`previous`, read under the file
|
|
5709
|
+
* lock) — the value an undo must restore. `fallback` only when the route could
|
|
5710
|
+
* not name it (an expression, or an older server). */
|
|
5711
|
+
const replacedValue = (j, fallback) =>
|
|
5712
|
+
j && Object.hasOwn(j, 'previous') ? j.previous : fallback;
|
|
5623
5713
|
const CSS_FONTS = [
|
|
5624
5714
|
'inherit',
|
|
5625
5715
|
'system-ui',
|
|
@@ -6512,12 +6602,16 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
6512
6602
|
// no longer triggers a reselect that would re-post fresh `authored` values — so
|
|
6513
6603
|
// the panel must reflect its own commits immediately or it shows the stale
|
|
6514
6604
|
// pre-edit value until the user re-selects. Each commit/reset writes here;
|
|
6515
|
-
// `null` marks a removed key. Cleared when a different element is selected
|
|
6605
|
+
// `null` marks a removed key. Cleared when a different element is selected —
|
|
6606
|
+
// and whenever the canvas re-posts this selection's maps. A re-post carries
|
|
6607
|
+
// the source as it is NOW (a teammate's change reloads the canvas); keeping
|
|
6608
|
+
// our older commit over it showed a stale value and recorded it as the next
|
|
6609
|
+
// undo's `before`, so Cmd+Z restored a value nobody had on screen.
|
|
6516
6610
|
const [overlay, setOverlay] = useState({ a: {}, c: {}, t: {} });
|
|
6517
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: clear
|
|
6611
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: clear on a fresh selection payload only.
|
|
6518
6612
|
useEffect(() => {
|
|
6519
6613
|
setOverlay({ a: {}, c: {}, t: {} });
|
|
6520
|
-
}, [el.id]);
|
|
6614
|
+
}, [el.id, el.authored, el.customStyles, el.attrs]);
|
|
6521
6615
|
const mergeOverlay = (base, ov) => {
|
|
6522
6616
|
const out = { ...(base || {}) };
|
|
6523
6617
|
for (const [k, v] of Object.entries(ov)) {
|
|
@@ -6602,10 +6696,27 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
6602
6696
|
...s,
|
|
6603
6697
|
[key]: !res.ok || !j.ok ? `err:${(j && j.error) || `HTTP ${res.status}`}` : 'saved',
|
|
6604
6698
|
}));
|
|
6699
|
+
return res.ok ? j : null;
|
|
6605
6700
|
} catch (err) {
|
|
6606
6701
|
setStatus((s) => ({ ...s, [key]: `err:${err && err.message ? err.message : String(err)}` }));
|
|
6702
|
+
return null;
|
|
6607
6703
|
}
|
|
6608
6704
|
}
|
|
6705
|
+
// Write, THEN record the undo entry from what the write actually replaced
|
|
6706
|
+
// (`previous`, read server-side under the file lock). The panel's own value
|
|
6707
|
+
// can be a teammate's edit old — it is not re-posted when a peer changes the
|
|
6708
|
+
// source — and recording it made Cmd+Z restore a value nobody had on screen.
|
|
6709
|
+
// Serialized so the stack keeps edit order; a failed write records nothing.
|
|
6710
|
+
const writeChainRef = useRef(Promise.resolve());
|
|
6711
|
+
const writeAndRecord = (url, payload, key, op, prop, fallbackBefore, after) => {
|
|
6712
|
+
writeChainRef.current = writeChainRef.current.then(async () => {
|
|
6713
|
+
const j = await post(url, payload, key);
|
|
6714
|
+
if (!j?.ok) return;
|
|
6715
|
+
const before = Object.hasOwn(j, 'previous') ? j.previous : fallbackBefore;
|
|
6716
|
+
if ((before ?? null) === (after ?? null)) return; // nothing changed to undo
|
|
6717
|
+
record(op, prop, before, after);
|
|
6718
|
+
});
|
|
6719
|
+
};
|
|
6609
6720
|
// Optimistic preview: nudge the live element so the change shows before the
|
|
6610
6721
|
// edit → HMR reload lands. `value` null = remove (reset path). No-op when the
|
|
6611
6722
|
// selection has no stable id (can't be resolved in the canvas).
|
|
@@ -6639,8 +6750,15 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
6639
6750
|
if (value === (before ?? '').trim()) return; // no-op
|
|
6640
6751
|
optimistic(property, value);
|
|
6641
6752
|
setA(property, value); // reflect in the panel immediately (no reload → no reselect)
|
|
6642
|
-
|
|
6643
|
-
|
|
6753
|
+
writeAndRecord(
|
|
6754
|
+
'/_api/edit-css',
|
|
6755
|
+
{ canvas: el.file, id: el.id, property, value },
|
|
6756
|
+
property,
|
|
6757
|
+
'css',
|
|
6758
|
+
property,
|
|
6759
|
+
before,
|
|
6760
|
+
value
|
|
6761
|
+
);
|
|
6644
6762
|
};
|
|
6645
6763
|
// A custom CSS property (Advanced) — same write, but the panel surfaces it from
|
|
6646
6764
|
// the customStyles map, so overlay THERE.
|
|
@@ -6651,8 +6769,15 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
6651
6769
|
const before = customStyles[prop] ?? null;
|
|
6652
6770
|
optimistic(prop, value);
|
|
6653
6771
|
setC(prop, value);
|
|
6654
|
-
|
|
6655
|
-
|
|
6772
|
+
writeAndRecord(
|
|
6773
|
+
'/_api/edit-css',
|
|
6774
|
+
{ canvas: el.file, id: el.id, property: prop, value },
|
|
6775
|
+
prop,
|
|
6776
|
+
'css',
|
|
6777
|
+
prop,
|
|
6778
|
+
before,
|
|
6779
|
+
value
|
|
6780
|
+
);
|
|
6656
6781
|
};
|
|
6657
6782
|
const commitAttr = (attr, raw) => {
|
|
6658
6783
|
const a = (attr || '').trim();
|
|
@@ -6660,8 +6785,15 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
6660
6785
|
if (!editable || !a || !value) return;
|
|
6661
6786
|
const before = attrs[a] ?? null;
|
|
6662
6787
|
setT(a, value);
|
|
6663
|
-
|
|
6664
|
-
|
|
6788
|
+
writeAndRecord(
|
|
6789
|
+
'/_api/edit-attr',
|
|
6790
|
+
{ canvas: el.file, id: el.id, attr: a, value },
|
|
6791
|
+
`@${a}`,
|
|
6792
|
+
'attr',
|
|
6793
|
+
a,
|
|
6794
|
+
before,
|
|
6795
|
+
value
|
|
6796
|
+
);
|
|
6665
6797
|
};
|
|
6666
6798
|
// Phase 12.3 — reset (remove the inline prop / attr → back to class/inherited).
|
|
6667
6799
|
const reset = (property) => {
|
|
@@ -6669,23 +6801,44 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
6669
6801
|
const before = authored[property] ?? null;
|
|
6670
6802
|
optimistic(property, null);
|
|
6671
6803
|
setA(property, null);
|
|
6672
|
-
|
|
6673
|
-
|
|
6804
|
+
writeAndRecord(
|
|
6805
|
+
'/_api/edit-css',
|
|
6806
|
+
{ canvas: el.file, id: el.id, property, reset: true },
|
|
6807
|
+
property,
|
|
6808
|
+
'css',
|
|
6809
|
+
property,
|
|
6810
|
+
before,
|
|
6811
|
+
null
|
|
6812
|
+
);
|
|
6674
6813
|
};
|
|
6675
6814
|
const resetCustom = (property) => {
|
|
6676
6815
|
if (!editable) return;
|
|
6677
6816
|
const before = customStyles[property] ?? null;
|
|
6678
6817
|
optimistic(property, null);
|
|
6679
6818
|
setC(property, null);
|
|
6680
|
-
|
|
6681
|
-
|
|
6819
|
+
writeAndRecord(
|
|
6820
|
+
'/_api/edit-css',
|
|
6821
|
+
{ canvas: el.file, id: el.id, property, reset: true },
|
|
6822
|
+
property,
|
|
6823
|
+
'css',
|
|
6824
|
+
property,
|
|
6825
|
+
before,
|
|
6826
|
+
null
|
|
6827
|
+
);
|
|
6682
6828
|
};
|
|
6683
6829
|
const resetAttr = (attr) => {
|
|
6684
6830
|
if (!editable) return;
|
|
6685
6831
|
const before = attrs[attr] ?? null;
|
|
6686
6832
|
setT(attr, null);
|
|
6687
|
-
|
|
6688
|
-
|
|
6833
|
+
writeAndRecord(
|
|
6834
|
+
'/_api/edit-attr',
|
|
6835
|
+
{ canvas: el.file, id: el.id, attr, reset: true },
|
|
6836
|
+
`@${attr}`,
|
|
6837
|
+
'attr',
|
|
6838
|
+
attr,
|
|
6839
|
+
before,
|
|
6840
|
+
null
|
|
6841
|
+
);
|
|
6689
6842
|
};
|
|
6690
6843
|
// Stage M1 — apply a Fixed / Hug / Fill sizing mode to one axis. The pure
|
|
6691
6844
|
// `sizingModePatch` returns the exact writes (context-aware Fill: flex main axis
|
|
@@ -9451,8 +9604,10 @@ function InspectorPanel({
|
|
|
9451
9604
|
};
|
|
9452
9605
|
// An annotation-image has no element tabs; force Photo. Otherwise honor the
|
|
9453
9606
|
// requested tab, but drop off a stale 'photo' tab when the new selection isn't
|
|
9454
|
-
// photo-eligible
|
|
9455
|
-
|
|
9607
|
+
// photo-eligible — onto CSS, the editing tab a fresh selection opens on (Stage
|
|
9608
|
+
// C). Landing on read-only Inspect meant that after touching a sticker, the
|
|
9609
|
+
// next element you clicked showed facts instead of the controls to change it.
|
|
9610
|
+
const effTab = photoOnly ? 'photo' : tab === 'photo' && !photoTarget ? 'css' : tab;
|
|
9456
9611
|
const tabBtn = (id, label, icon) => (
|
|
9457
9612
|
<button
|
|
9458
9613
|
type="button"
|
|
@@ -9714,6 +9869,9 @@ function InspectorPanel({
|
|
|
9714
9869
|
|
|
9715
9870
|
function App() {
|
|
9716
9871
|
const [groups, setGroups] = useState([]);
|
|
9872
|
+
const [treeLoaded, setTreeLoaded] = useState(false);
|
|
9873
|
+
const addressMode = useRef('push');
|
|
9874
|
+
const previousAddressPath = useRef(null);
|
|
9717
9875
|
const [project, setProject] = useState('Design');
|
|
9718
9876
|
const [tabs, setTabs] = useState([]);
|
|
9719
9877
|
const [activePath, setActivePath] = useState(null);
|
|
@@ -9723,6 +9881,7 @@ function App() {
|
|
|
9723
9881
|
// state, or iframe registration that openTab()/openSystem() drive.
|
|
9724
9882
|
const [previewPath, setPreviewPath] = useState(null);
|
|
9725
9883
|
const onPreview = useCallback((path) => {
|
|
9884
|
+
addressMode.current = 'push';
|
|
9726
9885
|
setPreviewPath(path);
|
|
9727
9886
|
}, []);
|
|
9728
9887
|
const [selected, setSelected] = useState(null);
|
|
@@ -9829,6 +9988,30 @@ function App() {
|
|
|
9829
9988
|
// after it) renumbers. Stash the re-settle target { file, movedId, artboardId }
|
|
9830
9989
|
// so the dgn:'loaded' handler re-selects the moved element by its NEW id.
|
|
9831
9990
|
const pendingReorderRef = useRef(null);
|
|
9991
|
+
// The last layers tree the canvas posted, and its id signature. A duplicate
|
|
9992
|
+
// or insert renumbers every later positional id, so the copy's NEW id already
|
|
9993
|
+
// names another element (the next sibling) in the pre-write tree: settling
|
|
9994
|
+
// on the first tree that merely contains it selected the wrong node. The
|
|
9995
|
+
// pending entry records the signature at request time and waits for a tree
|
|
9996
|
+
// that differs from it.
|
|
9997
|
+
const lastLayersTreeRef = useRef(null);
|
|
9998
|
+
const settlePendingSelectionRef = useRef(null);
|
|
9999
|
+
settlePendingSelectionRef.current = (tree, sig, artboardId) => {
|
|
10000
|
+
const pend = pendingReorderRef.current;
|
|
10001
|
+
if (!pend || !pend.movedId) return;
|
|
10002
|
+
if (pend.staleSig != null && sig === pend.staleSig) return; // still the pre-write DOM
|
|
10003
|
+
const has = (function find(nodes) {
|
|
10004
|
+
return (nodes || []).some((n) => n.id === pend.movedId || find(n.children));
|
|
10005
|
+
})(tree);
|
|
10006
|
+
if (!has) return;
|
|
10007
|
+
pendingReorderRef.current = null;
|
|
10008
|
+
const win = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
10009
|
+
if (win) {
|
|
10010
|
+
try {
|
|
10011
|
+
win.postMessage({ dgn: 'select-by-id', id: pend.movedId, artboardId, index: 0 }, '*');
|
|
10012
|
+
} catch {}
|
|
10013
|
+
}
|
|
10014
|
+
};
|
|
9832
10015
|
// Latest `reorderLayer` (defined far below), read from the stale-closure
|
|
9833
10016
|
// onMessage handler when the in-canvas grip posts dgn:'reorder-request'
|
|
9834
10017
|
// (same freshness idiom as selectedRef; avoids a render-time TDZ on the
|
|
@@ -9924,6 +10107,7 @@ function App() {
|
|
|
9924
10107
|
// CloudBar (status resolve / attach / detach). Gates the GitPanel's
|
|
9925
10108
|
// cloud-managed posture; linked-but-uncredentialed keeps the full panel.
|
|
9926
10109
|
const [cloudLinkedHub, setCloudLinkedHub] = useState(null);
|
|
10110
|
+
const [localProjectName, setLocalProjectName] = useState(null);
|
|
9927
10111
|
// Phase 27 (E2) — in-UI git layer. `gitStatus` is the live dirty-state the
|
|
9928
10112
|
// server broadcasts on `git-status`; `changesOpen` toggles the Changes panel;
|
|
9929
10113
|
// `diffTarget` opens the before/after DiffView ({ file, conflict }).
|
|
@@ -10247,6 +10431,7 @@ function App() {
|
|
|
10247
10431
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
|
10248
10432
|
// T5/T6 (Plan C) — shell-level export/handoff dialog + inspector panel state.
|
|
10249
10433
|
// The palette (T4) drives them; the dialog (T5) + panel (T6) consume them.
|
|
10434
|
+
const [shareDialog, setShareDialog] = useState(null);
|
|
10250
10435
|
const [exportDialog, setExportDialog] = useState(null); // null | { mode: 'export'|'handoff', scope? }
|
|
10251
10436
|
// feature-ai-media-generation (DDR-16x) — BYOK provider-key Settings modal +
|
|
10252
10437
|
// the AI generate action.
|
|
@@ -10649,6 +10834,71 @@ function App() {
|
|
|
10649
10834
|
const wsRef = useRef(null);
|
|
10650
10835
|
const iframesRef = useRef(new Map());
|
|
10651
10836
|
|
|
10837
|
+
// THE CANVAS CAPABILITY OUTLIVES NO TAB (cloud only). It expires 15 minutes
|
|
10838
|
+
// after the proxy minted it (render-token.mjs) and cannot be revoked, so it
|
|
10839
|
+
// stays short — which left every cloud tab open longer than that with
|
|
10840
|
+
// canvases that stopped updating: a teammate's edit re-imports the canvas
|
|
10841
|
+
// module, and the canvas origin answered 401. Re-mint it well inside the
|
|
10842
|
+
// lifetime (every `/_config` answer carries a fresh one), hand it to each
|
|
10843
|
+
// open canvas, and let new frames be built with it. On waking a tab that
|
|
10844
|
+
// slept past the cadence, do it at once. Desktops have no capability and
|
|
10845
|
+
// never start the timer.
|
|
10846
|
+
useEffect(() => {
|
|
10847
|
+
if (!cfg?.canvasToken) return undefined;
|
|
10848
|
+
let stopped = false;
|
|
10849
|
+
let timer = null;
|
|
10850
|
+
let dueAt = 0;
|
|
10851
|
+
const schedule = (token) => {
|
|
10852
|
+
clearTimeout(timer);
|
|
10853
|
+
const delay = canvasTokenRefreshDelay(token);
|
|
10854
|
+
dueAt = Date.now() + delay;
|
|
10855
|
+
timer = setTimeout(refresh, delay);
|
|
10856
|
+
};
|
|
10857
|
+
async function refresh() {
|
|
10858
|
+
let token = null;
|
|
10859
|
+
try {
|
|
10860
|
+
const res = await fetch('/_config', { cache: 'no-store', credentials: 'same-origin' });
|
|
10861
|
+
if (res.ok) token = (await res.json())?.canvasToken;
|
|
10862
|
+
} catch {
|
|
10863
|
+
/* retried below; the current capability may still be valid */
|
|
10864
|
+
}
|
|
10865
|
+
if (stopped) return;
|
|
10866
|
+
if (typeof token !== 'string' || !token) {
|
|
10867
|
+
clearTimeout(timer);
|
|
10868
|
+
dueAt = Date.now() + 30_000;
|
|
10869
|
+
timer = setTimeout(refresh, 30_000);
|
|
10870
|
+
return;
|
|
10871
|
+
}
|
|
10872
|
+
setLiveCanvasToken(token);
|
|
10873
|
+
// To the canvas origin only, never '*': a frame the canvas content
|
|
10874
|
+
// navigated elsewhere must not be handed the capability.
|
|
10875
|
+
let target = null;
|
|
10876
|
+
try {
|
|
10877
|
+
target = new URL(cfg.canvasOrigin, location.href).origin;
|
|
10878
|
+
} catch {}
|
|
10879
|
+
if (target) {
|
|
10880
|
+
for (const el of iframesRef.current.values()) {
|
|
10881
|
+
try {
|
|
10882
|
+
el.contentWindow?.postMessage({ dgn: 'canvas-cap', t: token }, target);
|
|
10883
|
+
} catch {}
|
|
10884
|
+
}
|
|
10885
|
+
}
|
|
10886
|
+
schedule(token);
|
|
10887
|
+
}
|
|
10888
|
+
schedule(cfg.canvasToken);
|
|
10889
|
+
// A background tab's timers are throttled; one woken past its due time
|
|
10890
|
+
// re-mints at once rather than on the throttled tick.
|
|
10891
|
+
const onVisible = () => {
|
|
10892
|
+
if (document.visibilityState === 'visible' && Date.now() >= dueAt) refresh();
|
|
10893
|
+
};
|
|
10894
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
10895
|
+
return () => {
|
|
10896
|
+
stopped = true;
|
|
10897
|
+
clearTimeout(timer);
|
|
10898
|
+
document.removeEventListener('visibilitychange', onVisible);
|
|
10899
|
+
};
|
|
10900
|
+
}, [cfg?.canvasToken, cfg?.canvasOrigin]);
|
|
10901
|
+
|
|
10652
10902
|
// Phase 5.1 — postMessage bridge from menubar dropdowns to the canvas iframe.
|
|
10653
10903
|
// The canvas-shell listens for these `dgn:*` messages and dispatches into the
|
|
10654
10904
|
// matching local provider (annotations visibility / both selection stores /
|
|
@@ -11589,37 +11839,47 @@ function App() {
|
|
|
11589
11839
|
}, []);
|
|
11590
11840
|
|
|
11591
11841
|
// ----- Tree -----
|
|
11592
|
-
const
|
|
11593
|
-
|
|
11594
|
-
const r = await fetch('/_index-data');
|
|
11595
|
-
const data = await r.json();
|
|
11596
|
-
setProject(data.project || 'Design');
|
|
11597
|
-
const built = data.groups.map((g) => ({
|
|
11598
|
-
...g,
|
|
11599
|
-
tree: buildTree(g.paths, g.stripPrefix, g.dirs),
|
|
11600
|
-
}));
|
|
11601
|
-
setGroups(built);
|
|
11602
|
-
// DDR-093 — fold the server-resolved per-canvas DS map into cfg so
|
|
11603
|
-
// canvasUrl() injects each UI canvas's OWN design-system tokens instead of
|
|
11604
|
-
// always designSystems[0]. Functional merge to coexist with the /_config
|
|
11605
|
-
// fetch (either may land first). `?? {}` keeps older servers (no map) on
|
|
11606
|
-
// the ds0 fallback. Re-runs on every tree reload, so adding/retargeting a
|
|
11607
|
-
// canvas refreshes the map.
|
|
11608
|
-
setCfg((prev) => ({
|
|
11609
|
-
...prev,
|
|
11610
|
-
canvasDesignSystems: data.canvasDesignSystems ?? {},
|
|
11611
|
-
// DDR-174 (T15) — per-canvas notable `.meta.json` `kind` values (today:
|
|
11612
|
-
// only `reconstructed-experimental`), folded in the same way + for the
|
|
11613
|
-
// same reason as canvasDesignSystems above.
|
|
11614
|
-
canvasKinds: data.canvasKinds ?? {},
|
|
11615
|
-
}));
|
|
11616
|
-
} catch (e) {
|
|
11617
|
-
console.error('failed to load tree', e);
|
|
11618
|
-
}
|
|
11619
|
-
}, []);
|
|
11620
|
-
|
|
11842
|
+
const treeLoaderRef = useRef(null);
|
|
11843
|
+
const loadTree = useCallback(() => treeLoaderRef.current?.reload() ?? Promise.resolve(), []);
|
|
11621
11844
|
useEffect(() => {
|
|
11845
|
+
const loader = createIndexLoader({
|
|
11846
|
+
read: async (signal) => {
|
|
11847
|
+
const r = await fetch('/_index-data', { signal, cache: 'no-store' });
|
|
11848
|
+
if (!r.ok) throw Object.assign(new Error(`Project index request failed: ${r.status}`), { status: r.status });
|
|
11849
|
+
const data = await r.json();
|
|
11850
|
+
const built = data.groups.map((g) => ({
|
|
11851
|
+
...g,
|
|
11852
|
+
tree: buildTree(g.paths, g.stripPrefix, g.dirs),
|
|
11853
|
+
}));
|
|
11854
|
+
return { data, built };
|
|
11855
|
+
},
|
|
11856
|
+
apply: ({ data, built }) => {
|
|
11857
|
+
setProject(data.project || 'Design');
|
|
11858
|
+
setGroups(built);
|
|
11859
|
+
setTreeLoaded(true);
|
|
11860
|
+
// DDR-093 — fold the server-resolved per-canvas DS map into cfg so
|
|
11861
|
+
// canvasUrl() injects each UI canvas's OWN design-system tokens instead of
|
|
11862
|
+
// always designSystems[0]. Functional merge to coexist with the /_config
|
|
11863
|
+
// fetch (either may land first). `?? {}` keeps older servers (no map) on
|
|
11864
|
+
// the ds0 fallback. Re-runs on every tree reload, so adding/retargeting a
|
|
11865
|
+
// canvas refreshes the map.
|
|
11866
|
+
setCfg((prev) => ({
|
|
11867
|
+
...prev,
|
|
11868
|
+
canvasDesignSystems: data.canvasDesignSystems ?? {},
|
|
11869
|
+
// DDR-174 (T15) — per-canvas notable `.meta.json` `kind` values (today:
|
|
11870
|
+
// only `reconstructed-experimental`), folded in the same way + for the
|
|
11871
|
+
// same reason as canvasDesignSystems above.
|
|
11872
|
+
canvasKinds: data.canvasKinds ?? {},
|
|
11873
|
+
}));
|
|
11874
|
+
},
|
|
11875
|
+
onError: (error) => console.error('failed to load tree', error),
|
|
11876
|
+
});
|
|
11877
|
+
treeLoaderRef.current = loader;
|
|
11622
11878
|
loadTree();
|
|
11879
|
+
return () => {
|
|
11880
|
+
treeLoaderRef.current = null;
|
|
11881
|
+
loader.dispose();
|
|
11882
|
+
};
|
|
11623
11883
|
}, [loadTree]);
|
|
11624
11884
|
|
|
11625
11885
|
// ----- System data (lazy) -----
|
|
@@ -11668,6 +11928,7 @@ function App() {
|
|
|
11668
11928
|
}, [loadAllComments]);
|
|
11669
11929
|
|
|
11670
11930
|
// ----- WebSocket -----
|
|
11931
|
+
const canvasListChangeRef = useRef(() => {});
|
|
11671
11932
|
useEffect(() => {
|
|
11672
11933
|
// KEEPALIVE. The inspector feed only pushes on events (a comment, a
|
|
11673
11934
|
// selection, sync:status), so an idle designer's socket exchanges nothing
|
|
@@ -11779,26 +12040,8 @@ function App() {
|
|
|
11779
12040
|
// Phase 9 Task 8 — hub connection state for the offline banner.
|
|
11780
12041
|
setSyncStatus(m.payload);
|
|
11781
12042
|
} else if (m.type === 'canvas-list-update') {
|
|
11782
|
-
// Phase 30 — a canvas was created/deleted on THIS dev-server; re-read
|
|
11783
|
-
// the branch-scoped tree so other open tabs reflect it without a
|
|
11784
|
-
// reload. Cross-machine peers get a new canvas via git "Get latest".
|
|
11785
12043
|
loadTree();
|
|
11786
|
-
|
|
11787
|
-
// moveCanvasReq already retargets the INITIATING tab locally (no
|
|
11788
|
-
// need to wait for this broadcast to round-trip); this branch is
|
|
11789
|
-
// for every OTHER open tab on the same dev-server, so a canvas
|
|
11790
|
-
// that was open there doesn't go dead pointing at a path that no
|
|
11791
|
-
// longer exists.
|
|
11792
|
-
if (m.payload?.action === 'moved' && m.payload.fromRel && m.payload.rel) {
|
|
11793
|
-
const designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(
|
|
11794
|
-
/^\/+|\/+$/g,
|
|
11795
|
-
''
|
|
11796
|
-
);
|
|
11797
|
-
const fromFile = `${designRel}/${m.payload.fromRel}`;
|
|
11798
|
-
const toFile = `${designRel}/${m.payload.rel}`;
|
|
11799
|
-
setTabs((prev) => prev.map((t) => (t.path === fromFile ? { path: toFile } : t)));
|
|
11800
|
-
setActivePath((prev) => (prev === fromFile ? toFile : prev));
|
|
11801
|
-
}
|
|
12044
|
+
canvasListChangeRef.current(m.payload);
|
|
11802
12045
|
} else if (m.type === 'config-updated') {
|
|
11803
12046
|
// Server hot-reloaded .design/config.json (/design:setup-ds rewrote
|
|
11804
12047
|
// it) — refetch /_config so designSystems / tokensCssRel / groups
|
|
@@ -12001,6 +12244,39 @@ function App() {
|
|
|
12001
12244
|
}
|
|
12002
12245
|
}, []);
|
|
12003
12246
|
|
|
12247
|
+
// ACCEPTED REVISIONS — the project's own history (DDR-241, T27). When the
|
|
12248
|
+
// project saves through accepted revisions the History tab lists logical
|
|
12249
|
+
// actions (who did what, when), not Git commits; a row previews as `r<rev>`
|
|
12250
|
+
// through the same version preview. `'legacy'` means "use Git history".
|
|
12251
|
+
const [projectHistoryOn, setProjectHistoryOn] = useState(false);
|
|
12252
|
+
const loadAcceptedLog = useCallback(async (path) => {
|
|
12253
|
+
try {
|
|
12254
|
+
const qs =
|
|
12255
|
+
'/_api/project/history?limit=40' + (path ? `&path=${encodeURIComponent(path)}` : '');
|
|
12256
|
+
const r = await fetch(qs);
|
|
12257
|
+
if (!r.ok) return 'legacy';
|
|
12258
|
+
const data = await r.json();
|
|
12259
|
+
if (!data?.ok) return data?.reason === 'legacy' ? 'legacy' : null;
|
|
12260
|
+
return (data.history || []).map((a) => {
|
|
12261
|
+
const where = path ? '' : (a.canvases || []).map((c) => c.split('/').pop()).join(', ');
|
|
12262
|
+
return {
|
|
12263
|
+
sha: `r${a.revision}`,
|
|
12264
|
+
message: [a.label || a.kind, where].filter(Boolean).join(' · '),
|
|
12265
|
+
author: a.actor,
|
|
12266
|
+
date: new Date(a.committedAt).toISOString(),
|
|
12267
|
+
accepted: {
|
|
12268
|
+
revision: a.revision,
|
|
12269
|
+
actionId: a.actionId,
|
|
12270
|
+
mine: !!a.mine,
|
|
12271
|
+
undo: a.kind === 'undo' || a.kind === 'redo',
|
|
12272
|
+
},
|
|
12273
|
+
};
|
|
12274
|
+
});
|
|
12275
|
+
} catch {
|
|
12276
|
+
return null;
|
|
12277
|
+
}
|
|
12278
|
+
}, []);
|
|
12279
|
+
|
|
12004
12280
|
// Repo-relative path → M/A/D/U badge for the tree (paths match: both the tree
|
|
12005
12281
|
// and gitStatus use `.design/ui/Foo.tsx`). Keyed off gitStatus so it updates
|
|
12006
12282
|
// live with the WS broadcast.
|
|
@@ -12044,20 +12320,85 @@ function App() {
|
|
|
12044
12320
|
// (iframesRef, comments push, WS `tabs` message) doesn't need refactoring.
|
|
12045
12321
|
// ARTBOARDS slot in the menubar reads `tabs.length` and reports 0 or 1.
|
|
12046
12322
|
const openTab = useCallback((path) => {
|
|
12323
|
+
addressMode.current = 'push';
|
|
12324
|
+
setFocusedCommentId(null);
|
|
12325
|
+
setPreviewPath(null);
|
|
12326
|
+
// The same path keeps its mounted iframe. It will not emit a new loaded
|
|
12327
|
+
// event, so keep its current loading/error state until an actual retry.
|
|
12328
|
+
if (path === activePath) return;
|
|
12047
12329
|
setTabs((prev) => {
|
|
12048
12330
|
// Drop the previously-open iframe so we don't leak DOM nodes.
|
|
12049
12331
|
for (const t of prev) if (t.path !== path) iframesRef.current.delete(t.path);
|
|
12050
12332
|
return [{ path }];
|
|
12051
12333
|
});
|
|
12052
12334
|
setActivePath(path);
|
|
12053
|
-
setFocusedCommentId(null);
|
|
12054
|
-
setPreviewPath(null);
|
|
12055
12335
|
setCanvasError(null);
|
|
12056
12336
|
setLoadedPath(null);
|
|
12057
12337
|
// Canvas-compile skeleton — cleared by the iframe's dgn:'loaded' message,
|
|
12058
12338
|
// the onLoad fallback timer (legacy .html), or a hard 15s cap.
|
|
12059
12339
|
if (path !== SYSTEM_TAB) setLoadingPath(path);
|
|
12060
|
-
}, []);
|
|
12340
|
+
}, [activePath]);
|
|
12341
|
+
|
|
12342
|
+
// Resolve URL identities against the loaded tree, including non-canvas previews.
|
|
12343
|
+
const openLinkedFile = useCallback((rel, mode = 'push') => {
|
|
12344
|
+
const path = groups.flatMap((g) => g.paths || []).find((p) => normalizeOpenPath(p, cfg.designRel) === rel);
|
|
12345
|
+
if (path && CANVAS_EXT_RE.test(path)) openTab(path);
|
|
12346
|
+
else if (path && previewKind(basename(path))) onPreview(path);
|
|
12347
|
+
else {
|
|
12348
|
+
setTabs([]);
|
|
12349
|
+
setActivePath(null);
|
|
12350
|
+
setPreviewPath(null);
|
|
12351
|
+
notify({ title: 'Not here yet', description: `${rel} is not in this project (not synced yet?)`, kind: 'info' });
|
|
12352
|
+
}
|
|
12353
|
+
addressMode.current = mode;
|
|
12354
|
+
}, [groups, cfg.designRel, openTab, onPreview]);
|
|
12355
|
+
|
|
12356
|
+
const addressBooted = useRef(false);
|
|
12357
|
+
useEffect(() => {
|
|
12358
|
+
if (!treeLoaded || cfg.cloud === undefined || addressBooted.current) return;
|
|
12359
|
+
addressBooted.current = true;
|
|
12360
|
+
const rel = readOpenParam(location, cfg.designRel);
|
|
12361
|
+
if (rel) openLinkedFile(rel, 'none');
|
|
12362
|
+
}, [treeLoaded, cfg.cloud, cfg.designRel, openLinkedFile]);
|
|
12363
|
+
|
|
12364
|
+
useEffect(() => {
|
|
12365
|
+
if (!treeLoaded || cfg.cloud === undefined) return;
|
|
12366
|
+
const navigate = () => {
|
|
12367
|
+
const rel = readOpenParam(location, cfg.designRel);
|
|
12368
|
+
if (rel) openLinkedFile(rel, 'none');
|
|
12369
|
+
else {
|
|
12370
|
+
addressMode.current = 'none';
|
|
12371
|
+
setTabs([]);
|
|
12372
|
+
setActivePath(null);
|
|
12373
|
+
setPreviewPath(null);
|
|
12374
|
+
}
|
|
12375
|
+
};
|
|
12376
|
+
window.addEventListener('popstate', navigate);
|
|
12377
|
+
return () => window.removeEventListener('popstate', navigate);
|
|
12378
|
+
}, [treeLoaded, cfg.cloud, cfg.designRel, openLinkedFile]);
|
|
12379
|
+
|
|
12380
|
+
useEffect(() => {
|
|
12381
|
+
const visible = previewPath || (activePath === SYSTEM_TAB ? null : activePath);
|
|
12382
|
+
if (visible === previousAddressPath.current) return;
|
|
12383
|
+
previousAddressPath.current = visible;
|
|
12384
|
+
const rel = normalizeOpenPath(visible, cfg.designRel);
|
|
12385
|
+
if (addressMode.current !== 'none' && readOpenParam(location, cfg.designRel) !== rel) {
|
|
12386
|
+
history[rel ? 'pushState' : 'replaceState'](rel ? { open: rel } : null, '', withOpenParam(location, rel));
|
|
12387
|
+
}
|
|
12388
|
+
addressMode.current = 'push';
|
|
12389
|
+
}, [activePath, previewPath, cfg.designRel]);
|
|
12390
|
+
|
|
12391
|
+
const sharePath = previewPath || (activePath === SYSTEM_TAB ? null : activePath);
|
|
12392
|
+
const shareShell = cfg.cloud ? 'cloud' : isNativeApp() ? 'native' : 'local';
|
|
12393
|
+
const shareLinksFor = (path) => buildShareLinks({
|
|
12394
|
+
rel: normalizeOpenPath(path, cfg.designRel), shell: shareShell, location,
|
|
12395
|
+
linkedHubUrl: cloudLinkedHub?.url, project: cfg.cloud ? project : localProjectName,
|
|
12396
|
+
localUrl: location.origin,
|
|
12397
|
+
});
|
|
12398
|
+
const showShare = (path = sharePath) => {
|
|
12399
|
+
const rel = normalizeOpenPath(path, cfg.designRel);
|
|
12400
|
+
if (rel) setShareDialog({ path, rel, label: basename(path) });
|
|
12401
|
+
};
|
|
12061
12402
|
|
|
12062
12403
|
// Retry from the #115 error panel: re-read /_config FIRST (the stale
|
|
12063
12404
|
// `canvasOrigin` is the likeliest reason we are here at all), then remount the
|
|
@@ -12089,6 +12430,7 @@ function App() {
|
|
|
12089
12430
|
useEffect(() => {
|
|
12090
12431
|
if (autoOpened.current) return;
|
|
12091
12432
|
if (!cfg.cloud) return; // desktop / unknown-yet
|
|
12433
|
+
if (new URLSearchParams(location.search).has('open')) { autoOpened.current = true; return; }
|
|
12092
12434
|
if (tabs.length > 0) return;
|
|
12093
12435
|
if (!groups.length) return; // tree not loaded
|
|
12094
12436
|
// The first canvas that is somebody's WORK — a design-system specimen is a
|
|
@@ -12149,6 +12491,23 @@ function App() {
|
|
|
12149
12491
|
[activePath]
|
|
12150
12492
|
);
|
|
12151
12493
|
|
|
12494
|
+
// The socket stays connected while this ref follows the current config and
|
|
12495
|
+
// active tab. Structural events apply to local and remote users alike.
|
|
12496
|
+
canvasListChangeRef.current = (change) => {
|
|
12497
|
+
if (!change?.rel) return;
|
|
12498
|
+
const designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '');
|
|
12499
|
+
const file = `${designRel}/${change.rel}`;
|
|
12500
|
+
if (change.action === 'removed') {
|
|
12501
|
+
closeTab(file);
|
|
12502
|
+
} else if (change.action === 'moved' && change.fromRel) {
|
|
12503
|
+
const fromFile = `${designRel}/${change.fromRel}`;
|
|
12504
|
+
setTabs((prev) => prev.map((t) => t.path === fromFile ? { ...t, path: file } : t));
|
|
12505
|
+
setActivePath((prev) => prev === fromFile ? file : prev);
|
|
12506
|
+
setLoadingPath((prev) => prev === fromFile ? null : prev);
|
|
12507
|
+
iframesRef.current.delete(fromFile);
|
|
12508
|
+
}
|
|
12509
|
+
};
|
|
12510
|
+
|
|
12152
12511
|
const reloadActive = useCallback(() => {
|
|
12153
12512
|
if (!activePath || activePath === SYSTEM_TAB) {
|
|
12154
12513
|
if (activePath === SYSTEM_TAB) loadSystemData();
|
|
@@ -12533,6 +12892,100 @@ function App() {
|
|
|
12533
12892
|
[loadTree]
|
|
12534
12893
|
);
|
|
12535
12894
|
|
|
12895
|
+
// Plan T17 — rename a folder: a move to a new name in the same parent (one
|
|
12896
|
+
// dir.move action in accepted mode, carrying every canvas inside).
|
|
12897
|
+
const renameFolderReq = useCallback(
|
|
12898
|
+
async (dirPath, name) => {
|
|
12899
|
+
const parent = dirPath.split('/').slice(0, -1).join('/');
|
|
12900
|
+
try {
|
|
12901
|
+
const r = await fetch('/_api/fs-move', {
|
|
12902
|
+
method: 'POST',
|
|
12903
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12904
|
+
body: JSON.stringify({ file: dirPath, toDir: parent, toName: name }),
|
|
12905
|
+
});
|
|
12906
|
+
const j = await r.json().catch(() => ({}));
|
|
12907
|
+
if (!r.ok || !j.ok) {
|
|
12908
|
+
shellToast(`Could not rename folder: ${j.error || `error ${r.status}`}`);
|
|
12909
|
+
return;
|
|
12910
|
+
}
|
|
12911
|
+
await loadTree();
|
|
12912
|
+
} catch (e) {
|
|
12913
|
+
shellToast(`Rename failed: ${e instanceof Error ? e.message : 'network error'}`);
|
|
12914
|
+
}
|
|
12915
|
+
},
|
|
12916
|
+
[loadTree]
|
|
12917
|
+
);
|
|
12918
|
+
|
|
12919
|
+
// Plan T25/L04 — rename a canvas in place (its sidecars follow; an open tab
|
|
12920
|
+
// follows the new path) and duplicate one beside itself.
|
|
12921
|
+
const renameCanvasReq = useCallback(
|
|
12922
|
+
async (filePath, name) => {
|
|
12923
|
+
try {
|
|
12924
|
+
const r = await fetch('/_api/fs-move', {
|
|
12925
|
+
method: 'POST',
|
|
12926
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12927
|
+
body: JSON.stringify({ file: filePath, toName: name }),
|
|
12928
|
+
});
|
|
12929
|
+
const j = await r.json().catch(() => ({}));
|
|
12930
|
+
if (!r.ok || !j.ok) {
|
|
12931
|
+
shellToast(`Could not rename: ${j.error || `error ${r.status}`}`);
|
|
12932
|
+
return;
|
|
12933
|
+
}
|
|
12934
|
+
const designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(
|
|
12935
|
+
/^\/+|\/+$/g,
|
|
12936
|
+
''
|
|
12937
|
+
);
|
|
12938
|
+
const fromFile = `${designRel}/${j.fromRel}`;
|
|
12939
|
+
const toFile = `${designRel}/${j.toRel}`;
|
|
12940
|
+
await loadTree();
|
|
12941
|
+
setTabs((prev) => prev.map((t) => (t.path === fromFile ? { path: toFile } : t)));
|
|
12942
|
+
setActivePath((prev) => (prev === fromFile ? toFile : prev));
|
|
12943
|
+
} catch (e) {
|
|
12944
|
+
shellToast(`Rename failed: ${e instanceof Error ? e.message : 'network error'}`);
|
|
12945
|
+
}
|
|
12946
|
+
},
|
|
12947
|
+
[loadTree, cfg]
|
|
12948
|
+
);
|
|
12949
|
+
const duplicateCanvasReq = useCallback(
|
|
12950
|
+
async (filePath) => {
|
|
12951
|
+
try {
|
|
12952
|
+
const r = await fetch('/_api/canvas', {
|
|
12953
|
+
method: 'POST',
|
|
12954
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12955
|
+
body: JSON.stringify({ duplicateOf: filePath }),
|
|
12956
|
+
});
|
|
12957
|
+
const j = await r.json().catch(() => ({}));
|
|
12958
|
+
if (!r.ok || !j.ok) {
|
|
12959
|
+
shellToast(`Could not duplicate: ${j.error || `error ${r.status}`}`);
|
|
12960
|
+
return;
|
|
12961
|
+
}
|
|
12962
|
+
await loadTree();
|
|
12963
|
+
shellToast(`Duplicated as ${j.rel.split('/').pop().replace(/\.tsx$/i, '')}`, true);
|
|
12964
|
+
} catch (e) {
|
|
12965
|
+
shellToast(`Duplicate failed: ${e instanceof Error ? e.message : 'network error'}`);
|
|
12966
|
+
}
|
|
12967
|
+
},
|
|
12968
|
+
[loadTree]
|
|
12969
|
+
);
|
|
12970
|
+
|
|
12971
|
+
const deleteFileReq = useCallback(
|
|
12972
|
+
async (filePath, name) => {
|
|
12973
|
+
if (!window.confirm(`Move “${name}” to trash?\n\nYou can restore it from .design/_trash/.`)) return;
|
|
12974
|
+
try {
|
|
12975
|
+
const r = await fetch(`/_api/canvas?file=${encodeURIComponent(filePath)}`, { method: 'DELETE' });
|
|
12976
|
+
const j = await r.json().catch(() => ({}));
|
|
12977
|
+
if (!r.ok || !j.ok) {
|
|
12978
|
+
shellToast(`Could not delete: ${j.error || `error ${r.status}`}`);
|
|
12979
|
+
return;
|
|
12980
|
+
}
|
|
12981
|
+
await loadTree();
|
|
12982
|
+
} catch (e) {
|
|
12983
|
+
shellToast(`Delete failed: ${e instanceof Error ? e.message : 'network error'}`);
|
|
12984
|
+
}
|
|
12985
|
+
},
|
|
12986
|
+
[loadTree]
|
|
12987
|
+
);
|
|
12988
|
+
|
|
12536
12989
|
const clearSelected = useCallback(() => {
|
|
12537
12990
|
wsSend({ type: 'clear-select' });
|
|
12538
12991
|
setSelected(null);
|
|
@@ -12582,6 +13035,13 @@ function App() {
|
|
|
12582
13035
|
if (e.origin !== expectedOrigin) return;
|
|
12583
13036
|
const m = e.data;
|
|
12584
13037
|
if (!m || typeof m !== 'object' || !m.dgn) return;
|
|
13038
|
+
if (m.dgn === 'canvas-notice') {
|
|
13039
|
+
const activeWin = activePath && activePath !== SYSTEM_TAB
|
|
13040
|
+
? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
13041
|
+
const notice = acceptCanvasNotice(e, expectedOrigin, activeWin);
|
|
13042
|
+
if (notice) notifyCanvasText(notice.title, notice.kind);
|
|
13043
|
+
return;
|
|
13044
|
+
}
|
|
12585
13045
|
if (m.dgn === 'tool-cursor') {
|
|
12586
13046
|
// Phase 24 — show the active canvas tool's cursor across the WHOLE app
|
|
12587
13047
|
// shell (sidebar, top bar, everything) so the custom cursor is visible
|
|
@@ -12723,55 +13183,48 @@ function App() {
|
|
|
12723
13183
|
// Inline-edit undo/redo (DDR-103/104 follow-up). The canvas iframe's
|
|
12724
13184
|
// `edit-source` command can't call the main-origin-only `/_api/edit-*`
|
|
12725
13185
|
// routes (DDR-054), so it asks us to re-apply the before/after value.
|
|
12726
|
-
// `value` null = reset (remove the inline prop / attr).
|
|
12727
|
-
//
|
|
12728
|
-
|
|
13186
|
+
// `value` null = reset (remove the inline prop / attr). The css/attr
|
|
13187
|
+
// request carries the command's expected current value, so an undo can
|
|
13188
|
+
// never overwrite a teammate's newer value (audit 2026-09-13 P1 #5);
|
|
13189
|
+
// the outcome goes back to the canvas so a refused undo/redo does not
|
|
13190
|
+
// advance its stack.
|
|
13191
|
+
const req = applyEditRequest(m);
|
|
13192
|
+
if (!req) return;
|
|
13193
|
+
const replyTo = e.source;
|
|
13194
|
+
const reply = (result) => {
|
|
13195
|
+
if (typeof m.requestId !== 'string') return;
|
|
13196
|
+
try {
|
|
13197
|
+
replyTo?.postMessage({ dgn: 'apply-edit-result', requestId: m.requestId, ...result }, '*');
|
|
13198
|
+
} catch {}
|
|
13199
|
+
};
|
|
12729
13200
|
const value = typeof m.value === 'string' ? m.value : null;
|
|
12730
|
-
let url;
|
|
12731
|
-
let body;
|
|
12732
|
-
if (op === 'css') {
|
|
12733
|
-
url = '/_api/edit-css';
|
|
12734
|
-
body =
|
|
12735
|
-
value == null
|
|
12736
|
-
? { canvas: m.canvas, id: m.id, property: m.key, reset: true }
|
|
12737
|
-
: { canvas: m.canvas, id: m.id, property: m.key, value };
|
|
12738
|
-
applyOptimisticStyle({ id: m.id, prop: m.key, value });
|
|
12739
|
-
} else if (op === 'attr') {
|
|
12740
|
-
url = '/_api/edit-attr';
|
|
12741
|
-
body =
|
|
12742
|
-
value == null
|
|
12743
|
-
? { canvas: m.canvas, id: m.id, attr: m.key, reset: true }
|
|
12744
|
-
: { canvas: m.canvas, id: m.id, attr: m.key, value };
|
|
12745
|
-
} else {
|
|
12746
|
-
url = '/_api/edit-text';
|
|
12747
|
-
body = { canvas: m.canvas, id: m.id, text: value ?? '' };
|
|
12748
|
-
// Undo/redo of a `{variable}` text edit needs to re-target the source
|
|
12749
|
-
// string: the occurrence + the value currently on disk (`from` — the
|
|
12750
|
-
// side we're replacing FROM). Harmless for literal text.
|
|
12751
|
-
if (typeof m.occurrence === 'number') body.occurrence = m.occurrence;
|
|
12752
|
-
if (typeof m.from === 'string') body.before = m.from;
|
|
12753
|
-
}
|
|
12754
13201
|
editApplyChainRef.current = editApplyChainRef.current
|
|
12755
13202
|
.catch(() => {})
|
|
12756
13203
|
.then(() =>
|
|
12757
|
-
fetch(url, {
|
|
13204
|
+
fetch(req.url, {
|
|
12758
13205
|
method: 'POST',
|
|
12759
13206
|
headers: { 'content-type': 'application/json' },
|
|
12760
|
-
body: JSON.stringify(body),
|
|
13207
|
+
body: JSON.stringify(req.body),
|
|
12761
13208
|
})
|
|
12762
13209
|
.then((r) => r.json().catch(() => ({})))
|
|
12763
13210
|
.then((j) => {
|
|
12764
|
-
|
|
12765
|
-
|
|
12766
|
-
|
|
12767
|
-
|
|
12768
|
-
|
|
13211
|
+
if (j.ok) {
|
|
13212
|
+
// Repaint only once the source accepted it: a refused undo
|
|
13213
|
+
// must not show a value the file does not hold.
|
|
13214
|
+
if (req.op === 'css') applyOptimisticStyle({ id: m.id, prop: m.key, value });
|
|
13215
|
+
reply({ ok: true });
|
|
13216
|
+
return;
|
|
13217
|
+
}
|
|
13218
|
+
console.warn('[apply-edit]', req.op, j.error || 'failed');
|
|
13219
|
+
reply({ ok: false, error: j.error || 'failed', conflict: j.conflict === true });
|
|
12769
13220
|
})
|
|
12770
|
-
.catch(() => {})
|
|
13221
|
+
.catch(() => reply({ ok: false, error: 'network error' }))
|
|
12771
13222
|
);
|
|
12772
13223
|
} else if (m.dgn === 'layers-tree') {
|
|
12773
13224
|
// Phase 12 Task 4 — browsable layers tree for the active artboard.
|
|
12774
13225
|
setLayersTree({ artboardId: m.artboardId, nodes: Array.isArray(m.tree) ? m.tree : [] });
|
|
13226
|
+
const sig = layersTreeSig(m.tree);
|
|
13227
|
+
lastLayersTreeRef.current = { tree: m.tree, sig, artboardId: m.artboardId ?? null };
|
|
12775
13228
|
// fresh tree (correct ids) landed — drags OK again
|
|
12776
13229
|
layersBusyRef.current = false;
|
|
12777
13230
|
if (layersBusyTimerRef.current) {
|
|
@@ -12787,24 +13240,7 @@ function App() {
|
|
|
12787
13240
|
// actually CONTAINS movedId — an in-canvas drag posts a PREVIEW tree
|
|
12788
13241
|
// first (old ids, pre-write); consuming then would re-select nothing and
|
|
12789
13242
|
// burn the pending ref before the real post-HMR tree lands.
|
|
12790
|
-
|
|
12791
|
-
if (pend && pend.movedId) {
|
|
12792
|
-
const has = (function find(nodes) {
|
|
12793
|
-
return (nodes || []).some((n) => n.id === pend.movedId || find(n.children));
|
|
12794
|
-
})(m.tree);
|
|
12795
|
-
if (has) {
|
|
12796
|
-
pendingReorderRef.current = null;
|
|
12797
|
-
const win = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
12798
|
-
if (win) {
|
|
12799
|
-
try {
|
|
12800
|
-
win.postMessage(
|
|
12801
|
-
{ dgn: 'select-by-id', id: pend.movedId, artboardId: m.artboardId ?? null, index: 0 },
|
|
12802
|
-
'*'
|
|
12803
|
-
);
|
|
12804
|
-
} catch {}
|
|
12805
|
-
}
|
|
12806
|
-
}
|
|
12807
|
-
}
|
|
13243
|
+
settlePendingSelectionRef.current?.(m.tree, sig, m.artboardId ?? null);
|
|
12808
13244
|
} else if (m.dgn === 'reorder-revert') {
|
|
12809
13245
|
// Phase 12.1 follow-up — Cmd+Z/Cmd+Shift+Z on a reorder. The canvas undo
|
|
12810
13246
|
// stack (untrusted iframe) can't reach the main-origin-only
|
|
@@ -13128,6 +13564,23 @@ function App() {
|
|
|
13128
13564
|
if (e.source === activeWin && typeof m.artboardId === 'string') {
|
|
13129
13565
|
deleteArtboardShellRef.current?.(m.artboardId);
|
|
13130
13566
|
}
|
|
13567
|
+
} else if (m.dgn === 'rename-artboard-request') {
|
|
13568
|
+
// Plan T25/L08 — double-click an artboard's name to rename it. Same
|
|
13569
|
+
// confused-deputy guard: the canvas asks, the shell writes the active
|
|
13570
|
+
// canvas only.
|
|
13571
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
13572
|
+
if (
|
|
13573
|
+
e.source === activeWin &&
|
|
13574
|
+
typeof m.artboardId === 'string' &&
|
|
13575
|
+
typeof m.label === 'string' &&
|
|
13576
|
+
m.label.trim()
|
|
13577
|
+
) {
|
|
13578
|
+
structuralWriteRef.current?.(
|
|
13579
|
+
'/_api/set-artboard-label',
|
|
13580
|
+
{ artboardId: m.artboardId, label: m.label.slice(0, 80) },
|
|
13581
|
+
{ label: 'rename artboard' }
|
|
13582
|
+
);
|
|
13583
|
+
}
|
|
13131
13584
|
} else if (m.dgn === 'set-artboard-kind-request') {
|
|
13132
13585
|
// feature-1-artboard-kinds-foundation, T8 — context-menu "Artboard
|
|
13133
13586
|
// kind" submenu (inside the untrusted iframe). `kind: null` clears
|
|
@@ -13774,6 +14227,7 @@ function App() {
|
|
|
13774
14227
|
// fix as resizeElement; also covers the keyboard nudge (L1) which reuses this.
|
|
13775
14228
|
applyOptimisticStyle({ id, prop: 'left', value: `${left}px` });
|
|
13776
14229
|
applyOptimisticStyle({ id, prop: 'top', value: `${top}px` });
|
|
14230
|
+
let j1ref = null;
|
|
13777
14231
|
const writeProp = (property, value) =>
|
|
13778
14232
|
fetch('/_api/edit-css', {
|
|
13779
14233
|
method: 'POST',
|
|
@@ -13785,6 +14239,7 @@ function App() {
|
|
|
13785
14239
|
.then(() => writeProp('left', left))
|
|
13786
14240
|
.then((j1) => {
|
|
13787
14241
|
if (!j1.ok) throw new Error(j1.error || 'left write failed');
|
|
14242
|
+
j1ref = j1;
|
|
13788
14243
|
return writeProp('top', top);
|
|
13789
14244
|
})
|
|
13790
14245
|
.then((j2) => {
|
|
@@ -13796,7 +14251,7 @@ function App() {
|
|
|
13796
14251
|
canvas,
|
|
13797
14252
|
id,
|
|
13798
14253
|
key: 'left',
|
|
13799
|
-
before: `${beforeLeft}px
|
|
14254
|
+
before: replacedValue(j1ref, `${beforeLeft}px`),
|
|
13800
14255
|
after: `${left}px`,
|
|
13801
14256
|
});
|
|
13802
14257
|
recordSourceEdit({
|
|
@@ -13804,7 +14259,7 @@ function App() {
|
|
|
13804
14259
|
canvas,
|
|
13805
14260
|
id,
|
|
13806
14261
|
key: 'top',
|
|
13807
|
-
before: `${beforeTop}px
|
|
14262
|
+
before: replacedValue(j2, `${beforeTop}px`),
|
|
13808
14263
|
after: `${top}px`,
|
|
13809
14264
|
});
|
|
13810
14265
|
})
|
|
@@ -13892,11 +14347,15 @@ function App() {
|
|
|
13892
14347
|
headers: { 'content-type': 'application/json' },
|
|
13893
14348
|
body: JSON.stringify({ canvas, id, property, value, idIndex: occ }),
|
|
13894
14349
|
}).then((r) => r.json().catch(() => ({})));
|
|
14350
|
+
const replies = {};
|
|
13895
14351
|
let chain = editApplyChainRef.current.catch(() => {});
|
|
13896
14352
|
for (const p of props) {
|
|
13897
14353
|
chain = chain.then((prev) => {
|
|
13898
14354
|
if (prev && prev.ok === false) throw new Error(prev.error || `${p} write failed`);
|
|
13899
|
-
return writeProp(p, patch[p])
|
|
14355
|
+
return writeProp(p, patch[p]).then((j) => {
|
|
14356
|
+
replies[p] = j;
|
|
14357
|
+
return j;
|
|
14358
|
+
});
|
|
13900
14359
|
});
|
|
13901
14360
|
}
|
|
13902
14361
|
editApplyChainRef.current = chain
|
|
@@ -13904,7 +14363,14 @@ function App() {
|
|
|
13904
14363
|
if (last && last.ok === false) throw new Error(last.error || 'resize write failed');
|
|
13905
14364
|
// Record one undo entry per written property (each Cmd+Z reverts one).
|
|
13906
14365
|
for (const p of props) {
|
|
13907
|
-
recordSourceEdit({
|
|
14366
|
+
recordSourceEdit({
|
|
14367
|
+
op: 'css',
|
|
14368
|
+
canvas,
|
|
14369
|
+
id,
|
|
14370
|
+
key: p,
|
|
14371
|
+
before: replacedValue(replies[p], b[p] ?? null),
|
|
14372
|
+
after: patch[p],
|
|
14373
|
+
});
|
|
13908
14374
|
}
|
|
13909
14375
|
// Dogfood 2026-07-07 — belt-and-suspenders reselect, mirroring the
|
|
13910
14376
|
// structural ops' `pendingReorderRef` re-settle. The DDR-105 suppression
|
|
@@ -13936,9 +14402,18 @@ function App() {
|
|
|
13936
14402
|
// inverse descriptor goes stale (same reason reorder uses the server seq log).
|
|
13937
14403
|
const structuralWriteRef = useRef(null);
|
|
13938
14404
|
const structuralWrite = useCallback(
|
|
13939
|
-
(route, body, { label, onOk, onFail } = {}) => {
|
|
14405
|
+
(route, body, { label, onOk, onFail: failed } = {}) => {
|
|
13940
14406
|
if (!activePath) return;
|
|
13941
14407
|
const canvas = activePath;
|
|
14408
|
+
// A refused structural edit used to vanish into the console: the
|
|
14409
|
+
// designer pressed Delete and nothing happened. Say so in the canvas.
|
|
14410
|
+
const onFail =
|
|
14411
|
+
failed ??
|
|
14412
|
+
((j) =>
|
|
14413
|
+
postToActiveCanvas({
|
|
14414
|
+
dgn: 'op-toast',
|
|
14415
|
+
message: `Couldn't ${label || 'apply the edit'}${j?.error ? ` — ${j.error}` : ''}.`,
|
|
14416
|
+
}));
|
|
13942
14417
|
editApplyChainRef.current = editApplyChainRef.current
|
|
13943
14418
|
.catch(() => {})
|
|
13944
14419
|
.then(() =>
|
|
@@ -13985,6 +14460,7 @@ function App() {
|
|
|
13985
14460
|
|
|
13986
14461
|
const insertElementShell = useCallback(
|
|
13987
14462
|
(refId, position, kind, opts = {}) => {
|
|
14463
|
+
const staleSig = lastLayersTreeRef.current?.sig ?? null;
|
|
13988
14464
|
structuralWrite(
|
|
13989
14465
|
'/_api/insert-element',
|
|
13990
14466
|
{
|
|
@@ -14000,7 +14476,10 @@ function App() {
|
|
|
14000
14476
|
// Select the new element once the HMR reload lands (its id is stamped
|
|
14001
14477
|
// on transpile — best-effort, like reorder's pendingReorderRef).
|
|
14002
14478
|
onOk: (j, canvas) => {
|
|
14003
|
-
if (j.newId)
|
|
14479
|
+
if (!j.newId) return;
|
|
14480
|
+
pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null, staleSig };
|
|
14481
|
+
const last = lastLayersTreeRef.current;
|
|
14482
|
+
if (last) settlePendingSelectionRef.current?.(last.tree, last.sig, last.artboardId);
|
|
14004
14483
|
},
|
|
14005
14484
|
}
|
|
14006
14485
|
);
|
|
@@ -14064,14 +14543,19 @@ function App() {
|
|
|
14064
14543
|
|
|
14065
14544
|
const duplicateElementShell = useCallback(
|
|
14066
14545
|
(id, idIndex) => {
|
|
14546
|
+
const staleSig = lastLayersTreeRef.current?.sig ?? null;
|
|
14067
14547
|
structuralWrite(
|
|
14068
14548
|
'/_api/duplicate-element',
|
|
14069
14549
|
{ id, idIndex: Number.isInteger(idIndex) ? idIndex : undefined },
|
|
14070
14550
|
{
|
|
14071
14551
|
label: 'duplicate element',
|
|
14072
|
-
// Select the copy once the
|
|
14552
|
+
// Select the copy once the post-write tree lands — not the pre-write
|
|
14553
|
+
// one, where the copy's id still names the next sibling.
|
|
14073
14554
|
onOk: (j, canvas) => {
|
|
14074
|
-
if (j.newId)
|
|
14555
|
+
if (!j.newId) return;
|
|
14556
|
+
pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null, staleSig };
|
|
14557
|
+
const last = lastLayersTreeRef.current;
|
|
14558
|
+
if (last) settlePendingSelectionRef.current?.(last.tree, last.sig, last.artboardId);
|
|
14075
14559
|
},
|
|
14076
14560
|
}
|
|
14077
14561
|
);
|
|
@@ -14141,7 +14625,14 @@ function App() {
|
|
|
14141
14625
|
.then((r) => r.json().catch(() => ({})))
|
|
14142
14626
|
.then((j) => {
|
|
14143
14627
|
if (j.ok)
|
|
14144
|
-
recordSourceEdit({
|
|
14628
|
+
recordSourceEdit({
|
|
14629
|
+
op: 'css',
|
|
14630
|
+
canvas,
|
|
14631
|
+
id,
|
|
14632
|
+
key: property,
|
|
14633
|
+
before: replacedValue(j, null),
|
|
14634
|
+
after: value,
|
|
14635
|
+
});
|
|
14145
14636
|
})
|
|
14146
14637
|
.catch(() => {})
|
|
14147
14638
|
);
|
|
@@ -14481,7 +14972,7 @@ function App() {
|
|
|
14481
14972
|
canvas,
|
|
14482
14973
|
id: req.id,
|
|
14483
14974
|
key: 'src',
|
|
14484
|
-
before: req.before ?? null,
|
|
14975
|
+
before: replacedValue(j, req.before ?? null),
|
|
14485
14976
|
after: pickedPath,
|
|
14486
14977
|
});
|
|
14487
14978
|
} else {
|
|
@@ -14945,11 +15436,18 @@ function App() {
|
|
|
14945
15436
|
kbd: '⇧⌘E',
|
|
14946
15437
|
run: () => setExportDialog({ mode: 'export' }),
|
|
14947
15438
|
},
|
|
15439
|
+
{
|
|
15440
|
+
id: 'share-link',
|
|
15441
|
+
group: 'Canvas',
|
|
15442
|
+
label: 'Copy share link',
|
|
15443
|
+
icon: 'link',
|
|
15444
|
+
run: () => { const links = shareLinksFor(sharePath); const link = links.web ?? links.app ?? links.local; if (link) copyShareLink(link); },
|
|
15445
|
+
},
|
|
14948
15446
|
{
|
|
14949
15447
|
id: 'handoff',
|
|
14950
15448
|
group: 'Canvas',
|
|
14951
15449
|
label: 'Handoff to production',
|
|
14952
|
-
icon: '
|
|
15450
|
+
icon: 'external',
|
|
14953
15451
|
kbd: '⇧⌘H',
|
|
14954
15452
|
run: () => setExportDialog({ mode: 'handoff' }),
|
|
14955
15453
|
},
|
|
@@ -15054,7 +15552,7 @@ function App() {
|
|
|
15054
15552
|
run: () => setReportBugOpen(true),
|
|
15055
15553
|
},
|
|
15056
15554
|
],
|
|
15057
|
-
[openSystem, toggleTheme, reloadActive, whatsNew, createVideo]
|
|
15555
|
+
[openSystem, toggleTheme, reloadActive, whatsNew, createVideo, sharePath, shareShell, cloudLinkedHub, localProjectName, cfg.designRel]
|
|
15058
15556
|
);
|
|
15059
15557
|
|
|
15060
15558
|
// feature-configurable-panel-docking — resolve, for each slot, the panels
|
|
@@ -15133,6 +15631,9 @@ function App() {
|
|
|
15133
15631
|
activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
|
|
15134
15632
|
onOpen={openTab}
|
|
15135
15633
|
onPreview={onPreview}
|
|
15634
|
+
onOpenLinkedFile={openLinkedFile}
|
|
15635
|
+
filesReady={treeLoaded}
|
|
15636
|
+
onShare={showShare}
|
|
15136
15637
|
onOpenSystem={openSystem}
|
|
15137
15638
|
wsConnected={wsConnected}
|
|
15138
15639
|
search={search}
|
|
@@ -15146,6 +15647,10 @@ function App() {
|
|
|
15146
15647
|
onMoveCanvas={moveCanvasReq}
|
|
15147
15648
|
onNewFolder={newFolderReq}
|
|
15148
15649
|
onDeleteFolder={deleteFolderReq}
|
|
15650
|
+
onRenameFolder={renameFolderReq}
|
|
15651
|
+
onRenameCanvas={renameCanvasReq}
|
|
15652
|
+
onDuplicateCanvas={duplicateCanvasReq}
|
|
15653
|
+
onDeleteFile={deleteFileReq}
|
|
15149
15654
|
onRefresh={refreshTree}
|
|
15150
15655
|
refreshing={treeRefreshing}
|
|
15151
15656
|
collapsed={false}
|
|
@@ -15159,6 +15664,7 @@ function App() {
|
|
|
15159
15664
|
canvasKinds={cfg?.canvasKinds}
|
|
15160
15665
|
syncStatus={syncStatus}
|
|
15161
15666
|
onLinkedHub={setCloudLinkedHub}
|
|
15667
|
+
onLocalProject={setLocalProjectName}
|
|
15162
15668
|
savingIsManaged={savingIsManaged}
|
|
15163
15669
|
/>
|
|
15164
15670
|
);
|
|
@@ -15199,8 +15705,35 @@ function App() {
|
|
|
15199
15705
|
// WHICH HISTORY, decided at the ONE place the posture is named — not
|
|
15200
15706
|
// inside the panel, which would be a second derivation of the rule
|
|
15201
15707
|
// `cloud-managed-save-surfaces.test.ts` exists to keep singular.
|
|
15202
|
-
loadLog={
|
|
15203
|
-
|
|
15708
|
+
loadLog={async (path) => {
|
|
15709
|
+
// The project's accepted history when it has one; Git otherwise.
|
|
15710
|
+
const accepted = await loadAcceptedLog(path);
|
|
15711
|
+
setProjectHistoryOn(accepted !== 'legacy');
|
|
15712
|
+
if (accepted !== 'legacy') return accepted;
|
|
15713
|
+
return (cloudManaged ? gitLoadCloudLog : gitLoadLog)(path);
|
|
15714
|
+
}}
|
|
15715
|
+
historySource={projectHistoryOn ? 'project' : cloudManaged ? 'cloud' : 'local'}
|
|
15716
|
+
onRestoreVersion={async (revision) => {
|
|
15717
|
+
const r = await fetch('/_api/project/restore', {
|
|
15718
|
+
method: 'POST',
|
|
15719
|
+
headers: { 'Content-Type': 'application/json' },
|
|
15720
|
+
body: JSON.stringify({ path: activePath, revision }),
|
|
15721
|
+
}).catch(() => null);
|
|
15722
|
+
const j = r ? await r.json().catch(() => null) : null;
|
|
15723
|
+
return !!j?.ok;
|
|
15724
|
+
}}
|
|
15725
|
+
// Not through a cell's door: every browser editor proposes under the
|
|
15726
|
+
// studio's one credential there, so "your own action" is not
|
|
15727
|
+
// knowable and the hub refuses the route (studio-manifest.mjs).
|
|
15728
|
+
onUndoAction={cellManaged ? undefined : async (actionId) => {
|
|
15729
|
+
const r = await fetch('/_api/project/undo', {
|
|
15730
|
+
method: 'POST',
|
|
15731
|
+
headers: { 'Content-Type': 'application/json' },
|
|
15732
|
+
body: JSON.stringify({ actionId }),
|
|
15733
|
+
}).catch(() => null);
|
|
15734
|
+
const j = r ? await r.json().catch(() => null) : null;
|
|
15735
|
+
return !!j?.ok;
|
|
15736
|
+
}}
|
|
15204
15737
|
// What the cloud half of the header names. The cell reports its own
|
|
15205
15738
|
// project and branch with the log; the hub host is the fallback,
|
|
15206
15739
|
// because a header that names the LOCAL folder while listing the
|
|
@@ -15317,8 +15850,10 @@ function App() {
|
|
|
15317
15850
|
only appears if you happen to open the Sync panel is not consent.
|
|
15318
15851
|
Skipped during first-run onboarding so two flows don't stack. */}
|
|
15319
15852
|
{!firstRun && <SyncConsentDialog status={syncStatus} cloud={cfg.cloud} />}
|
|
15320
|
-
{
|
|
15321
|
-
|
|
15853
|
+
<NotificationHost paused={!!usageNudge || !!tourSteps}
|
|
15854
|
+
hiddenGroups={exportCenter.panelOpen ? ['exports'] : []} />
|
|
15855
|
+
<WhatsNewToast wn={whatsNew} />
|
|
15856
|
+
<ExportToast center={exportCenter} />
|
|
15322
15857
|
{gitLifecycle && (
|
|
15323
15858
|
<div role="status" aria-live="polite" className="st-banner st-banner--info">
|
|
15324
15859
|
<span className="st-banner-dot" aria-hidden="true" />
|
|
@@ -15341,6 +15876,8 @@ function App() {
|
|
|
15341
15876
|
)}
|
|
15342
15877
|
<div className="st-shell">
|
|
15343
15878
|
<Menubar
|
|
15879
|
+
sharePath={sharePath}
|
|
15880
|
+
onShare={() => showShare()}
|
|
15344
15881
|
readOnly={viewerMode}
|
|
15345
15882
|
// Cloud Phase 27 C2/C4 — the ONE cloud-only input the shared chrome
|
|
15346
15883
|
// takes. A prop, not the whole `cfg`: the Menubar needs to know it is
|
|
@@ -16353,6 +16890,7 @@ function App() {
|
|
|
16353
16890
|
</div>
|
|
16354
16891
|
</div>
|
|
16355
16892
|
)}
|
|
16893
|
+
{shareDialog && <ShareDialog target={shareDialog} links={shareLinksFor(shareDialog.path)} shell={shareShell} onClose={() => setShareDialog(null)} Icon={StIcon} />}
|
|
16356
16894
|
{exportDialog && (
|
|
16357
16895
|
<ExportDialog
|
|
16358
16896
|
mode={exportDialog.mode}
|