@1agh/maude 0.22.2 → 0.24.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 +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +84 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +135 -10
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +222 -11
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/hubs-config.mjs +42 -4
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
- package/plugins/design/dev-server/annotations-layer.tsx +906 -137
- package/plugins/design/dev-server/api.ts +109 -4
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +94 -11
- package/plugins/design/dev-server/build.ts +69 -3
- package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
- package/plugins/design/dev-server/canvas-cursors.ts +125 -0
- package/plugins/design/dev-server/canvas-icons.tsx +130 -0
- package/plugins/design/dev-server/canvas-lib.tsx +47 -27
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +358 -245
- package/plugins/design/dev-server/client/app.jsx +191 -21
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +131 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/config.schema.json +20 -0
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +31 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +144 -22
- package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/dom-selection.ts +156 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +19 -17
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
- package/plugins/design/dev-server/http.ts +260 -20
- package/plugins/design/dev-server/input-router.tsx +125 -64
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +141 -1
- package/plugins/design/dev-server/sync/agent.ts +418 -0
- package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
- package/plugins/design/dev-server/sync/codec.ts +324 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
- package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
- package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
- package/plugins/design/dev-server/sync/index.ts +918 -0
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
- package/plugins/design/dev-server/test/input-router.test.ts +21 -0
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
- package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
- package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
- package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
- package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
- package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
- package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
- package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
- package/plugins/design/dev-server/tool-palette.tsx +18 -16
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
- package/plugins/design/dev-server/use-collab.tsx +157 -13
- package/plugins/design/dev-server/use-selection-set.tsx +12 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +63 -5
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
|
@@ -64,7 +64,7 @@ function urlOf(p) {
|
|
|
64
64
|
// canvases keep the legacy "serve the file with inspector + Babel injected"
|
|
65
65
|
// path. Phase 3.6 contract; the path argument is repo-root-relative
|
|
66
66
|
// (e.g. ".design/ui/Foo.tsx").
|
|
67
|
-
function canvasUrl(p, cfg) {
|
|
67
|
+
function canvasUrl(p, cfg, opts) {
|
|
68
68
|
if (!p.endsWith('.tsx')) return urlOf(p);
|
|
69
69
|
const designRel = (cfg?.designRel || '.design').replace(/^\/+|\/+$/g, '');
|
|
70
70
|
// Path under designRoot.
|
|
@@ -77,22 +77,36 @@ function canvasUrl(p, cfg) {
|
|
|
77
77
|
const params = new URLSearchParams();
|
|
78
78
|
params.set('canvas', rel);
|
|
79
79
|
params.set('designRel', designRel);
|
|
80
|
-
//
|
|
81
|
-
// the
|
|
82
|
-
//
|
|
83
|
-
|
|
80
|
+
// Gallery thumbnails suppress the shell-owned comment layer (`?comments=0`)
|
|
81
|
+
// so previews stay non-interactive; opening the same canvas as a real tab
|
|
82
|
+
// omits the flag and gets comments. See canvas-comment-mount.tsx.
|
|
83
|
+
if (opts?.thumbnail) params.set('comments', '0');
|
|
84
84
|
const ds0 = cfg?.designSystems?.[0];
|
|
85
|
-
|
|
85
|
+
// Specimen detection: anything under `system/<ds>/preview/` belongs to that
|
|
86
|
+
// specific DS, so it must render with *that* DS's tokens — not always the
|
|
87
|
+
// first one. In a multi-DS project this is what keeps each design system's
|
|
88
|
+
// preview distinct (beta previews use beta tokens, not alpha's).
|
|
89
|
+
const specMatch = rel.match(/^system\/([^/]+)\/preview\//);
|
|
90
|
+
const specDsEntry = specMatch
|
|
91
|
+
? cfg?.designSystems?.find(
|
|
92
|
+
(d) => d.path === `system/${specMatch[1]}` || d.path.endsWith(`/${specMatch[1]}`)
|
|
93
|
+
)
|
|
94
|
+
: null;
|
|
95
|
+
// Resolve tokens path. For a specimen, prefer the matching DS's tokensCssRel
|
|
96
|
+
// (fall back to the `system/<ds>/colors_and_type.css` convention). Otherwise
|
|
97
|
+
// prefer the first designSystem's tokensCssRel — the project's authoritative
|
|
98
|
+
// tokens file. The top-level cfg.tokensCssRel is the legacy default
|
|
99
|
+
// (`system/colors_and_type.css`) and usually doesn't exist post-bootstrap.
|
|
100
|
+
const tokens = specMatch
|
|
101
|
+
? (specDsEntry?.tokensCssRel || `system/${specMatch[1]}/colors_and_type.css`)
|
|
102
|
+
: (ds0?.tokensCssRel || cfg?.tokensCssRel);
|
|
86
103
|
if (tokens) params.set('tokens', tokens);
|
|
87
104
|
if (cfg?.componentsCssRel) params.set('components', cfg.componentsCssRel);
|
|
88
|
-
// Specimen detection: anything under `system/<ds>/preview/` gets the layout
|
|
89
|
-
// chrome CSS so its `.specimen-hd` / `_layout.css`-baked treatment renders.
|
|
90
|
-
const specMatch = rel.match(/^system\/([^/]+)\/preview\//);
|
|
91
105
|
if (specMatch) {
|
|
92
|
-
const
|
|
93
|
-
params.set('layout', `system/${
|
|
106
|
+
const dsName = specMatch[1];
|
|
107
|
+
params.set('layout', `system/${dsName}/preview/_layout.css`);
|
|
94
108
|
if (!cfg?.componentsCssRel) {
|
|
95
|
-
params.set('components', `system/${
|
|
109
|
+
params.set('components', `system/${dsName}/preview/_components.css`);
|
|
96
110
|
}
|
|
97
111
|
} else if (ds0?.path) {
|
|
98
112
|
// UI canvas — load the project DS's `_components.css` so the dc-canvas /
|
|
@@ -102,7 +116,11 @@ function canvasUrl(p, cfg) {
|
|
|
102
116
|
params.set('components', `${ds0.path}/preview/_components.css`);
|
|
103
117
|
}
|
|
104
118
|
}
|
|
105
|
-
|
|
119
|
+
// T2 (9.1-A) — load the iframe from the segregated canvas origin when the
|
|
120
|
+
// server advertises one; fall back to a same-origin relative URL otherwise
|
|
121
|
+
// (older server, tests). The trailing path + query are identical either way.
|
|
122
|
+
const origin = cfg?.canvasOrigin || '';
|
|
123
|
+
return `${origin}/_canvas-shell.html?${params.toString()}`;
|
|
106
124
|
}
|
|
107
125
|
|
|
108
126
|
function basename(p) {
|
|
@@ -418,7 +436,7 @@ function CanvasRow({ primary, sidecars, depth, kind, activePath, onOpen, openCou
|
|
|
418
436
|
);
|
|
419
437
|
}
|
|
420
438
|
|
|
421
|
-
function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showHidden, search, dsFolders, onOpenSystem }) {
|
|
439
|
+
function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showHidden, search, dsFolders, activeDsName, onOpenSystem }) {
|
|
422
440
|
const dirs = Object.keys(node).filter(k => k !== '_files').sort();
|
|
423
441
|
const files = node._files || [];
|
|
424
442
|
// VS Code-style sidecar grouping. Canvas (`.tsx`/`.html`) becomes the primary
|
|
@@ -480,6 +498,7 @@ function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showH
|
|
|
480
498
|
kind={kind}
|
|
481
499
|
showHidden={showHidden}
|
|
482
500
|
search={search}
|
|
501
|
+
activeDsName={activeDsName}
|
|
483
502
|
onOpenSystem={onOpenSystem}
|
|
484
503
|
/>
|
|
485
504
|
);
|
|
@@ -491,7 +510,7 @@ function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showH
|
|
|
491
510
|
dsName={dsMatch.name}
|
|
492
511
|
depth={depth}
|
|
493
512
|
defaultOpen={true}
|
|
494
|
-
active={activePath === SYSTEM_TAB}
|
|
513
|
+
active={activePath === SYSTEM_TAB && dsMatch.name === activeDsName}
|
|
495
514
|
onOpenSystem={onOpenSystem}
|
|
496
515
|
>
|
|
497
516
|
{childTree}
|
|
@@ -529,7 +548,7 @@ function sectionMetaFor(g) {
|
|
|
529
548
|
return { title: g.label.toUpperCase(), pillFromCount: true };
|
|
530
549
|
}
|
|
531
550
|
|
|
532
|
-
function Sidebar({ groups, activePath, onOpen, onOpenSystem, wsConnected, search, setSearch, commentsByFile, showHidden, sectionsExpanded, onToggleSection }) {
|
|
551
|
+
function Sidebar({ groups, activePath, activeDsName, onOpen, onOpenSystem, wsConnected, search, setSearch, commentsByFile, showHidden, sectionsExpanded, onToggleSection }) {
|
|
533
552
|
const filteredGroups = useMemo(() => {
|
|
534
553
|
if (!search) return groups;
|
|
535
554
|
return groups.map(g => ({ ...g, tree: filterTree(g.tree, search), filtered: !!search }));
|
|
@@ -624,6 +643,7 @@ function Sidebar({ groups, activePath, onOpen, onOpenSystem, wsConnected, search
|
|
|
624
643
|
showHidden={showHidden}
|
|
625
644
|
search={search}
|
|
626
645
|
dsFolders={g.dsFolders}
|
|
646
|
+
activeDsName={activeDsName}
|
|
627
647
|
onOpenSystem={isDs ? onOpenSystem : undefined}
|
|
628
648
|
/>
|
|
629
649
|
) : (
|
|
@@ -896,7 +916,9 @@ function ToolsDropdown({ onAction, onClose }) {
|
|
|
896
916
|
{ id: 'pen', label: 'Pen', shortcut: 'B' },
|
|
897
917
|
{ id: 'rect', label: 'Rect', shortcut: 'R' },
|
|
898
918
|
{ id: 'ellipse', label: 'Ellipse', shortcut: 'O' },
|
|
919
|
+
{ id: 'sticky', label: 'Sticky', shortcut: 'N' },
|
|
899
920
|
{ id: 'arrow', label: 'Arrow', shortcut: 'A' },
|
|
921
|
+
{ id: 'text', label: 'Text', shortcut: 'T' },
|
|
900
922
|
{ id: 'eraser', label: 'Eraser', shortcut: 'E' },
|
|
901
923
|
];
|
|
902
924
|
return (
|
|
@@ -1084,6 +1106,14 @@ function Viewport({ tabs, activePath, registerIframe, systemData, onOpenFromSyst
|
|
|
1084
1106
|
src={canvasUrl(t.path, cfg)}
|
|
1085
1107
|
className={t.path === activePath ? 'active' : ''}
|
|
1086
1108
|
data-path={t.path}
|
|
1109
|
+
// T2 (9.1-A) — only sandbox + delegate clipboard when the canvas is
|
|
1110
|
+
// served cross-origin (canvasOrigin present = the split is on). In
|
|
1111
|
+
// the default same-origin mode these attrs are omitted so behavior
|
|
1112
|
+
// is identical to pre-9.1. allow-same-origin gives the cross-origin
|
|
1113
|
+
// frame its OWN origin (own WS/fetch/storage), NOT the parent's.
|
|
1114
|
+
{...(cfg?.canvasOrigin
|
|
1115
|
+
? { sandbox: 'allow-scripts allow-same-origin', allow: 'clipboard-write' }
|
|
1116
|
+
: {})}
|
|
1087
1117
|
/>
|
|
1088
1118
|
);
|
|
1089
1119
|
})}
|
|
@@ -1294,7 +1324,7 @@ function Gallery({ title, items, onOpen, kind, cfg }) {
|
|
|
1294
1324
|
{items.map(p => (
|
|
1295
1325
|
<article key={p.path} className="sv-preview-card" onClick={() => onOpen(p.path)}>
|
|
1296
1326
|
<div className="sv-preview-frame">
|
|
1297
|
-
<iframe src={canvasUrl(p.path, cfg)} title={p.label} scrolling="no" />
|
|
1327
|
+
<iframe src={canvasUrl(p.path, cfg, { thumbnail: true })} title={p.label} scrolling="no" {...(cfg?.canvasOrigin ? { sandbox: 'allow-scripts allow-same-origin' } : {})} />
|
|
1298
1328
|
</div>
|
|
1299
1329
|
<div className="sv-preview-foot">
|
|
1300
1330
|
<strong>{p.label}</strong>
|
|
@@ -1336,7 +1366,7 @@ function StatusBarSlot({ label, children, className = '' }) {
|
|
|
1336
1366
|
);
|
|
1337
1367
|
}
|
|
1338
1368
|
|
|
1339
|
-
function StatusBar({ activePath, selected, wsConnected, openCount, theme, onToggleTheme, onClearSelected }) {
|
|
1369
|
+
function StatusBar({ activePath, selected, wsConnected, openCount, theme, onToggleTheme, onClearSelected, syncStatus }) {
|
|
1340
1370
|
const isSystem = activePath === SYSTEM_TAB;
|
|
1341
1371
|
const text = selected && selected.selector
|
|
1342
1372
|
? selected.selector + (selected.text ? ` — "${selected.text.slice(0, 60)}"` : '')
|
|
@@ -1369,6 +1399,18 @@ function StatusBar({ activePath, selected, wsConnected, openCount, theme, onTogg
|
|
|
1369
1399
|
<span className="sb-key">{wsConnected ? 'live' : 'reconnecting'}</span>
|
|
1370
1400
|
</StatusBarSlot>
|
|
1371
1401
|
|
|
1402
|
+
{/* DDR-060 / 9.1-D — linked-to-hub-but-nothing-syncs state lives here in the
|
|
1403
|
+
status bar (DS-styled, hover for detail) instead of a floating off-brand
|
|
1404
|
+
pill. Only shown when the project is linked + has 0 syncable canvases. */}
|
|
1405
|
+
{syncStatus?.notSyncable && (
|
|
1406
|
+
<StatusBarSlot label="Hub sync" className="sb-sync">
|
|
1407
|
+
<span className="sb-sync-dot" aria-hidden="true" />
|
|
1408
|
+
<span className="sb-key" title={syncStatus.reason || 'Linked to a hub, but no canvases are syncable.'}>
|
|
1409
|
+
0 syncable{syncStatus.tsxCount > 0 ? ` · ${syncStatus.tsxCount} tsx` : ''}
|
|
1410
|
+
</span>
|
|
1411
|
+
</StatusBarSlot>
|
|
1412
|
+
)}
|
|
1413
|
+
|
|
1372
1414
|
<span className="sb-spacer" />
|
|
1373
1415
|
|
|
1374
1416
|
<StatusBarSlot label="Theme" className="sb-theme">
|
|
@@ -1474,6 +1516,77 @@ function CommentsPanel({ commentsByFile, filter, setFilter, activePath, focusedI
|
|
|
1474
1516
|
);
|
|
1475
1517
|
}
|
|
1476
1518
|
|
|
1519
|
+
// ---------- Sync banner (Phase 9 Task 8 — hub-down offline mode) ----------
|
|
1520
|
+
|
|
1521
|
+
// Renders nothing when online with no flash (the common case). Yellow strip
|
|
1522
|
+
// while offline (with queued-edit count), red when offline > 24h, green flash
|
|
1523
|
+
// for 3s right after a reconnect. Driven entirely by the 'sync:status' payload
|
|
1524
|
+
// the dev-server's linked-mode sync runtime broadcasts.
|
|
1525
|
+
function SyncBanner({ status }) {
|
|
1526
|
+
if (!status || status.linked === false) return null;
|
|
1527
|
+
// DDR-060 / 9.1-D — the "linked but 0 syncable" state is surfaced in the
|
|
1528
|
+
// status bar (sb-sync slot), NOT as a floating banner. This component owns
|
|
1529
|
+
// only the transient offline / reconnect-flash banner (Task 8).
|
|
1530
|
+
if (status.notSyncable) return null;
|
|
1531
|
+
const { state, queuedOps, flash, conflicts } = status;
|
|
1532
|
+
const showFlash = flash === 'synced';
|
|
1533
|
+
const offline = state === 'offline' || state === 'offline-long';
|
|
1534
|
+
if (!offline && !showFlash) return null;
|
|
1535
|
+
|
|
1536
|
+
let bg;
|
|
1537
|
+
let fg;
|
|
1538
|
+
let border;
|
|
1539
|
+
let text;
|
|
1540
|
+
if (showFlash) {
|
|
1541
|
+
bg = '#dcfce7';
|
|
1542
|
+
fg = '#166534';
|
|
1543
|
+
border = '#86efac';
|
|
1544
|
+
text = 'Synced with hub';
|
|
1545
|
+
} else if (state === 'offline-long') {
|
|
1546
|
+
bg = '#fee2e2';
|
|
1547
|
+
fg = '#991b1b';
|
|
1548
|
+
border = '#fca5a5';
|
|
1549
|
+
text = `Long offline — ${queuedOps} edit(s) queued. Consider \`git commit && git push\` as backup.`;
|
|
1550
|
+
} else {
|
|
1551
|
+
bg = '#fef9c3';
|
|
1552
|
+
fg = '#854d0e';
|
|
1553
|
+
border = '#fde047';
|
|
1554
|
+
text = `Working offline · ${queuedOps} edit(s) queued · will sync when the hub reconnects.`;
|
|
1555
|
+
}
|
|
1556
|
+
const conflictNote =
|
|
1557
|
+
conflicts && conflicts.length > 0 ? ` (${conflicts.length} conflict notice(s))` : '';
|
|
1558
|
+
|
|
1559
|
+
return (
|
|
1560
|
+
<div
|
|
1561
|
+
role="status"
|
|
1562
|
+
aria-live="polite"
|
|
1563
|
+
style={{
|
|
1564
|
+
position: 'fixed',
|
|
1565
|
+
top: 12,
|
|
1566
|
+
left: '50%',
|
|
1567
|
+
transform: 'translateX(-50%)',
|
|
1568
|
+
zIndex: 10003,
|
|
1569
|
+
display: 'flex',
|
|
1570
|
+
alignItems: 'center',
|
|
1571
|
+
gap: 10,
|
|
1572
|
+
padding: '8px 14px',
|
|
1573
|
+
background: bg,
|
|
1574
|
+
color: fg,
|
|
1575
|
+
border: `1px solid ${border}`,
|
|
1576
|
+
borderRadius: 999,
|
|
1577
|
+
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
|
1578
|
+
font: '500 13px/1.2 system-ui, -apple-system, sans-serif',
|
|
1579
|
+
maxWidth: '80vw',
|
|
1580
|
+
}}
|
|
1581
|
+
>
|
|
1582
|
+
<span>
|
|
1583
|
+
{text}
|
|
1584
|
+
{conflictNote}
|
|
1585
|
+
</span>
|
|
1586
|
+
</div>
|
|
1587
|
+
);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1477
1590
|
// ---------- App ----------
|
|
1478
1591
|
|
|
1479
1592
|
function App() {
|
|
@@ -1487,6 +1600,9 @@ function App() {
|
|
|
1487
1600
|
// every dirty Y.Doc to disk by the time this state populates, so accepting
|
|
1488
1601
|
// the reload is data-loss-safe (DDR-051 §3).
|
|
1489
1602
|
const [gitLifecycle, setGitLifecycle] = useState(null);
|
|
1603
|
+
// Phase 9 Task 8 — hub-down offline mode banner. Driven by the 'sync:status'
|
|
1604
|
+
// WS message the linked-mode sync runtime emits. null in solo mode.
|
|
1605
|
+
const [syncStatus, setSyncStatus] = useState(null);
|
|
1490
1606
|
const [search, setSearch] = useState('');
|
|
1491
1607
|
const [systemData, setSystemData] = useState(null);
|
|
1492
1608
|
// Loaded once at boot from /_config — informs canvasUrl() so TSX iframes
|
|
@@ -1507,11 +1623,31 @@ function App() {
|
|
|
1507
1623
|
// legacy default; designSystems[0].tokensCssRel is the project's
|
|
1508
1624
|
// authoritative value (post DS-bootstrap).
|
|
1509
1625
|
designSystems: data.designSystems,
|
|
1626
|
+
// T2 (9.1-A) — segregated canvas-content origin. canvasUrl() prepends
|
|
1627
|
+
// it so iframes load cross-origin (hub-pushed JSX is then walled off
|
|
1628
|
+
// from the main origin's /_api). Absent on older servers → relative
|
|
1629
|
+
// URL fallback keeps same-origin behavior.
|
|
1630
|
+
canvasOrigin: data.canvasOrigin,
|
|
1510
1631
|
});
|
|
1511
1632
|
})
|
|
1512
1633
|
.catch(() => {});
|
|
1513
1634
|
return () => { cancelled = true; };
|
|
1514
1635
|
}, []);
|
|
1636
|
+
// Backfill the sync banner on mount from /_sync-status. The 'sync:status' WS
|
|
1637
|
+
// broadcast is one-shot for the zero-syncable case (DDR-060 / 9.1-D), so a
|
|
1638
|
+
// tab that connects after boot would otherwise miss it. {linked:false} (solo)
|
|
1639
|
+
// leaves the banner null.
|
|
1640
|
+
useEffect(() => {
|
|
1641
|
+
let cancelled = false;
|
|
1642
|
+
fetch('/_sync-status')
|
|
1643
|
+
.then((r) => r.json())
|
|
1644
|
+
.then((data) => {
|
|
1645
|
+
if (cancelled || !data || data.linked === false) return;
|
|
1646
|
+
setSyncStatus(data);
|
|
1647
|
+
})
|
|
1648
|
+
.catch(() => {});
|
|
1649
|
+
return () => { cancelled = true; };
|
|
1650
|
+
}, []);
|
|
1515
1651
|
const [commentsByFile, setCommentsByFile] = useState({}); // { file: [Comment] }
|
|
1516
1652
|
// Phase 6 — the in-iframe composer owns drafting; the shell no longer holds
|
|
1517
1653
|
// a `draft` state. Mutations route through postMessage → WS instead.
|
|
@@ -1555,6 +1691,17 @@ function App() {
|
|
|
1555
1691
|
document.documentElement.setAttribute('data-theme', theme);
|
|
1556
1692
|
localStorage.setItem(THEME_STORE, theme);
|
|
1557
1693
|
} catch {}
|
|
1694
|
+
// System-review D9 — the canvas-shell chrome (workspace plane, floating
|
|
1695
|
+
// toolbar, minimap, zoom HUD, halos) follows the Maude theme. Broadcast to
|
|
1696
|
+
// EVERY open canvas iframe (not just activePath — several may be open); the
|
|
1697
|
+
// iframe's canvas-shell sets `data-maude-theme` and re-themes its floating
|
|
1698
|
+
// chrome via the --maude-chrome-* family. Artboards keep their DS theme.
|
|
1699
|
+
// Mirrors the git-lifecycle broadcast-to-all loop below. On the initial
|
|
1700
|
+
// mount run iframesRef is empty (no canvas open yet) — a freshly-loaded
|
|
1701
|
+
// iframe instead gets the current theme from the `dgn:'loaded'` handler.
|
|
1702
|
+
for (const el of iframesRef.current.values()) {
|
|
1703
|
+
try { el.contentWindow.postMessage({ dgn: 'theme', theme }, '*'); } catch {}
|
|
1704
|
+
}
|
|
1558
1705
|
}, [theme]);
|
|
1559
1706
|
|
|
1560
1707
|
// Persist sidebar / hidden-files / DS-body toggles. Mirror theme pattern.
|
|
@@ -1669,6 +1816,9 @@ function App() {
|
|
|
1669
1816
|
for (const el of iframesRef.current.values()) {
|
|
1670
1817
|
try { el.contentWindow.postMessage({ dgn: 'ai-activity', file: m.file, entry: m.entry }, '*'); } catch {}
|
|
1671
1818
|
}
|
|
1819
|
+
} else if (m.type === 'sync:status' && m.payload) {
|
|
1820
|
+
// Phase 9 Task 8 — hub connection state for the offline banner.
|
|
1821
|
+
setSyncStatus(m.payload);
|
|
1672
1822
|
} else if (m.type === 'git-lifecycle' && m.payload) {
|
|
1673
1823
|
// Phase 8 Task 7 — branch switch / pull mid-session. Server has
|
|
1674
1824
|
// already flushed every dirty Y.Doc to JSON; just prompt the user.
|
|
@@ -1707,8 +1857,13 @@ function App() {
|
|
|
1707
1857
|
setFocusedCommentId(null);
|
|
1708
1858
|
}, []);
|
|
1709
1859
|
|
|
1710
|
-
const openSystem = useCallback(() => {
|
|
1711
|
-
|
|
1860
|
+
const openSystem = useCallback((dsName) => {
|
|
1861
|
+
// DsFolderRow passes the clicked DS name → scope the System view to it so
|
|
1862
|
+
// each folder shows its own tokens + previews. The no-arg callers (menubar,
|
|
1863
|
+
// keyboard reopen) only load default data on first open.
|
|
1864
|
+
const ds = typeof dsName === 'string' ? dsName : undefined;
|
|
1865
|
+
if (ds) loadSystemData(ds);
|
|
1866
|
+
else if (!systemData) loadSystemData();
|
|
1712
1867
|
openTab(SYSTEM_TAB);
|
|
1713
1868
|
}, [systemData, loadSystemData, openTab]);
|
|
1714
1869
|
|
|
@@ -1770,6 +1925,14 @@ function App() {
|
|
|
1770
1925
|
// ----- Inbound messages from iframes -----
|
|
1771
1926
|
useEffect(() => {
|
|
1772
1927
|
function onMessage(e) {
|
|
1928
|
+
// Cross-origin hardening (DDR-054): only accept dgn control messages from
|
|
1929
|
+
// the canvas-content origin — the split origin when on, else our own origin
|
|
1930
|
+
// for the same-origin iframe. Drops spoofed messages from any other window.
|
|
1931
|
+
// The handlers below relay to inert stores (comments / selection — the
|
|
1932
|
+
// "safe to sync" set), so the blast radius was small, but unchecked inbound
|
|
1933
|
+
// postMessage is a confused-deputy seam the F1 hardening should close.
|
|
1934
|
+
const expectedOrigin = cfg?.canvasOrigin || window.location.origin;
|
|
1935
|
+
if (e.origin !== expectedOrigin) return;
|
|
1773
1936
|
const m = e.data;
|
|
1774
1937
|
if (!m || typeof m !== 'object' || !m.dgn) return;
|
|
1775
1938
|
if (m.dgn === 'select' && m.selection) {
|
|
@@ -1841,6 +2004,10 @@ function App() {
|
|
|
1841
2004
|
const el = [...iframesRef.current.entries()].find(([k]) => k === m.file)?.[1];
|
|
1842
2005
|
if (el && el.contentWindow) {
|
|
1843
2006
|
try { el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*'); } catch {}
|
|
2007
|
+
// System-review D9 — seed the just-loaded canvas with the current
|
|
2008
|
+
// chrome theme so a canvas opened AFTER a theme toggle starts
|
|
2009
|
+
// correct (no flash from the dark default).
|
|
2010
|
+
try { el.contentWindow.postMessage({ dgn: 'theme', theme }, '*'); } catch {}
|
|
1844
2011
|
if (focusedCommentId && list.some(c => c.id === focusedCommentId)) {
|
|
1845
2012
|
try { el.contentWindow.postMessage({ dgn: 'comment-focus', id: focusedCommentId }, '*'); } catch {}
|
|
1846
2013
|
}
|
|
@@ -1849,7 +2016,7 @@ function App() {
|
|
|
1849
2016
|
}
|
|
1850
2017
|
window.addEventListener('message', onMessage);
|
|
1851
2018
|
return () => window.removeEventListener('message', onMessage);
|
|
1852
|
-
}, [commentsByFile, focusedCommentId]);
|
|
2019
|
+
}, [commentsByFile, focusedCommentId, cfg, theme]);
|
|
1853
2020
|
|
|
1854
2021
|
// Tell the active canvas iframe to drop any persistent selection (canvas
|
|
1855
2022
|
// SelectionSet) — used when the comment composer closes via submit /
|
|
@@ -2021,6 +2188,7 @@ function App() {
|
|
|
2021
2188
|
className={'app' + (commentsPanelOpen ? ' with-rsidebar' : '') + (sidebarOpen ? '' : ' no-sidebar')}
|
|
2022
2189
|
onContextMenu={onShellContextMenu}
|
|
2023
2190
|
>
|
|
2191
|
+
<SyncBanner status={syncStatus} />
|
|
2024
2192
|
{gitLifecycle && (
|
|
2025
2193
|
<div
|
|
2026
2194
|
role="status"
|
|
@@ -2075,6 +2243,7 @@ function App() {
|
|
|
2075
2243
|
<Sidebar
|
|
2076
2244
|
groups={groups}
|
|
2077
2245
|
activePath={activePath}
|
|
2246
|
+
activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
|
|
2078
2247
|
onOpen={openTab}
|
|
2079
2248
|
onOpenSystem={openSystem}
|
|
2080
2249
|
wsConnected={wsConnected}
|
|
@@ -2125,6 +2294,7 @@ function App() {
|
|
|
2125
2294
|
theme={theme}
|
|
2126
2295
|
onToggleTheme={toggleTheme}
|
|
2127
2296
|
onClearSelected={clearSelected}
|
|
2297
|
+
syncStatus={syncStatus}
|
|
2128
2298
|
/>
|
|
2129
2299
|
</div>
|
|
2130
2300
|
{commentsPanelOpen && (
|
|
@@ -799,6 +799,16 @@
|
|
|
799
799
|
background: var(--u-status-error);
|
|
800
800
|
}
|
|
801
801
|
.sb-live .sb-key { color: var(--u-fg-1); }
|
|
802
|
+
/* DDR-060 / 9.1-D — linked-but-0-syncable indicator. Warn dot (heads-up, not
|
|
803
|
+
error), DS-tokened, hover the label for the full reason via the title attr. */
|
|
804
|
+
.sb-sync { cursor: help; }
|
|
805
|
+
.sb-sync-dot {
|
|
806
|
+
width: 7px; height: 7px;
|
|
807
|
+
background: var(--u-status-warn);
|
|
808
|
+
border-radius: 50%;
|
|
809
|
+
flex: none;
|
|
810
|
+
}
|
|
811
|
+
.sb-sync .sb-key { color: var(--u-fg-2); }
|
|
802
812
|
.sb-spacer {
|
|
803
813
|
flex: 1 0 auto;
|
|
804
814
|
border-right: 1px solid var(--u-border-subtle);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Bidirectional Awareness relay — Phase 9 Task 5 (awareness over WSS).
|
|
2
|
+
//
|
|
3
|
+
// In linked mode each canvas has TWO Awareness instances in this process:
|
|
4
|
+
//
|
|
5
|
+
// - the collab Room's Awareness (browser tabs ↔ dev-server, loopback WS)
|
|
6
|
+
// - the sync provider's Awareness (dev-server ↔ hub, HocuspocusProvider)
|
|
7
|
+
//
|
|
8
|
+
// Hocuspocus relays awareness frames between connected peers on a document out
|
|
9
|
+
// of the box, so the provider's Awareness already reaches cross-continent
|
|
10
|
+
// peers. The only missing link is in-process: this bridge wires the Room's
|
|
11
|
+
// Awareness to the provider's Awareness so a browser cursor published into the
|
|
12
|
+
// Room reaches the hub (and back) without either side knowing about the other.
|
|
13
|
+
//
|
|
14
|
+
// Awareness is EPHEMERAL — it is never persisted to disk. That is why this
|
|
15
|
+
// bridge is decoupled from the file-ownership question (DDR-054 F14): relaying
|
|
16
|
+
// cursors/selections/viewport touches no `.json` / `.svg` / `.html` file, so
|
|
17
|
+
// it neither introduces nor resolves the comments/annotations write race. The
|
|
18
|
+
// doc-content bridge (Room doc ↔ provider doc) is intentionally NOT built here
|
|
19
|
+
// — disk stays the medium between the two docs (Task 4), and F14 remains a
|
|
20
|
+
// documented risk for the doc-content bridge work.
|
|
21
|
+
//
|
|
22
|
+
// Echo prevention: every cross relay applies the update to the far side tagged
|
|
23
|
+
// with a shared BRIDGE origin. Each direction's listener skips updates carrying
|
|
24
|
+
// that origin, so a relayed state never bounces back to where it came from.
|
|
25
|
+
// This is the same pattern y-websocket uses to relay awareness across a fan-out
|
|
26
|
+
// hub. State identity is preserved because awareness updates are keyed by the
|
|
27
|
+
// originating clientID — the random 32-bit ids make local↔remote collisions
|
|
28
|
+
// negligible.
|
|
29
|
+
|
|
30
|
+
import { applyAwarenessUpdate, encodeAwarenessUpdate } from 'y-protocols/awareness';
|
|
31
|
+
import type { Awareness } from 'y-protocols/awareness';
|
|
32
|
+
|
|
33
|
+
interface AwarenessChange {
|
|
34
|
+
added: number[];
|
|
35
|
+
updated: number[];
|
|
36
|
+
removed: number[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Wire `a` ↔ `b` bidirectionally. Existing states on each side are exchanged
|
|
41
|
+
* immediately so a peer that connected before the bridge existed still shows
|
|
42
|
+
* up. Returns a detach fn that removes both listeners — call it BEFORE either
|
|
43
|
+
* Awareness is destroyed, otherwise a late relay would apply to a dead
|
|
44
|
+
* instance.
|
|
45
|
+
*/
|
|
46
|
+
export function bridgeAwareness(a: Awareness, b: Awareness): () => void {
|
|
47
|
+
// A single shared origin for both directions. A genuine local change carries
|
|
48
|
+
// some other origin (a browser conn, the provider's internal token, or null),
|
|
49
|
+
// so it relays; a change this bridge applied carries BRIDGE, so it stops.
|
|
50
|
+
const BRIDGE = { awarenessBridge: true };
|
|
51
|
+
|
|
52
|
+
function makeRelay(from: Awareness, to: Awareness) {
|
|
53
|
+
return ({ added, updated, removed }: AwarenessChange, origin: unknown) => {
|
|
54
|
+
if (origin === BRIDGE) return;
|
|
55
|
+
const changed = added.concat(updated, removed);
|
|
56
|
+
if (changed.length === 0) return;
|
|
57
|
+
applyAwarenessUpdate(to, encodeAwarenessUpdate(from, changed), BRIDGE);
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const aToB = makeRelay(a, b);
|
|
62
|
+
const bToA = makeRelay(b, a);
|
|
63
|
+
|
|
64
|
+
// Initial state exchange — push each side's current states to the other.
|
|
65
|
+
const aClients = Array.from(a.getStates().keys());
|
|
66
|
+
if (aClients.length > 0) applyAwarenessUpdate(b, encodeAwarenessUpdate(a, aClients), BRIDGE);
|
|
67
|
+
const bClients = Array.from(b.getStates().keys());
|
|
68
|
+
if (bClients.length > 0) applyAwarenessUpdate(a, encodeAwarenessUpdate(b, bClients), BRIDGE);
|
|
69
|
+
|
|
70
|
+
a.on('update', aToB);
|
|
71
|
+
b.on('update', bToA);
|
|
72
|
+
|
|
73
|
+
return () => {
|
|
74
|
+
a.off('update', aToB);
|
|
75
|
+
b.off('update', bToA);
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Public surface of the collab module. Bundles the registry + persistence
|
|
2
2
|
// wiring so ws.ts + server.ts don't need to know about the internal split.
|
|
3
3
|
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
4
7
|
import type { Api } from '../api.ts';
|
|
5
8
|
import type { Context } from '../context.ts';
|
|
6
9
|
|
|
@@ -14,6 +17,8 @@ export type { CollabConn } from './protocol.ts';
|
|
|
14
17
|
|
|
15
18
|
export interface Collab {
|
|
16
19
|
registry: Registry;
|
|
20
|
+
/** Tear down the fs-driven re-seed subscription (server shutdown). */
|
|
21
|
+
dispose(): void;
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
/**
|
|
@@ -24,15 +29,21 @@ export interface Collab {
|
|
|
24
29
|
*/
|
|
25
30
|
export function createCollab(ctx: Context, api: Api): Collab {
|
|
26
31
|
// Inverse of api.fileSlug — we have the URL slug, need the canonical
|
|
27
|
-
// repo-relative path the api expects.
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// canvas
|
|
31
|
-
//
|
|
32
|
+
// repo-relative path the api expects.
|
|
33
|
+
//
|
|
34
|
+
// Primary: api.fileForSlug scans the ACTUAL canvas files, so it resolves even
|
|
35
|
+
// when the canvas has no comments yet. This is the fix for the receiving-peer
|
|
36
|
+
// projection gap — a peer that hasn't yet got a comment for a canvas must
|
|
37
|
+
// still locate the file to MATERIALIZE the first hub-pushed comment to disk
|
|
38
|
+
// (DDR-064). The old comments-file scan was chicken-and-egg here: no comments
|
|
39
|
+
// → null → persistJson bailed → the incoming comment never hit disk.
|
|
32
40
|
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
41
|
+
// Fallback: a canvas whose file the scan missed (renamed/moved with an orphan
|
|
42
|
+
// comments file) still resolves via its existing comments file, preserving
|
|
43
|
+
// the prior behavior for that edge.
|
|
35
44
|
async function fileForSlug(slug: string): Promise<string | null> {
|
|
45
|
+
const byCanvas = await api.fileForSlug(slug);
|
|
46
|
+
if (byCanvas) return byCanvas;
|
|
36
47
|
const all = await api.loadAllComments();
|
|
37
48
|
for (const [file] of Object.entries(all)) {
|
|
38
49
|
if (api.fileSlug(file) === slug) return file;
|
|
@@ -40,8 +51,75 @@ export function createCollab(ctx: Context, api: Api): Collab {
|
|
|
40
51
|
return null;
|
|
41
52
|
}
|
|
42
53
|
|
|
43
|
-
|
|
54
|
+
// Phase 9.2 (DDR-064) — under sharedDoc, disable the room's local file-seed
|
|
55
|
+
// for pinned (provider-attached) slugs so it can't push duplicate Y.Array
|
|
56
|
+
// items against the hub's canonical items (Risk 1). The registry is created
|
|
57
|
+
// from the persistence callbacks, so break the cycle with a late-bound ref
|
|
58
|
+
// (the predicate is only consulted at room-seed time, after `registry` is
|
|
59
|
+
// assigned). Flag-OFF → predicate returns true → seed unchanged.
|
|
60
|
+
let registryRef: Registry | null = null;
|
|
61
|
+
const persistence = createPersistence({
|
|
62
|
+
ctx,
|
|
63
|
+
api,
|
|
64
|
+
fileForSlug,
|
|
65
|
+
shouldSeed: (slug) => !(ctx.sharedDoc && registryRef?.isPinned(slug)),
|
|
66
|
+
});
|
|
44
67
|
const registry = createRegistry(persistence);
|
|
68
|
+
registryRef = registry;
|
|
69
|
+
|
|
70
|
+
// File = truth (the file-sync collaboration model + DDR-051): when a synced
|
|
71
|
+
// file changes on disk from OUTSIDE the API path — the sync agent writing a
|
|
72
|
+
// hub-pushed diff, or `design:edit` editing a JSON/SVG directly — fan the
|
|
73
|
+
// change back out to the browser, since none of these go through the API's
|
|
74
|
+
// onCommentsChanged. Two consumers, two reasons:
|
|
75
|
+
// - the live collab room (canvas pins / annotation strokes) — re-seeded so
|
|
76
|
+
// its in-memory doc stops clobbering the external change on next persist
|
|
77
|
+
// (the cross-machine "comment reverts to []" bug). Only when a room is
|
|
78
|
+
// mounted (peek, never create).
|
|
79
|
+
// - the shell's comments SIDEBAR — driven solely by the 'comments' WS event
|
|
80
|
+
// (app.jsx), which otherwise fires only on API writes. Emit it here too so
|
|
81
|
+
// a hub-pushed comment shows up on the peer's sidebar without a reload.
|
|
82
|
+
// The registry's no-op guards make an identical re-seed free, so this can't
|
|
83
|
+
// loop against the room's own persist (which also writes the file).
|
|
84
|
+
const reseedFromDisk = async (rel: string): Promise<void> => {
|
|
85
|
+
const cm = /^_comments\/(.+)\.json$/.exec(rel);
|
|
86
|
+
const am = /^(.+)\.annotations\.svg$/.exec(rel);
|
|
87
|
+
const slug = cm?.[1] ?? am?.[1];
|
|
88
|
+
if (!slug) return;
|
|
89
|
+
const abs = path.join(ctx.paths.designRoot, rel);
|
|
90
|
+
try {
|
|
91
|
+
if (cm) {
|
|
92
|
+
// Prefer the canonical loader (validates + default-fills the Comment
|
|
93
|
+
// shape) keyed by the canvas file; fall back to the raw array when the
|
|
94
|
+
// slug isn't mapped yet (e.g. a freshly-synced file with no prior load).
|
|
95
|
+
const file = await fileForSlug(slug);
|
|
96
|
+
const parsed = file
|
|
97
|
+
? await api.loadCommentsForFile(file)
|
|
98
|
+
: JSON.parse(readFileSync(abs, 'utf8'));
|
|
99
|
+
if (!Array.isArray(parsed)) return;
|
|
100
|
+
// Phase 9.2 (DDR-064): under sharedDoc the hub provider is attached to
|
|
101
|
+
// the single room doc, so a hub-pushed comment is ALREADY in the room —
|
|
102
|
+
// the wholesale re-seed (last-writer-wins blob copy) is the retired
|
|
103
|
+
// clobber path. The agent's diff-aware applyFromFs handles external
|
|
104
|
+
// file→doc imports instead. The sidebar still needs the 'comments' bus
|
|
105
|
+
// emit, so keep that unconditionally.
|
|
106
|
+
if (!ctx.sharedDoc && registry.peek(slug)) registry.syncRoomFromComments(slug, parsed);
|
|
107
|
+
if (file) ctx.bus.emit('comments', { file, comments: parsed });
|
|
108
|
+
} else if (!ctx.sharedDoc && registry.peek(slug)) {
|
|
109
|
+
registry.syncRoomFromAnnotations(slug, readFileSync(abs, 'utf8'));
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
/* file vanished mid-flight or unreadable — leave state as-is */
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const unsubFs = ctx.bus.on('fs:any', (rel: string) => {
|
|
116
|
+
void reseedFromDisk(rel);
|
|
117
|
+
});
|
|
45
118
|
|
|
46
|
-
return {
|
|
119
|
+
return {
|
|
120
|
+
registry,
|
|
121
|
+
dispose() {
|
|
122
|
+
unsubFs?.();
|
|
123
|
+
},
|
|
124
|
+
};
|
|
47
125
|
}
|