@1agh/maude 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/apps/studio/acp/index.ts +43 -7
- package/apps/studio/annotations-layer.tsx +191 -116
- package/apps/studio/annotations-model.ts +39 -0
- package/apps/studio/annotations-sync.ts +50 -0
- package/apps/studio/api.ts +689 -36
- package/apps/studio/bin/annotate.mjs +3 -1
- package/apps/studio/bin/server-up.sh +17 -2
- package/apps/studio/canvas-edit.ts +391 -7
- package/apps/studio/canvas-lib.tsx +82 -18
- package/apps/studio/canvas-notice-message.ts +16 -0
- package/apps/studio/canvas-notifications.tsx +19 -0
- package/apps/studio/canvas-shell.tsx +57 -6
- package/apps/studio/client/app.jsx +686 -241
- package/apps/studio/client/apply-edit-request.ts +55 -0
- package/apps/studio/client/export-center.jsx +111 -61
- package/apps/studio/client/github.js +38 -1
- package/apps/studio/client/index-loader.ts +76 -0
- package/apps/studio/client/panels/CloudBar.jsx +52 -6
- package/apps/studio/client/panels/GitPanel.jsx +103 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
- package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
- package/apps/studio/client/panels/SyncPanel.jsx +113 -0
- package/apps/studio/client/panels/TeamProjects.jsx +376 -0
- package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
- package/apps/studio/client/photo-knobs.jsx +3 -0
- package/apps/studio/client/share-dialog.jsx +91 -0
- package/apps/studio/client/share-link.js +94 -0
- package/apps/studio/client/styles/3-shell-maude.css +35 -18
- package/apps/studio/client/styles/4-components.css +18 -1
- package/apps/studio/client/tree-row-menu.jsx +48 -3
- package/apps/studio/client/whats-new.jsx +26 -32
- package/apps/studio/cloud/endpoints.ts +132 -0
- package/apps/studio/collab/index.ts +4 -0
- package/apps/studio/collab/persistence.ts +5 -1
- package/apps/studio/collab/registry.ts +9 -3
- package/apps/studio/collab/room.ts +13 -2
- package/apps/studio/context.ts +59 -0
- package/apps/studio/dist/client.bundle.js +1586 -1554
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/sonner.js +1 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +289 -18
- package/apps/studio/inspect.ts +19 -0
- package/apps/studio/managed-projects.ts +150 -0
- package/apps/studio/notifications.tsx +292 -0
- package/apps/studio/runtime-bundle.ts +2 -0
- package/apps/studio/server.ts +59 -3
- package/apps/studio/sync/accepted-cold-start.ts +225 -0
- package/apps/studio/sync/accepted-link.ts +320 -0
- package/apps/studio/sync/action-stage.ts +343 -0
- package/apps/studio/sync/agent.ts +12 -60
- package/apps/studio/sync/cell-file-events.ts +3 -0
- package/apps/studio/sync/codec.ts +73 -6
- package/apps/studio/sync/ctl-provider.ts +15 -2
- package/apps/studio/sync/document-discovery.ts +81 -0
- package/apps/studio/sync/file-membership.ts +10 -2
- package/apps/studio/sync/file-plane.ts +309 -26
- package/apps/studio/sync/index.ts +1078 -19
- package/apps/studio/sync/migrate-seed.ts +110 -19
- package/apps/studio/sync/poke.ts +4 -2
- package/apps/studio/sync/presentation.ts +285 -2
- package/apps/studio/sync/projection.ts +820 -23
- package/apps/studio/sync/repeated-module.ts +73 -0
- package/apps/studio/sync/revision-barrier.ts +129 -0
- package/apps/studio/sync/seed-repair.ts +46 -0
- package/apps/studio/sync/source-merge.ts +100 -0
- package/apps/studio/sync/source-ops.ts +289 -0
- package/apps/studio/sync/source-recovery.ts +70 -0
- package/apps/studio/sync/source-validation.ts +56 -0
- package/apps/studio/sync/status.ts +79 -1
- package/apps/studio/sync/transaction-client.ts +460 -0
- package/apps/studio/sync/writer-registry.ts +236 -0
- package/apps/studio/text-caret.ts +35 -0
- package/apps/studio/undo-hud.tsx +9 -87
- package/apps/studio/use-canvas-media-drop.tsx +4 -39
- package/apps/studio/whats-new.json +101 -0
- package/cli/lib/harness/codex-runtime.mjs +4 -1
- package/package.json +9 -8
- package/plugins/design/dependencies.json +3 -3
- package/plugins/design/templates/_shell.html +47 -0
- package/plugins/flow/dependencies.json +3 -3
|
@@ -33,6 +33,8 @@ import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
|
|
|
33
33
|
// above (a type-only SyncStatusSnapshot import that Bun erases).
|
|
34
34
|
import { syncPresentation } from '../sync/presentation.ts';
|
|
35
35
|
import { canvasUrl } 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"
|
|
@@ -5620,6 +5679,11 @@ const CSS_JUSTIFY = [
|
|
|
5620
5679
|
'space-evenly',
|
|
5621
5680
|
];
|
|
5622
5681
|
const CSS_WEIGHTS = ['300', '400', '500', '600', '700', '800'];
|
|
5682
|
+
/** What an edit route says the write replaced (`previous`, read under the file
|
|
5683
|
+
* lock) — the value an undo must restore. `fallback` only when the route could
|
|
5684
|
+
* not name it (an expression, or an older server). */
|
|
5685
|
+
const replacedValue = (j, fallback) =>
|
|
5686
|
+
j && Object.hasOwn(j, 'previous') ? j.previous : fallback;
|
|
5623
5687
|
const CSS_FONTS = [
|
|
5624
5688
|
'inherit',
|
|
5625
5689
|
'system-ui',
|
|
@@ -6512,12 +6576,16 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
6512
6576
|
// no longer triggers a reselect that would re-post fresh `authored` values — so
|
|
6513
6577
|
// the panel must reflect its own commits immediately or it shows the stale
|
|
6514
6578
|
// 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
|
|
6579
|
+
// `null` marks a removed key. Cleared when a different element is selected —
|
|
6580
|
+
// and whenever the canvas re-posts this selection's maps. A re-post carries
|
|
6581
|
+
// the source as it is NOW (a teammate's change reloads the canvas); keeping
|
|
6582
|
+
// our older commit over it showed a stale value and recorded it as the next
|
|
6583
|
+
// undo's `before`, so Cmd+Z restored a value nobody had on screen.
|
|
6516
6584
|
const [overlay, setOverlay] = useState({ a: {}, c: {}, t: {} });
|
|
6517
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: clear
|
|
6585
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: clear on a fresh selection payload only.
|
|
6518
6586
|
useEffect(() => {
|
|
6519
6587
|
setOverlay({ a: {}, c: {}, t: {} });
|
|
6520
|
-
}, [el.id]);
|
|
6588
|
+
}, [el.id, el.authored, el.customStyles, el.attrs]);
|
|
6521
6589
|
const mergeOverlay = (base, ov) => {
|
|
6522
6590
|
const out = { ...(base || {}) };
|
|
6523
6591
|
for (const [k, v] of Object.entries(ov)) {
|
|
@@ -6602,10 +6670,27 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
6602
6670
|
...s,
|
|
6603
6671
|
[key]: !res.ok || !j.ok ? `err:${(j && j.error) || `HTTP ${res.status}`}` : 'saved',
|
|
6604
6672
|
}));
|
|
6673
|
+
return res.ok ? j : null;
|
|
6605
6674
|
} catch (err) {
|
|
6606
6675
|
setStatus((s) => ({ ...s, [key]: `err:${err && err.message ? err.message : String(err)}` }));
|
|
6676
|
+
return null;
|
|
6607
6677
|
}
|
|
6608
6678
|
}
|
|
6679
|
+
// Write, THEN record the undo entry from what the write actually replaced
|
|
6680
|
+
// (`previous`, read server-side under the file lock). The panel's own value
|
|
6681
|
+
// can be a teammate's edit old — it is not re-posted when a peer changes the
|
|
6682
|
+
// source — and recording it made Cmd+Z restore a value nobody had on screen.
|
|
6683
|
+
// Serialized so the stack keeps edit order; a failed write records nothing.
|
|
6684
|
+
const writeChainRef = useRef(Promise.resolve());
|
|
6685
|
+
const writeAndRecord = (url, payload, key, op, prop, fallbackBefore, after) => {
|
|
6686
|
+
writeChainRef.current = writeChainRef.current.then(async () => {
|
|
6687
|
+
const j = await post(url, payload, key);
|
|
6688
|
+
if (!j?.ok) return;
|
|
6689
|
+
const before = Object.hasOwn(j, 'previous') ? j.previous : fallbackBefore;
|
|
6690
|
+
if ((before ?? null) === (after ?? null)) return; // nothing changed to undo
|
|
6691
|
+
record(op, prop, before, after);
|
|
6692
|
+
});
|
|
6693
|
+
};
|
|
6609
6694
|
// Optimistic preview: nudge the live element so the change shows before the
|
|
6610
6695
|
// edit → HMR reload lands. `value` null = remove (reset path). No-op when the
|
|
6611
6696
|
// selection has no stable id (can't be resolved in the canvas).
|
|
@@ -6639,8 +6724,15 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
6639
6724
|
if (value === (before ?? '').trim()) return; // no-op
|
|
6640
6725
|
optimistic(property, value);
|
|
6641
6726
|
setA(property, value); // reflect in the panel immediately (no reload → no reselect)
|
|
6642
|
-
|
|
6643
|
-
|
|
6727
|
+
writeAndRecord(
|
|
6728
|
+
'/_api/edit-css',
|
|
6729
|
+
{ canvas: el.file, id: el.id, property, value },
|
|
6730
|
+
property,
|
|
6731
|
+
'css',
|
|
6732
|
+
property,
|
|
6733
|
+
before,
|
|
6734
|
+
value
|
|
6735
|
+
);
|
|
6644
6736
|
};
|
|
6645
6737
|
// A custom CSS property (Advanced) — same write, but the panel surfaces it from
|
|
6646
6738
|
// the customStyles map, so overlay THERE.
|
|
@@ -6651,8 +6743,15 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
6651
6743
|
const before = customStyles[prop] ?? null;
|
|
6652
6744
|
optimistic(prop, value);
|
|
6653
6745
|
setC(prop, value);
|
|
6654
|
-
|
|
6655
|
-
|
|
6746
|
+
writeAndRecord(
|
|
6747
|
+
'/_api/edit-css',
|
|
6748
|
+
{ canvas: el.file, id: el.id, property: prop, value },
|
|
6749
|
+
prop,
|
|
6750
|
+
'css',
|
|
6751
|
+
prop,
|
|
6752
|
+
before,
|
|
6753
|
+
value
|
|
6754
|
+
);
|
|
6656
6755
|
};
|
|
6657
6756
|
const commitAttr = (attr, raw) => {
|
|
6658
6757
|
const a = (attr || '').trim();
|
|
@@ -6660,8 +6759,15 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
6660
6759
|
if (!editable || !a || !value) return;
|
|
6661
6760
|
const before = attrs[a] ?? null;
|
|
6662
6761
|
setT(a, value);
|
|
6663
|
-
|
|
6664
|
-
|
|
6762
|
+
writeAndRecord(
|
|
6763
|
+
'/_api/edit-attr',
|
|
6764
|
+
{ canvas: el.file, id: el.id, attr: a, value },
|
|
6765
|
+
`@${a}`,
|
|
6766
|
+
'attr',
|
|
6767
|
+
a,
|
|
6768
|
+
before,
|
|
6769
|
+
value
|
|
6770
|
+
);
|
|
6665
6771
|
};
|
|
6666
6772
|
// Phase 12.3 — reset (remove the inline prop / attr → back to class/inherited).
|
|
6667
6773
|
const reset = (property) => {
|
|
@@ -6669,23 +6775,44 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
|
|
|
6669
6775
|
const before = authored[property] ?? null;
|
|
6670
6776
|
optimistic(property, null);
|
|
6671
6777
|
setA(property, null);
|
|
6672
|
-
|
|
6673
|
-
|
|
6778
|
+
writeAndRecord(
|
|
6779
|
+
'/_api/edit-css',
|
|
6780
|
+
{ canvas: el.file, id: el.id, property, reset: true },
|
|
6781
|
+
property,
|
|
6782
|
+
'css',
|
|
6783
|
+
property,
|
|
6784
|
+
before,
|
|
6785
|
+
null
|
|
6786
|
+
);
|
|
6674
6787
|
};
|
|
6675
6788
|
const resetCustom = (property) => {
|
|
6676
6789
|
if (!editable) return;
|
|
6677
6790
|
const before = customStyles[property] ?? null;
|
|
6678
6791
|
optimistic(property, null);
|
|
6679
6792
|
setC(property, null);
|
|
6680
|
-
|
|
6681
|
-
|
|
6793
|
+
writeAndRecord(
|
|
6794
|
+
'/_api/edit-css',
|
|
6795
|
+
{ canvas: el.file, id: el.id, property, reset: true },
|
|
6796
|
+
property,
|
|
6797
|
+
'css',
|
|
6798
|
+
property,
|
|
6799
|
+
before,
|
|
6800
|
+
null
|
|
6801
|
+
);
|
|
6682
6802
|
};
|
|
6683
6803
|
const resetAttr = (attr) => {
|
|
6684
6804
|
if (!editable) return;
|
|
6685
6805
|
const before = attrs[attr] ?? null;
|
|
6686
6806
|
setT(attr, null);
|
|
6687
|
-
|
|
6688
|
-
|
|
6807
|
+
writeAndRecord(
|
|
6808
|
+
'/_api/edit-attr',
|
|
6809
|
+
{ canvas: el.file, id: el.id, attr, reset: true },
|
|
6810
|
+
`@${attr}`,
|
|
6811
|
+
'attr',
|
|
6812
|
+
attr,
|
|
6813
|
+
before,
|
|
6814
|
+
null
|
|
6815
|
+
);
|
|
6689
6816
|
};
|
|
6690
6817
|
// Stage M1 — apply a Fixed / Hug / Fill sizing mode to one axis. The pure
|
|
6691
6818
|
// `sizingModePatch` returns the exact writes (context-aware Fill: flex main axis
|
|
@@ -9714,6 +9841,9 @@ function InspectorPanel({
|
|
|
9714
9841
|
|
|
9715
9842
|
function App() {
|
|
9716
9843
|
const [groups, setGroups] = useState([]);
|
|
9844
|
+
const [treeLoaded, setTreeLoaded] = useState(false);
|
|
9845
|
+
const addressMode = useRef('push');
|
|
9846
|
+
const previousAddressPath = useRef(null);
|
|
9717
9847
|
const [project, setProject] = useState('Design');
|
|
9718
9848
|
const [tabs, setTabs] = useState([]);
|
|
9719
9849
|
const [activePath, setActivePath] = useState(null);
|
|
@@ -9723,6 +9853,7 @@ function App() {
|
|
|
9723
9853
|
// state, or iframe registration that openTab()/openSystem() drive.
|
|
9724
9854
|
const [previewPath, setPreviewPath] = useState(null);
|
|
9725
9855
|
const onPreview = useCallback((path) => {
|
|
9856
|
+
addressMode.current = 'push';
|
|
9726
9857
|
setPreviewPath(path);
|
|
9727
9858
|
}, []);
|
|
9728
9859
|
const [selected, setSelected] = useState(null);
|
|
@@ -9829,6 +9960,30 @@ function App() {
|
|
|
9829
9960
|
// after it) renumbers. Stash the re-settle target { file, movedId, artboardId }
|
|
9830
9961
|
// so the dgn:'loaded' handler re-selects the moved element by its NEW id.
|
|
9831
9962
|
const pendingReorderRef = useRef(null);
|
|
9963
|
+
// The last layers tree the canvas posted, and its id signature. A duplicate
|
|
9964
|
+
// or insert renumbers every later positional id, so the copy's NEW id already
|
|
9965
|
+
// names another element (the next sibling) in the pre-write tree: settling
|
|
9966
|
+
// on the first tree that merely contains it selected the wrong node. The
|
|
9967
|
+
// pending entry records the signature at request time and waits for a tree
|
|
9968
|
+
// that differs from it.
|
|
9969
|
+
const lastLayersTreeRef = useRef(null);
|
|
9970
|
+
const settlePendingSelectionRef = useRef(null);
|
|
9971
|
+
settlePendingSelectionRef.current = (tree, sig, artboardId) => {
|
|
9972
|
+
const pend = pendingReorderRef.current;
|
|
9973
|
+
if (!pend || !pend.movedId) return;
|
|
9974
|
+
if (pend.staleSig != null && sig === pend.staleSig) return; // still the pre-write DOM
|
|
9975
|
+
const has = (function find(nodes) {
|
|
9976
|
+
return (nodes || []).some((n) => n.id === pend.movedId || find(n.children));
|
|
9977
|
+
})(tree);
|
|
9978
|
+
if (!has) return;
|
|
9979
|
+
pendingReorderRef.current = null;
|
|
9980
|
+
const win = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
9981
|
+
if (win) {
|
|
9982
|
+
try {
|
|
9983
|
+
win.postMessage({ dgn: 'select-by-id', id: pend.movedId, artboardId, index: 0 }, '*');
|
|
9984
|
+
} catch {}
|
|
9985
|
+
}
|
|
9986
|
+
};
|
|
9832
9987
|
// Latest `reorderLayer` (defined far below), read from the stale-closure
|
|
9833
9988
|
// onMessage handler when the in-canvas grip posts dgn:'reorder-request'
|
|
9834
9989
|
// (same freshness idiom as selectedRef; avoids a render-time TDZ on the
|
|
@@ -9924,6 +10079,7 @@ function App() {
|
|
|
9924
10079
|
// CloudBar (status resolve / attach / detach). Gates the GitPanel's
|
|
9925
10080
|
// cloud-managed posture; linked-but-uncredentialed keeps the full panel.
|
|
9926
10081
|
const [cloudLinkedHub, setCloudLinkedHub] = useState(null);
|
|
10082
|
+
const [localProjectName, setLocalProjectName] = useState(null);
|
|
9927
10083
|
// Phase 27 (E2) — in-UI git layer. `gitStatus` is the live dirty-state the
|
|
9928
10084
|
// server broadcasts on `git-status`; `changesOpen` toggles the Changes panel;
|
|
9929
10085
|
// `diffTarget` opens the before/after DiffView ({ file, conflict }).
|
|
@@ -10247,6 +10403,7 @@ function App() {
|
|
|
10247
10403
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
|
10248
10404
|
// T5/T6 (Plan C) — shell-level export/handoff dialog + inspector panel state.
|
|
10249
10405
|
// The palette (T4) drives them; the dialog (T5) + panel (T6) consume them.
|
|
10406
|
+
const [shareDialog, setShareDialog] = useState(null);
|
|
10250
10407
|
const [exportDialog, setExportDialog] = useState(null); // null | { mode: 'export'|'handoff', scope? }
|
|
10251
10408
|
// feature-ai-media-generation (DDR-16x) — BYOK provider-key Settings modal +
|
|
10252
10409
|
// the AI generate action.
|
|
@@ -11589,37 +11746,47 @@ function App() {
|
|
|
11589
11746
|
}, []);
|
|
11590
11747
|
|
|
11591
11748
|
// ----- 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
|
-
|
|
11749
|
+
const treeLoaderRef = useRef(null);
|
|
11750
|
+
const loadTree = useCallback(() => treeLoaderRef.current?.reload() ?? Promise.resolve(), []);
|
|
11621
11751
|
useEffect(() => {
|
|
11752
|
+
const loader = createIndexLoader({
|
|
11753
|
+
read: async (signal) => {
|
|
11754
|
+
const r = await fetch('/_index-data', { signal, cache: 'no-store' });
|
|
11755
|
+
if (!r.ok) throw Object.assign(new Error(`Project index request failed: ${r.status}`), { status: r.status });
|
|
11756
|
+
const data = await r.json();
|
|
11757
|
+
const built = data.groups.map((g) => ({
|
|
11758
|
+
...g,
|
|
11759
|
+
tree: buildTree(g.paths, g.stripPrefix, g.dirs),
|
|
11760
|
+
}));
|
|
11761
|
+
return { data, built };
|
|
11762
|
+
},
|
|
11763
|
+
apply: ({ data, built }) => {
|
|
11764
|
+
setProject(data.project || 'Design');
|
|
11765
|
+
setGroups(built);
|
|
11766
|
+
setTreeLoaded(true);
|
|
11767
|
+
// DDR-093 — fold the server-resolved per-canvas DS map into cfg so
|
|
11768
|
+
// canvasUrl() injects each UI canvas's OWN design-system tokens instead of
|
|
11769
|
+
// always designSystems[0]. Functional merge to coexist with the /_config
|
|
11770
|
+
// fetch (either may land first). `?? {}` keeps older servers (no map) on
|
|
11771
|
+
// the ds0 fallback. Re-runs on every tree reload, so adding/retargeting a
|
|
11772
|
+
// canvas refreshes the map.
|
|
11773
|
+
setCfg((prev) => ({
|
|
11774
|
+
...prev,
|
|
11775
|
+
canvasDesignSystems: data.canvasDesignSystems ?? {},
|
|
11776
|
+
// DDR-174 (T15) — per-canvas notable `.meta.json` `kind` values (today:
|
|
11777
|
+
// only `reconstructed-experimental`), folded in the same way + for the
|
|
11778
|
+
// same reason as canvasDesignSystems above.
|
|
11779
|
+
canvasKinds: data.canvasKinds ?? {},
|
|
11780
|
+
}));
|
|
11781
|
+
},
|
|
11782
|
+
onError: (error) => console.error('failed to load tree', error),
|
|
11783
|
+
});
|
|
11784
|
+
treeLoaderRef.current = loader;
|
|
11622
11785
|
loadTree();
|
|
11786
|
+
return () => {
|
|
11787
|
+
treeLoaderRef.current = null;
|
|
11788
|
+
loader.dispose();
|
|
11789
|
+
};
|
|
11623
11790
|
}, [loadTree]);
|
|
11624
11791
|
|
|
11625
11792
|
// ----- System data (lazy) -----
|
|
@@ -11668,6 +11835,7 @@ function App() {
|
|
|
11668
11835
|
}, [loadAllComments]);
|
|
11669
11836
|
|
|
11670
11837
|
// ----- WebSocket -----
|
|
11838
|
+
const canvasListChangeRef = useRef(() => {});
|
|
11671
11839
|
useEffect(() => {
|
|
11672
11840
|
// KEEPALIVE. The inspector feed only pushes on events (a comment, a
|
|
11673
11841
|
// selection, sync:status), so an idle designer's socket exchanges nothing
|
|
@@ -11779,26 +11947,8 @@ function App() {
|
|
|
11779
11947
|
// Phase 9 Task 8 — hub connection state for the offline banner.
|
|
11780
11948
|
setSyncStatus(m.payload);
|
|
11781
11949
|
} 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
11950
|
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
|
-
}
|
|
11951
|
+
canvasListChangeRef.current(m.payload);
|
|
11802
11952
|
} else if (m.type === 'config-updated') {
|
|
11803
11953
|
// Server hot-reloaded .design/config.json (/design:setup-ds rewrote
|
|
11804
11954
|
// it) — refetch /_config so designSystems / tokensCssRel / groups
|
|
@@ -12001,6 +12151,39 @@ function App() {
|
|
|
12001
12151
|
}
|
|
12002
12152
|
}, []);
|
|
12003
12153
|
|
|
12154
|
+
// ACCEPTED REVISIONS — the project's own history (DDR-241, T27). When the
|
|
12155
|
+
// project saves through accepted revisions the History tab lists logical
|
|
12156
|
+
// actions (who did what, when), not Git commits; a row previews as `r<rev>`
|
|
12157
|
+
// through the same version preview. `'legacy'` means "use Git history".
|
|
12158
|
+
const [projectHistoryOn, setProjectHistoryOn] = useState(false);
|
|
12159
|
+
const loadAcceptedLog = useCallback(async (path) => {
|
|
12160
|
+
try {
|
|
12161
|
+
const qs =
|
|
12162
|
+
'/_api/project/history?limit=40' + (path ? `&path=${encodeURIComponent(path)}` : '');
|
|
12163
|
+
const r = await fetch(qs);
|
|
12164
|
+
if (!r.ok) return 'legacy';
|
|
12165
|
+
const data = await r.json();
|
|
12166
|
+
if (!data?.ok) return data?.reason === 'legacy' ? 'legacy' : null;
|
|
12167
|
+
return (data.history || []).map((a) => {
|
|
12168
|
+
const where = path ? '' : (a.canvases || []).map((c) => c.split('/').pop()).join(', ');
|
|
12169
|
+
return {
|
|
12170
|
+
sha: `r${a.revision}`,
|
|
12171
|
+
message: [a.label || a.kind, where].filter(Boolean).join(' · '),
|
|
12172
|
+
author: a.actor,
|
|
12173
|
+
date: new Date(a.committedAt).toISOString(),
|
|
12174
|
+
accepted: {
|
|
12175
|
+
revision: a.revision,
|
|
12176
|
+
actionId: a.actionId,
|
|
12177
|
+
mine: !!a.mine,
|
|
12178
|
+
undo: a.kind === 'undo' || a.kind === 'redo',
|
|
12179
|
+
},
|
|
12180
|
+
};
|
|
12181
|
+
});
|
|
12182
|
+
} catch {
|
|
12183
|
+
return null;
|
|
12184
|
+
}
|
|
12185
|
+
}, []);
|
|
12186
|
+
|
|
12004
12187
|
// Repo-relative path → M/A/D/U badge for the tree (paths match: both the tree
|
|
12005
12188
|
// and gitStatus use `.design/ui/Foo.tsx`). Keyed off gitStatus so it updates
|
|
12006
12189
|
// live with the WS broadcast.
|
|
@@ -12044,20 +12227,85 @@ function App() {
|
|
|
12044
12227
|
// (iframesRef, comments push, WS `tabs` message) doesn't need refactoring.
|
|
12045
12228
|
// ARTBOARDS slot in the menubar reads `tabs.length` and reports 0 or 1.
|
|
12046
12229
|
const openTab = useCallback((path) => {
|
|
12230
|
+
addressMode.current = 'push';
|
|
12231
|
+
setFocusedCommentId(null);
|
|
12232
|
+
setPreviewPath(null);
|
|
12233
|
+
// The same path keeps its mounted iframe. It will not emit a new loaded
|
|
12234
|
+
// event, so keep its current loading/error state until an actual retry.
|
|
12235
|
+
if (path === activePath) return;
|
|
12047
12236
|
setTabs((prev) => {
|
|
12048
12237
|
// Drop the previously-open iframe so we don't leak DOM nodes.
|
|
12049
12238
|
for (const t of prev) if (t.path !== path) iframesRef.current.delete(t.path);
|
|
12050
12239
|
return [{ path }];
|
|
12051
12240
|
});
|
|
12052
12241
|
setActivePath(path);
|
|
12053
|
-
setFocusedCommentId(null);
|
|
12054
|
-
setPreviewPath(null);
|
|
12055
12242
|
setCanvasError(null);
|
|
12056
12243
|
setLoadedPath(null);
|
|
12057
12244
|
// Canvas-compile skeleton — cleared by the iframe's dgn:'loaded' message,
|
|
12058
12245
|
// the onLoad fallback timer (legacy .html), or a hard 15s cap.
|
|
12059
12246
|
if (path !== SYSTEM_TAB) setLoadingPath(path);
|
|
12060
|
-
}, []);
|
|
12247
|
+
}, [activePath]);
|
|
12248
|
+
|
|
12249
|
+
// Resolve URL identities against the loaded tree, including non-canvas previews.
|
|
12250
|
+
const openLinkedFile = useCallback((rel, mode = 'push') => {
|
|
12251
|
+
const path = groups.flatMap((g) => g.paths || []).find((p) => normalizeOpenPath(p, cfg.designRel) === rel);
|
|
12252
|
+
if (path && CANVAS_EXT_RE.test(path)) openTab(path);
|
|
12253
|
+
else if (path && previewKind(basename(path))) onPreview(path);
|
|
12254
|
+
else {
|
|
12255
|
+
setTabs([]);
|
|
12256
|
+
setActivePath(null);
|
|
12257
|
+
setPreviewPath(null);
|
|
12258
|
+
notify({ title: 'Not here yet', description: `${rel} is not in this project (not synced yet?)`, kind: 'info' });
|
|
12259
|
+
}
|
|
12260
|
+
addressMode.current = mode;
|
|
12261
|
+
}, [groups, cfg.designRel, openTab, onPreview]);
|
|
12262
|
+
|
|
12263
|
+
const addressBooted = useRef(false);
|
|
12264
|
+
useEffect(() => {
|
|
12265
|
+
if (!treeLoaded || cfg.cloud === undefined || addressBooted.current) return;
|
|
12266
|
+
addressBooted.current = true;
|
|
12267
|
+
const rel = readOpenParam(location, cfg.designRel);
|
|
12268
|
+
if (rel) openLinkedFile(rel, 'none');
|
|
12269
|
+
}, [treeLoaded, cfg.cloud, cfg.designRel, openLinkedFile]);
|
|
12270
|
+
|
|
12271
|
+
useEffect(() => {
|
|
12272
|
+
if (!treeLoaded || cfg.cloud === undefined) return;
|
|
12273
|
+
const navigate = () => {
|
|
12274
|
+
const rel = readOpenParam(location, cfg.designRel);
|
|
12275
|
+
if (rel) openLinkedFile(rel, 'none');
|
|
12276
|
+
else {
|
|
12277
|
+
addressMode.current = 'none';
|
|
12278
|
+
setTabs([]);
|
|
12279
|
+
setActivePath(null);
|
|
12280
|
+
setPreviewPath(null);
|
|
12281
|
+
}
|
|
12282
|
+
};
|
|
12283
|
+
window.addEventListener('popstate', navigate);
|
|
12284
|
+
return () => window.removeEventListener('popstate', navigate);
|
|
12285
|
+
}, [treeLoaded, cfg.cloud, cfg.designRel, openLinkedFile]);
|
|
12286
|
+
|
|
12287
|
+
useEffect(() => {
|
|
12288
|
+
const visible = previewPath || (activePath === SYSTEM_TAB ? null : activePath);
|
|
12289
|
+
if (visible === previousAddressPath.current) return;
|
|
12290
|
+
previousAddressPath.current = visible;
|
|
12291
|
+
const rel = normalizeOpenPath(visible, cfg.designRel);
|
|
12292
|
+
if (addressMode.current !== 'none' && readOpenParam(location, cfg.designRel) !== rel) {
|
|
12293
|
+
history[rel ? 'pushState' : 'replaceState'](rel ? { open: rel } : null, '', withOpenParam(location, rel));
|
|
12294
|
+
}
|
|
12295
|
+
addressMode.current = 'push';
|
|
12296
|
+
}, [activePath, previewPath, cfg.designRel]);
|
|
12297
|
+
|
|
12298
|
+
const sharePath = previewPath || (activePath === SYSTEM_TAB ? null : activePath);
|
|
12299
|
+
const shareShell = cfg.cloud ? 'cloud' : isNativeApp() ? 'native' : 'local';
|
|
12300
|
+
const shareLinksFor = (path) => buildShareLinks({
|
|
12301
|
+
rel: normalizeOpenPath(path, cfg.designRel), shell: shareShell, location,
|
|
12302
|
+
linkedHubUrl: cloudLinkedHub?.url, project: cfg.cloud ? project : localProjectName,
|
|
12303
|
+
localUrl: location.origin,
|
|
12304
|
+
});
|
|
12305
|
+
const showShare = (path = sharePath) => {
|
|
12306
|
+
const rel = normalizeOpenPath(path, cfg.designRel);
|
|
12307
|
+
if (rel) setShareDialog({ path, rel, label: basename(path) });
|
|
12308
|
+
};
|
|
12061
12309
|
|
|
12062
12310
|
// Retry from the #115 error panel: re-read /_config FIRST (the stale
|
|
12063
12311
|
// `canvasOrigin` is the likeliest reason we are here at all), then remount the
|
|
@@ -12089,6 +12337,7 @@ function App() {
|
|
|
12089
12337
|
useEffect(() => {
|
|
12090
12338
|
if (autoOpened.current) return;
|
|
12091
12339
|
if (!cfg.cloud) return; // desktop / unknown-yet
|
|
12340
|
+
if (new URLSearchParams(location.search).has('open')) { autoOpened.current = true; return; }
|
|
12092
12341
|
if (tabs.length > 0) return;
|
|
12093
12342
|
if (!groups.length) return; // tree not loaded
|
|
12094
12343
|
// The first canvas that is somebody's WORK — a design-system specimen is a
|
|
@@ -12149,6 +12398,23 @@ function App() {
|
|
|
12149
12398
|
[activePath]
|
|
12150
12399
|
);
|
|
12151
12400
|
|
|
12401
|
+
// The socket stays connected while this ref follows the current config and
|
|
12402
|
+
// active tab. Structural events apply to local and remote users alike.
|
|
12403
|
+
canvasListChangeRef.current = (change) => {
|
|
12404
|
+
if (!change?.rel) return;
|
|
12405
|
+
const designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '');
|
|
12406
|
+
const file = `${designRel}/${change.rel}`;
|
|
12407
|
+
if (change.action === 'removed') {
|
|
12408
|
+
closeTab(file);
|
|
12409
|
+
} else if (change.action === 'moved' && change.fromRel) {
|
|
12410
|
+
const fromFile = `${designRel}/${change.fromRel}`;
|
|
12411
|
+
setTabs((prev) => prev.map((t) => t.path === fromFile ? { ...t, path: file } : t));
|
|
12412
|
+
setActivePath((prev) => prev === fromFile ? file : prev);
|
|
12413
|
+
setLoadingPath((prev) => prev === fromFile ? null : prev);
|
|
12414
|
+
iframesRef.current.delete(fromFile);
|
|
12415
|
+
}
|
|
12416
|
+
};
|
|
12417
|
+
|
|
12152
12418
|
const reloadActive = useCallback(() => {
|
|
12153
12419
|
if (!activePath || activePath === SYSTEM_TAB) {
|
|
12154
12420
|
if (activePath === SYSTEM_TAB) loadSystemData();
|
|
@@ -12533,6 +12799,100 @@ function App() {
|
|
|
12533
12799
|
[loadTree]
|
|
12534
12800
|
);
|
|
12535
12801
|
|
|
12802
|
+
// Plan T17 — rename a folder: a move to a new name in the same parent (one
|
|
12803
|
+
// dir.move action in accepted mode, carrying every canvas inside).
|
|
12804
|
+
const renameFolderReq = useCallback(
|
|
12805
|
+
async (dirPath, name) => {
|
|
12806
|
+
const parent = dirPath.split('/').slice(0, -1).join('/');
|
|
12807
|
+
try {
|
|
12808
|
+
const r = await fetch('/_api/fs-move', {
|
|
12809
|
+
method: 'POST',
|
|
12810
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12811
|
+
body: JSON.stringify({ file: dirPath, toDir: parent, toName: name }),
|
|
12812
|
+
});
|
|
12813
|
+
const j = await r.json().catch(() => ({}));
|
|
12814
|
+
if (!r.ok || !j.ok) {
|
|
12815
|
+
shellToast(`Could not rename folder: ${j.error || `error ${r.status}`}`);
|
|
12816
|
+
return;
|
|
12817
|
+
}
|
|
12818
|
+
await loadTree();
|
|
12819
|
+
} catch (e) {
|
|
12820
|
+
shellToast(`Rename failed: ${e instanceof Error ? e.message : 'network error'}`);
|
|
12821
|
+
}
|
|
12822
|
+
},
|
|
12823
|
+
[loadTree]
|
|
12824
|
+
);
|
|
12825
|
+
|
|
12826
|
+
// Plan T25/L04 — rename a canvas in place (its sidecars follow; an open tab
|
|
12827
|
+
// follows the new path) and duplicate one beside itself.
|
|
12828
|
+
const renameCanvasReq = useCallback(
|
|
12829
|
+
async (filePath, name) => {
|
|
12830
|
+
try {
|
|
12831
|
+
const r = await fetch('/_api/fs-move', {
|
|
12832
|
+
method: 'POST',
|
|
12833
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12834
|
+
body: JSON.stringify({ file: filePath, toName: name }),
|
|
12835
|
+
});
|
|
12836
|
+
const j = await r.json().catch(() => ({}));
|
|
12837
|
+
if (!r.ok || !j.ok) {
|
|
12838
|
+
shellToast(`Could not rename: ${j.error || `error ${r.status}`}`);
|
|
12839
|
+
return;
|
|
12840
|
+
}
|
|
12841
|
+
const designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(
|
|
12842
|
+
/^\/+|\/+$/g,
|
|
12843
|
+
''
|
|
12844
|
+
);
|
|
12845
|
+
const fromFile = `${designRel}/${j.fromRel}`;
|
|
12846
|
+
const toFile = `${designRel}/${j.toRel}`;
|
|
12847
|
+
await loadTree();
|
|
12848
|
+
setTabs((prev) => prev.map((t) => (t.path === fromFile ? { path: toFile } : t)));
|
|
12849
|
+
setActivePath((prev) => (prev === fromFile ? toFile : prev));
|
|
12850
|
+
} catch (e) {
|
|
12851
|
+
shellToast(`Rename failed: ${e instanceof Error ? e.message : 'network error'}`);
|
|
12852
|
+
}
|
|
12853
|
+
},
|
|
12854
|
+
[loadTree, cfg]
|
|
12855
|
+
);
|
|
12856
|
+
const duplicateCanvasReq = useCallback(
|
|
12857
|
+
async (filePath) => {
|
|
12858
|
+
try {
|
|
12859
|
+
const r = await fetch('/_api/canvas', {
|
|
12860
|
+
method: 'POST',
|
|
12861
|
+
headers: { 'Content-Type': 'application/json' },
|
|
12862
|
+
body: JSON.stringify({ duplicateOf: filePath }),
|
|
12863
|
+
});
|
|
12864
|
+
const j = await r.json().catch(() => ({}));
|
|
12865
|
+
if (!r.ok || !j.ok) {
|
|
12866
|
+
shellToast(`Could not duplicate: ${j.error || `error ${r.status}`}`);
|
|
12867
|
+
return;
|
|
12868
|
+
}
|
|
12869
|
+
await loadTree();
|
|
12870
|
+
shellToast(`Duplicated as ${j.rel.split('/').pop().replace(/\.tsx$/i, '')}`, true);
|
|
12871
|
+
} catch (e) {
|
|
12872
|
+
shellToast(`Duplicate failed: ${e instanceof Error ? e.message : 'network error'}`);
|
|
12873
|
+
}
|
|
12874
|
+
},
|
|
12875
|
+
[loadTree]
|
|
12876
|
+
);
|
|
12877
|
+
|
|
12878
|
+
const deleteFileReq = useCallback(
|
|
12879
|
+
async (filePath, name) => {
|
|
12880
|
+
if (!window.confirm(`Move “${name}” to trash?\n\nYou can restore it from .design/_trash/.`)) return;
|
|
12881
|
+
try {
|
|
12882
|
+
const r = await fetch(`/_api/canvas?file=${encodeURIComponent(filePath)}`, { method: 'DELETE' });
|
|
12883
|
+
const j = await r.json().catch(() => ({}));
|
|
12884
|
+
if (!r.ok || !j.ok) {
|
|
12885
|
+
shellToast(`Could not delete: ${j.error || `error ${r.status}`}`);
|
|
12886
|
+
return;
|
|
12887
|
+
}
|
|
12888
|
+
await loadTree();
|
|
12889
|
+
} catch (e) {
|
|
12890
|
+
shellToast(`Delete failed: ${e instanceof Error ? e.message : 'network error'}`);
|
|
12891
|
+
}
|
|
12892
|
+
},
|
|
12893
|
+
[loadTree]
|
|
12894
|
+
);
|
|
12895
|
+
|
|
12536
12896
|
const clearSelected = useCallback(() => {
|
|
12537
12897
|
wsSend({ type: 'clear-select' });
|
|
12538
12898
|
setSelected(null);
|
|
@@ -12582,6 +12942,13 @@ function App() {
|
|
|
12582
12942
|
if (e.origin !== expectedOrigin) return;
|
|
12583
12943
|
const m = e.data;
|
|
12584
12944
|
if (!m || typeof m !== 'object' || !m.dgn) return;
|
|
12945
|
+
if (m.dgn === 'canvas-notice') {
|
|
12946
|
+
const activeWin = activePath && activePath !== SYSTEM_TAB
|
|
12947
|
+
? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
12948
|
+
const notice = acceptCanvasNotice(e, expectedOrigin, activeWin);
|
|
12949
|
+
if (notice) notifyCanvasText(notice.title, notice.kind);
|
|
12950
|
+
return;
|
|
12951
|
+
}
|
|
12585
12952
|
if (m.dgn === 'tool-cursor') {
|
|
12586
12953
|
// Phase 24 — show the active canvas tool's cursor across the WHOLE app
|
|
12587
12954
|
// shell (sidebar, top bar, everything) so the custom cursor is visible
|
|
@@ -12723,55 +13090,48 @@ function App() {
|
|
|
12723
13090
|
// Inline-edit undo/redo (DDR-103/104 follow-up). The canvas iframe's
|
|
12724
13091
|
// `edit-source` command can't call the main-origin-only `/_api/edit-*`
|
|
12725
13092
|
// 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
|
-
|
|
13093
|
+
// `value` null = reset (remove the inline prop / attr). The css/attr
|
|
13094
|
+
// request carries the command's expected current value, so an undo can
|
|
13095
|
+
// never overwrite a teammate's newer value (audit 2026-09-13 P1 #5);
|
|
13096
|
+
// the outcome goes back to the canvas so a refused undo/redo does not
|
|
13097
|
+
// advance its stack.
|
|
13098
|
+
const req = applyEditRequest(m);
|
|
13099
|
+
if (!req) return;
|
|
13100
|
+
const replyTo = e.source;
|
|
13101
|
+
const reply = (result) => {
|
|
13102
|
+
if (typeof m.requestId !== 'string') return;
|
|
13103
|
+
try {
|
|
13104
|
+
replyTo?.postMessage({ dgn: 'apply-edit-result', requestId: m.requestId, ...result }, '*');
|
|
13105
|
+
} catch {}
|
|
13106
|
+
};
|
|
12729
13107
|
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
13108
|
editApplyChainRef.current = editApplyChainRef.current
|
|
12755
13109
|
.catch(() => {})
|
|
12756
13110
|
.then(() =>
|
|
12757
|
-
fetch(url, {
|
|
13111
|
+
fetch(req.url, {
|
|
12758
13112
|
method: 'POST',
|
|
12759
13113
|
headers: { 'content-type': 'application/json' },
|
|
12760
|
-
body: JSON.stringify(body),
|
|
13114
|
+
body: JSON.stringify(req.body),
|
|
12761
13115
|
})
|
|
12762
13116
|
.then((r) => r.json().catch(() => ({})))
|
|
12763
13117
|
.then((j) => {
|
|
12764
|
-
|
|
12765
|
-
|
|
12766
|
-
|
|
12767
|
-
|
|
12768
|
-
|
|
13118
|
+
if (j.ok) {
|
|
13119
|
+
// Repaint only once the source accepted it: a refused undo
|
|
13120
|
+
// must not show a value the file does not hold.
|
|
13121
|
+
if (req.op === 'css') applyOptimisticStyle({ id: m.id, prop: m.key, value });
|
|
13122
|
+
reply({ ok: true });
|
|
13123
|
+
return;
|
|
13124
|
+
}
|
|
13125
|
+
console.warn('[apply-edit]', req.op, j.error || 'failed');
|
|
13126
|
+
reply({ ok: false, error: j.error || 'failed', conflict: j.conflict === true });
|
|
12769
13127
|
})
|
|
12770
|
-
.catch(() => {})
|
|
13128
|
+
.catch(() => reply({ ok: false, error: 'network error' }))
|
|
12771
13129
|
);
|
|
12772
13130
|
} else if (m.dgn === 'layers-tree') {
|
|
12773
13131
|
// Phase 12 Task 4 — browsable layers tree for the active artboard.
|
|
12774
13132
|
setLayersTree({ artboardId: m.artboardId, nodes: Array.isArray(m.tree) ? m.tree : [] });
|
|
13133
|
+
const sig = layersTreeSig(m.tree);
|
|
13134
|
+
lastLayersTreeRef.current = { tree: m.tree, sig, artboardId: m.artboardId ?? null };
|
|
12775
13135
|
// fresh tree (correct ids) landed — drags OK again
|
|
12776
13136
|
layersBusyRef.current = false;
|
|
12777
13137
|
if (layersBusyTimerRef.current) {
|
|
@@ -12787,24 +13147,7 @@ function App() {
|
|
|
12787
13147
|
// actually CONTAINS movedId — an in-canvas drag posts a PREVIEW tree
|
|
12788
13148
|
// first (old ids, pre-write); consuming then would re-select nothing and
|
|
12789
13149
|
// 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
|
-
}
|
|
13150
|
+
settlePendingSelectionRef.current?.(m.tree, sig, m.artboardId ?? null);
|
|
12808
13151
|
} else if (m.dgn === 'reorder-revert') {
|
|
12809
13152
|
// Phase 12.1 follow-up — Cmd+Z/Cmd+Shift+Z on a reorder. The canvas undo
|
|
12810
13153
|
// stack (untrusted iframe) can't reach the main-origin-only
|
|
@@ -13128,6 +13471,23 @@ function App() {
|
|
|
13128
13471
|
if (e.source === activeWin && typeof m.artboardId === 'string') {
|
|
13129
13472
|
deleteArtboardShellRef.current?.(m.artboardId);
|
|
13130
13473
|
}
|
|
13474
|
+
} else if (m.dgn === 'rename-artboard-request') {
|
|
13475
|
+
// Plan T25/L08 — double-click an artboard's name to rename it. Same
|
|
13476
|
+
// confused-deputy guard: the canvas asks, the shell writes the active
|
|
13477
|
+
// canvas only.
|
|
13478
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
13479
|
+
if (
|
|
13480
|
+
e.source === activeWin &&
|
|
13481
|
+
typeof m.artboardId === 'string' &&
|
|
13482
|
+
typeof m.label === 'string' &&
|
|
13483
|
+
m.label.trim()
|
|
13484
|
+
) {
|
|
13485
|
+
structuralWriteRef.current?.(
|
|
13486
|
+
'/_api/set-artboard-label',
|
|
13487
|
+
{ artboardId: m.artboardId, label: m.label.slice(0, 80) },
|
|
13488
|
+
{ label: 'rename artboard' }
|
|
13489
|
+
);
|
|
13490
|
+
}
|
|
13131
13491
|
} else if (m.dgn === 'set-artboard-kind-request') {
|
|
13132
13492
|
// feature-1-artboard-kinds-foundation, T8 — context-menu "Artboard
|
|
13133
13493
|
// kind" submenu (inside the untrusted iframe). `kind: null` clears
|
|
@@ -13774,6 +14134,7 @@ function App() {
|
|
|
13774
14134
|
// fix as resizeElement; also covers the keyboard nudge (L1) which reuses this.
|
|
13775
14135
|
applyOptimisticStyle({ id, prop: 'left', value: `${left}px` });
|
|
13776
14136
|
applyOptimisticStyle({ id, prop: 'top', value: `${top}px` });
|
|
14137
|
+
let j1ref = null;
|
|
13777
14138
|
const writeProp = (property, value) =>
|
|
13778
14139
|
fetch('/_api/edit-css', {
|
|
13779
14140
|
method: 'POST',
|
|
@@ -13785,6 +14146,7 @@ function App() {
|
|
|
13785
14146
|
.then(() => writeProp('left', left))
|
|
13786
14147
|
.then((j1) => {
|
|
13787
14148
|
if (!j1.ok) throw new Error(j1.error || 'left write failed');
|
|
14149
|
+
j1ref = j1;
|
|
13788
14150
|
return writeProp('top', top);
|
|
13789
14151
|
})
|
|
13790
14152
|
.then((j2) => {
|
|
@@ -13796,7 +14158,7 @@ function App() {
|
|
|
13796
14158
|
canvas,
|
|
13797
14159
|
id,
|
|
13798
14160
|
key: 'left',
|
|
13799
|
-
before: `${beforeLeft}px
|
|
14161
|
+
before: replacedValue(j1ref, `${beforeLeft}px`),
|
|
13800
14162
|
after: `${left}px`,
|
|
13801
14163
|
});
|
|
13802
14164
|
recordSourceEdit({
|
|
@@ -13804,7 +14166,7 @@ function App() {
|
|
|
13804
14166
|
canvas,
|
|
13805
14167
|
id,
|
|
13806
14168
|
key: 'top',
|
|
13807
|
-
before: `${beforeTop}px
|
|
14169
|
+
before: replacedValue(j2, `${beforeTop}px`),
|
|
13808
14170
|
after: `${top}px`,
|
|
13809
14171
|
});
|
|
13810
14172
|
})
|
|
@@ -13892,11 +14254,15 @@ function App() {
|
|
|
13892
14254
|
headers: { 'content-type': 'application/json' },
|
|
13893
14255
|
body: JSON.stringify({ canvas, id, property, value, idIndex: occ }),
|
|
13894
14256
|
}).then((r) => r.json().catch(() => ({})));
|
|
14257
|
+
const replies = {};
|
|
13895
14258
|
let chain = editApplyChainRef.current.catch(() => {});
|
|
13896
14259
|
for (const p of props) {
|
|
13897
14260
|
chain = chain.then((prev) => {
|
|
13898
14261
|
if (prev && prev.ok === false) throw new Error(prev.error || `${p} write failed`);
|
|
13899
|
-
return writeProp(p, patch[p])
|
|
14262
|
+
return writeProp(p, patch[p]).then((j) => {
|
|
14263
|
+
replies[p] = j;
|
|
14264
|
+
return j;
|
|
14265
|
+
});
|
|
13900
14266
|
});
|
|
13901
14267
|
}
|
|
13902
14268
|
editApplyChainRef.current = chain
|
|
@@ -13904,7 +14270,14 @@ function App() {
|
|
|
13904
14270
|
if (last && last.ok === false) throw new Error(last.error || 'resize write failed');
|
|
13905
14271
|
// Record one undo entry per written property (each Cmd+Z reverts one).
|
|
13906
14272
|
for (const p of props) {
|
|
13907
|
-
recordSourceEdit({
|
|
14273
|
+
recordSourceEdit({
|
|
14274
|
+
op: 'css',
|
|
14275
|
+
canvas,
|
|
14276
|
+
id,
|
|
14277
|
+
key: p,
|
|
14278
|
+
before: replacedValue(replies[p], b[p] ?? null),
|
|
14279
|
+
after: patch[p],
|
|
14280
|
+
});
|
|
13908
14281
|
}
|
|
13909
14282
|
// Dogfood 2026-07-07 — belt-and-suspenders reselect, mirroring the
|
|
13910
14283
|
// structural ops' `pendingReorderRef` re-settle. The DDR-105 suppression
|
|
@@ -13936,9 +14309,18 @@ function App() {
|
|
|
13936
14309
|
// inverse descriptor goes stale (same reason reorder uses the server seq log).
|
|
13937
14310
|
const structuralWriteRef = useRef(null);
|
|
13938
14311
|
const structuralWrite = useCallback(
|
|
13939
|
-
(route, body, { label, onOk, onFail } = {}) => {
|
|
14312
|
+
(route, body, { label, onOk, onFail: failed } = {}) => {
|
|
13940
14313
|
if (!activePath) return;
|
|
13941
14314
|
const canvas = activePath;
|
|
14315
|
+
// A refused structural edit used to vanish into the console: the
|
|
14316
|
+
// designer pressed Delete and nothing happened. Say so in the canvas.
|
|
14317
|
+
const onFail =
|
|
14318
|
+
failed ??
|
|
14319
|
+
((j) =>
|
|
14320
|
+
postToActiveCanvas({
|
|
14321
|
+
dgn: 'op-toast',
|
|
14322
|
+
message: `Couldn't ${label || 'apply the edit'}${j?.error ? ` — ${j.error}` : ''}.`,
|
|
14323
|
+
}));
|
|
13942
14324
|
editApplyChainRef.current = editApplyChainRef.current
|
|
13943
14325
|
.catch(() => {})
|
|
13944
14326
|
.then(() =>
|
|
@@ -13985,6 +14367,7 @@ function App() {
|
|
|
13985
14367
|
|
|
13986
14368
|
const insertElementShell = useCallback(
|
|
13987
14369
|
(refId, position, kind, opts = {}) => {
|
|
14370
|
+
const staleSig = lastLayersTreeRef.current?.sig ?? null;
|
|
13988
14371
|
structuralWrite(
|
|
13989
14372
|
'/_api/insert-element',
|
|
13990
14373
|
{
|
|
@@ -14000,7 +14383,10 @@ function App() {
|
|
|
14000
14383
|
// Select the new element once the HMR reload lands (its id is stamped
|
|
14001
14384
|
// on transpile — best-effort, like reorder's pendingReorderRef).
|
|
14002
14385
|
onOk: (j, canvas) => {
|
|
14003
|
-
if (j.newId)
|
|
14386
|
+
if (!j.newId) return;
|
|
14387
|
+
pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null, staleSig };
|
|
14388
|
+
const last = lastLayersTreeRef.current;
|
|
14389
|
+
if (last) settlePendingSelectionRef.current?.(last.tree, last.sig, last.artboardId);
|
|
14004
14390
|
},
|
|
14005
14391
|
}
|
|
14006
14392
|
);
|
|
@@ -14064,14 +14450,19 @@ function App() {
|
|
|
14064
14450
|
|
|
14065
14451
|
const duplicateElementShell = useCallback(
|
|
14066
14452
|
(id, idIndex) => {
|
|
14453
|
+
const staleSig = lastLayersTreeRef.current?.sig ?? null;
|
|
14067
14454
|
structuralWrite(
|
|
14068
14455
|
'/_api/duplicate-element',
|
|
14069
14456
|
{ id, idIndex: Number.isInteger(idIndex) ? idIndex : undefined },
|
|
14070
14457
|
{
|
|
14071
14458
|
label: 'duplicate element',
|
|
14072
|
-
// Select the copy once the
|
|
14459
|
+
// Select the copy once the post-write tree lands — not the pre-write
|
|
14460
|
+
// one, where the copy's id still names the next sibling.
|
|
14073
14461
|
onOk: (j, canvas) => {
|
|
14074
|
-
if (j.newId)
|
|
14462
|
+
if (!j.newId) return;
|
|
14463
|
+
pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null, staleSig };
|
|
14464
|
+
const last = lastLayersTreeRef.current;
|
|
14465
|
+
if (last) settlePendingSelectionRef.current?.(last.tree, last.sig, last.artboardId);
|
|
14075
14466
|
},
|
|
14076
14467
|
}
|
|
14077
14468
|
);
|
|
@@ -14141,7 +14532,14 @@ function App() {
|
|
|
14141
14532
|
.then((r) => r.json().catch(() => ({})))
|
|
14142
14533
|
.then((j) => {
|
|
14143
14534
|
if (j.ok)
|
|
14144
|
-
recordSourceEdit({
|
|
14535
|
+
recordSourceEdit({
|
|
14536
|
+
op: 'css',
|
|
14537
|
+
canvas,
|
|
14538
|
+
id,
|
|
14539
|
+
key: property,
|
|
14540
|
+
before: replacedValue(j, null),
|
|
14541
|
+
after: value,
|
|
14542
|
+
});
|
|
14145
14543
|
})
|
|
14146
14544
|
.catch(() => {})
|
|
14147
14545
|
);
|
|
@@ -14481,7 +14879,7 @@ function App() {
|
|
|
14481
14879
|
canvas,
|
|
14482
14880
|
id: req.id,
|
|
14483
14881
|
key: 'src',
|
|
14484
|
-
before: req.before ?? null,
|
|
14882
|
+
before: replacedValue(j, req.before ?? null),
|
|
14485
14883
|
after: pickedPath,
|
|
14486
14884
|
});
|
|
14487
14885
|
} else {
|
|
@@ -14945,11 +15343,18 @@ function App() {
|
|
|
14945
15343
|
kbd: '⇧⌘E',
|
|
14946
15344
|
run: () => setExportDialog({ mode: 'export' }),
|
|
14947
15345
|
},
|
|
15346
|
+
{
|
|
15347
|
+
id: 'share-link',
|
|
15348
|
+
group: 'Canvas',
|
|
15349
|
+
label: 'Copy share link',
|
|
15350
|
+
icon: 'link',
|
|
15351
|
+
run: () => { const links = shareLinksFor(sharePath); const link = links.web ?? links.app ?? links.local; if (link) copyShareLink(link); },
|
|
15352
|
+
},
|
|
14948
15353
|
{
|
|
14949
15354
|
id: 'handoff',
|
|
14950
15355
|
group: 'Canvas',
|
|
14951
15356
|
label: 'Handoff to production',
|
|
14952
|
-
icon: '
|
|
15357
|
+
icon: 'external',
|
|
14953
15358
|
kbd: '⇧⌘H',
|
|
14954
15359
|
run: () => setExportDialog({ mode: 'handoff' }),
|
|
14955
15360
|
},
|
|
@@ -15054,7 +15459,7 @@ function App() {
|
|
|
15054
15459
|
run: () => setReportBugOpen(true),
|
|
15055
15460
|
},
|
|
15056
15461
|
],
|
|
15057
|
-
[openSystem, toggleTheme, reloadActive, whatsNew, createVideo]
|
|
15462
|
+
[openSystem, toggleTheme, reloadActive, whatsNew, createVideo, sharePath, shareShell, cloudLinkedHub, localProjectName, cfg.designRel]
|
|
15058
15463
|
);
|
|
15059
15464
|
|
|
15060
15465
|
// feature-configurable-panel-docking — resolve, for each slot, the panels
|
|
@@ -15133,6 +15538,9 @@ function App() {
|
|
|
15133
15538
|
activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
|
|
15134
15539
|
onOpen={openTab}
|
|
15135
15540
|
onPreview={onPreview}
|
|
15541
|
+
onOpenLinkedFile={openLinkedFile}
|
|
15542
|
+
filesReady={treeLoaded}
|
|
15543
|
+
onShare={showShare}
|
|
15136
15544
|
onOpenSystem={openSystem}
|
|
15137
15545
|
wsConnected={wsConnected}
|
|
15138
15546
|
search={search}
|
|
@@ -15146,6 +15554,10 @@ function App() {
|
|
|
15146
15554
|
onMoveCanvas={moveCanvasReq}
|
|
15147
15555
|
onNewFolder={newFolderReq}
|
|
15148
15556
|
onDeleteFolder={deleteFolderReq}
|
|
15557
|
+
onRenameFolder={renameFolderReq}
|
|
15558
|
+
onRenameCanvas={renameCanvasReq}
|
|
15559
|
+
onDuplicateCanvas={duplicateCanvasReq}
|
|
15560
|
+
onDeleteFile={deleteFileReq}
|
|
15149
15561
|
onRefresh={refreshTree}
|
|
15150
15562
|
refreshing={treeRefreshing}
|
|
15151
15563
|
collapsed={false}
|
|
@@ -15159,6 +15571,7 @@ function App() {
|
|
|
15159
15571
|
canvasKinds={cfg?.canvasKinds}
|
|
15160
15572
|
syncStatus={syncStatus}
|
|
15161
15573
|
onLinkedHub={setCloudLinkedHub}
|
|
15574
|
+
onLocalProject={setLocalProjectName}
|
|
15162
15575
|
savingIsManaged={savingIsManaged}
|
|
15163
15576
|
/>
|
|
15164
15577
|
);
|
|
@@ -15199,8 +15612,35 @@ function App() {
|
|
|
15199
15612
|
// WHICH HISTORY, decided at the ONE place the posture is named — not
|
|
15200
15613
|
// inside the panel, which would be a second derivation of the rule
|
|
15201
15614
|
// `cloud-managed-save-surfaces.test.ts` exists to keep singular.
|
|
15202
|
-
loadLog={
|
|
15203
|
-
|
|
15615
|
+
loadLog={async (path) => {
|
|
15616
|
+
// The project's accepted history when it has one; Git otherwise.
|
|
15617
|
+
const accepted = await loadAcceptedLog(path);
|
|
15618
|
+
setProjectHistoryOn(accepted !== 'legacy');
|
|
15619
|
+
if (accepted !== 'legacy') return accepted;
|
|
15620
|
+
return (cloudManaged ? gitLoadCloudLog : gitLoadLog)(path);
|
|
15621
|
+
}}
|
|
15622
|
+
historySource={projectHistoryOn ? 'project' : cloudManaged ? 'cloud' : 'local'}
|
|
15623
|
+
onRestoreVersion={async (revision) => {
|
|
15624
|
+
const r = await fetch('/_api/project/restore', {
|
|
15625
|
+
method: 'POST',
|
|
15626
|
+
headers: { 'Content-Type': 'application/json' },
|
|
15627
|
+
body: JSON.stringify({ path: activePath, revision }),
|
|
15628
|
+
}).catch(() => null);
|
|
15629
|
+
const j = r ? await r.json().catch(() => null) : null;
|
|
15630
|
+
return !!j?.ok;
|
|
15631
|
+
}}
|
|
15632
|
+
// Not through a cell's door: every browser editor proposes under the
|
|
15633
|
+
// studio's one credential there, so "your own action" is not
|
|
15634
|
+
// knowable and the hub refuses the route (studio-manifest.mjs).
|
|
15635
|
+
onUndoAction={cellManaged ? undefined : async (actionId) => {
|
|
15636
|
+
const r = await fetch('/_api/project/undo', {
|
|
15637
|
+
method: 'POST',
|
|
15638
|
+
headers: { 'Content-Type': 'application/json' },
|
|
15639
|
+
body: JSON.stringify({ actionId }),
|
|
15640
|
+
}).catch(() => null);
|
|
15641
|
+
const j = r ? await r.json().catch(() => null) : null;
|
|
15642
|
+
return !!j?.ok;
|
|
15643
|
+
}}
|
|
15204
15644
|
// What the cloud half of the header names. The cell reports its own
|
|
15205
15645
|
// project and branch with the log; the hub host is the fallback,
|
|
15206
15646
|
// because a header that names the LOCAL folder while listing the
|
|
@@ -15317,8 +15757,10 @@ function App() {
|
|
|
15317
15757
|
only appears if you happen to open the Sync panel is not consent.
|
|
15318
15758
|
Skipped during first-run onboarding so two flows don't stack. */}
|
|
15319
15759
|
{!firstRun && <SyncConsentDialog status={syncStatus} cloud={cfg.cloud} />}
|
|
15320
|
-
{
|
|
15321
|
-
|
|
15760
|
+
<NotificationHost paused={!!usageNudge || !!tourSteps}
|
|
15761
|
+
hiddenGroups={exportCenter.panelOpen ? ['exports'] : []} />
|
|
15762
|
+
<WhatsNewToast wn={whatsNew} />
|
|
15763
|
+
<ExportToast center={exportCenter} />
|
|
15322
15764
|
{gitLifecycle && (
|
|
15323
15765
|
<div role="status" aria-live="polite" className="st-banner st-banner--info">
|
|
15324
15766
|
<span className="st-banner-dot" aria-hidden="true" />
|
|
@@ -15341,6 +15783,8 @@ function App() {
|
|
|
15341
15783
|
)}
|
|
15342
15784
|
<div className="st-shell">
|
|
15343
15785
|
<Menubar
|
|
15786
|
+
sharePath={sharePath}
|
|
15787
|
+
onShare={() => showShare()}
|
|
15344
15788
|
readOnly={viewerMode}
|
|
15345
15789
|
// Cloud Phase 27 C2/C4 — the ONE cloud-only input the shared chrome
|
|
15346
15790
|
// takes. A prop, not the whole `cfg`: the Menubar needs to know it is
|
|
@@ -16353,6 +16797,7 @@ function App() {
|
|
|
16353
16797
|
</div>
|
|
16354
16798
|
</div>
|
|
16355
16799
|
)}
|
|
16800
|
+
{shareDialog && <ShareDialog target={shareDialog} links={shareLinksFor(shareDialog.path)} shell={shareShell} onClose={() => setShareDialog(null)} Icon={StIcon} />}
|
|
16356
16801
|
{exportDialog && (
|
|
16357
16802
|
<ExportDialog
|
|
16358
16803
|
mode={exportDialog.mode}
|