@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
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/** Shared shell/canvas notifications. Sonner owns layout, gestures and announcements;
|
|
2
|
+
* our clock also pauses for app overlays, hidden queue entries and Save dialogs. */
|
|
3
|
+
import {
|
|
4
|
+
type CSSProperties,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
useSyncExternalStore,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { Toaster, toast } from 'sonner';
|
|
12
|
+
|
|
13
|
+
export type NoticeKind = 'info' | 'success' | 'error' | 'warning' | 'undo';
|
|
14
|
+
export interface Notice {
|
|
15
|
+
id?: string;
|
|
16
|
+
title: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
content?: ReactNode;
|
|
19
|
+
kind?: NoticeKind;
|
|
20
|
+
group?: string;
|
|
21
|
+
duration?: number;
|
|
22
|
+
/** Change only for a new lifecycle phase, never for a progress update. */
|
|
23
|
+
timerKey?: string;
|
|
24
|
+
paused?: boolean;
|
|
25
|
+
action?: { label: string; onClick: () => void };
|
|
26
|
+
onDismiss?: () => void;
|
|
27
|
+
}
|
|
28
|
+
type Entry = Notice & { id: string };
|
|
29
|
+
let entries: Entry[] = [];
|
|
30
|
+
let sequence = 0;
|
|
31
|
+
const listeners = new Set<() => void>();
|
|
32
|
+
const publish = () => {
|
|
33
|
+
for (const listener of listeners) listener();
|
|
34
|
+
};
|
|
35
|
+
const subscribe = (listener: () => void) => {
|
|
36
|
+
listeners.add(listener);
|
|
37
|
+
return () => {
|
|
38
|
+
listeners.delete(listener);
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
const snapshot = () => entries;
|
|
42
|
+
|
|
43
|
+
export function notify(notice: Notice): string {
|
|
44
|
+
const id = notice.id ?? `notice-${++sequence}`;
|
|
45
|
+
const existing = entries.findIndex((entry) => entry.id === id);
|
|
46
|
+
if (existing < 0) entries = [...entries, { ...notice, id }];
|
|
47
|
+
else entries = entries.map((entry) => (entry.id === id ? { ...notice, id } : entry));
|
|
48
|
+
publish();
|
|
49
|
+
return id;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let canvasSlot = 0;
|
|
53
|
+
let canvasWindowStart = -Infinity;
|
|
54
|
+
let canvasWindowCount = 0;
|
|
55
|
+
/** Untrusted canvas traffic gets two reusable slots, leaving room for shell notices.
|
|
56
|
+
* Limit publications as well as retention; editing can emit dozens per second. */
|
|
57
|
+
export function notifyCanvasText(title: string, kind: NoticeKind = 'info'): string | null {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
if (now < canvasWindowStart || now - canvasWindowStart >= 1000) {
|
|
60
|
+
canvasWindowStart = now;
|
|
61
|
+
canvasWindowCount = 0;
|
|
62
|
+
}
|
|
63
|
+
if (canvasWindowCount >= 5) return null;
|
|
64
|
+
canvasWindowCount++;
|
|
65
|
+
const previousUndo =
|
|
66
|
+
kind === 'undo'
|
|
67
|
+
? entries.find((entry) => entry.group === 'canvas' && entry.kind === 'undo')
|
|
68
|
+
: undefined;
|
|
69
|
+
const id = previousUndo?.id ?? `canvas-notice-${canvasSlot++ % 2}`;
|
|
70
|
+
return notify({
|
|
71
|
+
id,
|
|
72
|
+
title: title.slice(0, 4000),
|
|
73
|
+
kind,
|
|
74
|
+
group: 'canvas',
|
|
75
|
+
timerKey: `${++sequence}`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Programmatic removal (unmount/navigation) does not acknowledge the notice. */
|
|
80
|
+
export function dismissNotice(id: string): void {
|
|
81
|
+
entries = entries.filter((entry) => entry.id !== id);
|
|
82
|
+
publish();
|
|
83
|
+
}
|
|
84
|
+
function acknowledge(id: string) {
|
|
85
|
+
const entry = entries.find((item) => item.id === id);
|
|
86
|
+
dismissNotice(id);
|
|
87
|
+
entry?.onDismiss?.();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function noticeDuration(entry: Notice): number {
|
|
91
|
+
return (
|
|
92
|
+
entry.duration ??
|
|
93
|
+
(entry.action || ['error', 'warning', 'undo'].includes(entry.kind ?? '') ? 10_000 : 5_000)
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Keep full diagnostics in their owning panel, including for screen readers. */
|
|
98
|
+
export function summarizeNoticeText(text: string): string {
|
|
99
|
+
const compact = text.trim().replace(/\s+/g, ' ');
|
|
100
|
+
return compact.length > 280 ? `${compact.slice(0, 279)}…` : compact;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function NoticeCard({ entry }: { entry: Entry }) {
|
|
104
|
+
return (
|
|
105
|
+
<div
|
|
106
|
+
className="maude-notice"
|
|
107
|
+
data-kind={entry.kind ?? 'info'}
|
|
108
|
+
data-testid={`notice-${entry.id}`}
|
|
109
|
+
>
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
className="maude-notice-close"
|
|
113
|
+
aria-label="Dismiss notification"
|
|
114
|
+
onClick={(event) => {
|
|
115
|
+
event.currentTarget.blur();
|
|
116
|
+
acknowledge(entry.id);
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
×
|
|
120
|
+
</button>
|
|
121
|
+
<div className="maude-notice-title">{summarizeNoticeText(entry.title)}</div>
|
|
122
|
+
{entry.description && (
|
|
123
|
+
<div className="maude-notice-summary">{summarizeNoticeText(entry.description)}</div>
|
|
124
|
+
)}
|
|
125
|
+
{entry.content}
|
|
126
|
+
{entry.action && (
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
className="maude-notice-action"
|
|
130
|
+
onClick={(event) => {
|
|
131
|
+
event.currentTarget.blur();
|
|
132
|
+
entry.action?.onClick();
|
|
133
|
+
acknowledge(entry.id);
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
{entry.action.label}
|
|
137
|
+
</button>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function MountedNotice({
|
|
144
|
+
entry,
|
|
145
|
+
visible,
|
|
146
|
+
paused,
|
|
147
|
+
}: {
|
|
148
|
+
entry: Entry;
|
|
149
|
+
visible: boolean;
|
|
150
|
+
paused: boolean;
|
|
151
|
+
}) {
|
|
152
|
+
const duration = noticeDuration(entry);
|
|
153
|
+
const presentationId = useRef<string | null>(null);
|
|
154
|
+
const remaining = useRef(duration);
|
|
155
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: a new lifecycle phase gets a fresh duration.
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
remaining.current = duration;
|
|
158
|
+
}, [duration, entry.timerKey]);
|
|
159
|
+
// Keep the clock outside Sonner's card: hiding a group must preserve its time.
|
|
160
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: restart the clock when its lifecycle phase changes.
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (!visible || paused || entry.paused || duration === Infinity) return;
|
|
163
|
+
const started = Date.now();
|
|
164
|
+
const timer = setTimeout(() => acknowledge(entry.id), Math.max(0, remaining.current));
|
|
165
|
+
return () => {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
remaining.current = Math.max(0, remaining.current - (Date.now() - started));
|
|
168
|
+
};
|
|
169
|
+
}, [visible, paused, entry.paused, duration, entry.timerKey, entry.id]);
|
|
170
|
+
// Sonner invokes onDismiss even for programmatic removal. Invalidate the
|
|
171
|
+
// presentation before hiding so opening history never acknowledges a job.
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (visible) {
|
|
174
|
+
const token = presentationId.current ?? `${entry.id}:view-${++sequence}`;
|
|
175
|
+
presentationId.current = token;
|
|
176
|
+
toast.custom(() => <NoticeCard entry={entry} />, {
|
|
177
|
+
id: token,
|
|
178
|
+
duration: Infinity,
|
|
179
|
+
onDismiss: () => {
|
|
180
|
+
if (presentationId.current === token) acknowledge(entry.id);
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
} else if (presentationId.current) {
|
|
184
|
+
const token = presentationId.current;
|
|
185
|
+
presentationId.current = null;
|
|
186
|
+
toast.dismiss(token);
|
|
187
|
+
}
|
|
188
|
+
}, [entry, visible]);
|
|
189
|
+
useEffect(
|
|
190
|
+
() => () => {
|
|
191
|
+
const token = presentationId.current;
|
|
192
|
+
presentationId.current = null;
|
|
193
|
+
if (token) toast.dismiss(token);
|
|
194
|
+
},
|
|
195
|
+
[]
|
|
196
|
+
);
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const NOTICE_CSS = `
|
|
201
|
+
.maude-notifications [data-sonner-toaster] { --width: min(360px, calc(100vw - 32px)); z-index: 8000; }
|
|
202
|
+
.maude-notifications [data-sonner-toast] { width: var(--width); }
|
|
203
|
+
.maude-notifications [data-sonner-toast][data-expanded=false][data-front=false] {
|
|
204
|
+
overflow: hidden; border-radius: var(--radius-md, 8px);
|
|
205
|
+
}
|
|
206
|
+
.maude-notifications[data-paused=true] { visibility: hidden; pointer-events: none; }
|
|
207
|
+
.maude-notice { box-sizing: border-box; position: relative; width: 100%; padding: 16px;
|
|
208
|
+
border: 1px solid var(--border-default, #45454c); border-left: 3px solid var(--accent, #d63b1f);
|
|
209
|
+
border-radius: var(--radius-md, 8px); background: var(--bg-2, #26262b); color: var(--fg-0, #fafafa);
|
|
210
|
+
box-shadow: 0 8px 28px #0004; font: 13px/1.5 var(--font-sans, system-ui, sans-serif);
|
|
211
|
+
display: flex; flex-direction: column; gap: 8px; overflow-wrap: anywhere; }
|
|
212
|
+
.maude-notice[data-kind=error], .maude-notice[data-kind=warning] { border-left-color: var(--status-error, #d65b4d); }
|
|
213
|
+
.maude-notice-title { font-weight: 600; padding-right: 20px; }
|
|
214
|
+
.maude-notice-summary, .maude-notice-title, .maude-notice .st-export-degraded {
|
|
215
|
+
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden;
|
|
216
|
+
overflow-wrap: anywhere; }
|
|
217
|
+
.maude-notice-summary { color: var(--fg-1, #ddd); }
|
|
218
|
+
.maude-notice-close { position: absolute; right: 8px; top: 8px; border: 0; border-radius: 4px;
|
|
219
|
+
background: transparent; color: inherit; cursor: pointer; font-size: 18px; padding: 0 6px; }
|
|
220
|
+
.maude-notice-action { align-self: flex-start; border: 0; background: transparent; color: inherit;
|
|
221
|
+
font: inherit; text-decoration: underline; cursor: pointer; padding: 2px 0; }
|
|
222
|
+
.maude-notice button:focus-visible { outline: 2px solid var(--accent, #d63b1f); outline-offset: 2px; }
|
|
223
|
+
.maude-notice .st-toast-actions { flex-wrap: wrap; }
|
|
224
|
+
@media (prefers-reduced-motion: reduce) {
|
|
225
|
+
.maude-notifications [data-sonner-toast], .maude-notifications [data-sonner-toaster] {
|
|
226
|
+
animation: none !important; transition: none !important;
|
|
227
|
+
}
|
|
228
|
+
}`;
|
|
229
|
+
|
|
230
|
+
const NO_GROUPS: string[] = [];
|
|
231
|
+
export function NotificationHost({
|
|
232
|
+
paused = false,
|
|
233
|
+
hiddenGroups = NO_GROUPS,
|
|
234
|
+
}: {
|
|
235
|
+
paused?: boolean;
|
|
236
|
+
hiddenGroups?: string[];
|
|
237
|
+
}) {
|
|
238
|
+
const notices = useSyncExternalStore(subscribe, snapshot, snapshot);
|
|
239
|
+
const hostRef = useRef<HTMLDivElement>(null);
|
|
240
|
+
const [hovered, setHovered] = useState(false);
|
|
241
|
+
const [focused, setFocused] = useState(false);
|
|
242
|
+
const [hidden, setHidden] = useState(() => typeof document !== 'undefined' && document.hidden);
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
const change = () => setHidden(document.hidden);
|
|
245
|
+
document.addEventListener('visibilitychange', change);
|
|
246
|
+
return () => document.removeEventListener('visibilitychange', change);
|
|
247
|
+
}, []);
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
if (!notices.length) {
|
|
250
|
+
setHovered(false);
|
|
251
|
+
setFocused(false);
|
|
252
|
+
}
|
|
253
|
+
// Removing a focused DOM node does not reliably emit blur in browsers.
|
|
254
|
+
if (focused && !hostRef.current?.contains(document.activeElement)) setFocused(false);
|
|
255
|
+
}, [notices, focused]);
|
|
256
|
+
const visible = notices.filter((entry) => !hiddenGroups.includes(entry.group ?? ''));
|
|
257
|
+
// Publish only the latest three; older entries keep their clocks while waiting.
|
|
258
|
+
// This also keeps visibility and clocks aligned when a hidden group returns.
|
|
259
|
+
const timedIds = new Set(visible.slice(-3).map((entry) => entry.id));
|
|
260
|
+
return (
|
|
261
|
+
<div
|
|
262
|
+
ref={hostRef}
|
|
263
|
+
className="maude-notifications"
|
|
264
|
+
data-paused={paused}
|
|
265
|
+
onPointerEnter={() => setHovered(true)}
|
|
266
|
+
onPointerLeave={() => setHovered(false)}
|
|
267
|
+
onFocusCapture={() => setFocused(true)}
|
|
268
|
+
onBlurCapture={(event) => {
|
|
269
|
+
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false);
|
|
270
|
+
}}
|
|
271
|
+
>
|
|
272
|
+
<style>{NOTICE_CSS}</style>
|
|
273
|
+
<Toaster
|
|
274
|
+
position="bottom-left"
|
|
275
|
+
offset={{ left: 16, bottom: 84 }}
|
|
276
|
+
mobileOffset={{ left: 16, right: 16, bottom: 84 }}
|
|
277
|
+
visibleToasts={3}
|
|
278
|
+
expand={focused}
|
|
279
|
+
style={{ '--width': 'min(360px, calc(100vw - 32px))' } as CSSProperties}
|
|
280
|
+
containerAriaLabel="Notifications"
|
|
281
|
+
/>
|
|
282
|
+
{notices.map((entry) => (
|
|
283
|
+
<MountedNotice
|
|
284
|
+
key={entry.id}
|
|
285
|
+
entry={entry}
|
|
286
|
+
visible={timedIds.has(entry.id)}
|
|
287
|
+
paused={paused || hovered || focused || hidden || !timedIds.has(entry.id)}
|
|
288
|
+
/>
|
|
289
|
+
))}
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
@@ -47,6 +47,8 @@ export const RUNTIME_PACKAGES = [
|
|
|
47
47
|
'react-dom/client',
|
|
48
48
|
'react/jsx-runtime',
|
|
49
49
|
'react/jsx-dev-runtime',
|
|
50
|
+
// Toasts in standalone canvases resolve without node_modules in packaged installs.
|
|
51
|
+
'sonner',
|
|
50
52
|
// Pixi.js v8 — per-iframe runtime bundle for the non-destructive photo editor
|
|
51
53
|
// (feature-photo-editor). The canvas-lib `<PhotoLayer>` reaches the WebGL
|
|
52
54
|
// compositor (`photo/pipeline.ts`) through a LAZY runtime `import('pixi.js')`,
|
package/apps/studio/server.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// else binds to a port. The orchestrator (slash commands) reads _server.json
|
|
15
15
|
// to detect a live instance and avoid duplicate boots.
|
|
16
16
|
|
|
17
|
+
import path from 'node:path';
|
|
17
18
|
import { createAcp } from './acp/index.ts';
|
|
18
19
|
import { cancelInstall, cancelSignin } from './acp/login-state.ts';
|
|
19
20
|
import { createActivity } from './activity.ts';
|
|
@@ -78,7 +79,23 @@ let collab: ReturnType<typeof createCollab> | null = null;
|
|
|
78
79
|
let inspectHandle: ReturnType<typeof createInspectRegistry> | null = null;
|
|
79
80
|
|
|
80
81
|
const api = createApi(ctx, {
|
|
81
|
-
onCommentsChanged: (file, comments) => {
|
|
82
|
+
onCommentsChanged: (file, comments, base) => {
|
|
83
|
+
// Accepted-revisions mode (DDR-241): the change is PROPOSED; the room's
|
|
84
|
+
// document changes when the project publishes it. Fire-and-forget — the
|
|
85
|
+
// proposal is durable in the outbox before this returns, and the sidebar
|
|
86
|
+
// below already shows the author their own comment.
|
|
87
|
+
const runtime = ctx.syncControl?.current?.();
|
|
88
|
+
const proposed = runtime?.proposeLane?.(
|
|
89
|
+
api.fileSlug(file),
|
|
90
|
+
'comments',
|
|
91
|
+
JSON.stringify(comments),
|
|
92
|
+
base ? { baseText: JSON.stringify(base) } : {}
|
|
93
|
+
);
|
|
94
|
+
if (proposed) {
|
|
95
|
+
void proposed.catch(() => {});
|
|
96
|
+
ctx.bus.emit('comments', { file, comments });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
82
99
|
// Phase 8 Task 3 — bridge into the live Y.Array so collab peers see the
|
|
83
100
|
// change without waiting for cold-open re-seeding. No-op when no room is
|
|
84
101
|
// live for this canvas slug.
|
|
@@ -98,9 +115,18 @@ const api = createApi(ctx, {
|
|
|
98
115
|
},
|
|
99
116
|
// Phase 8 Task 5 — same bridge for annotations. PUT /_api/annotations writes
|
|
100
117
|
// the SVG blob to disk; we mirror it into the live Y.Map for collab peers.
|
|
101
|
-
onAnnotationsChanged: (file, svg) => {
|
|
118
|
+
onAnnotationsChanged: (file, svg, writeId, base) => {
|
|
119
|
+
const runtime = ctx.syncControl?.current?.();
|
|
120
|
+
const proposed = runtime?.proposeLane?.(api.fileSlug(file), 'annotations', svg, {
|
|
121
|
+
...(writeId ? { writeId } : {}),
|
|
122
|
+
...(base !== undefined ? { baseText: base } : {}),
|
|
123
|
+
});
|
|
124
|
+
if (proposed) {
|
|
125
|
+
void proposed.catch(() => {});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
102
128
|
if (collab) {
|
|
103
|
-
collab.registry.syncRoomFromAnnotations(api.fileSlug(file), svg);
|
|
129
|
+
collab.registry.syncRoomFromAnnotations(api.fileSlug(file), svg, writeId);
|
|
104
130
|
}
|
|
105
131
|
},
|
|
106
132
|
// feature-file-tree-drag-drop-folders (Task 3) — moveCanvas's collab guard
|
|
@@ -120,6 +146,13 @@ const api = createApi(ctx, {
|
|
|
120
146
|
return false;
|
|
121
147
|
}
|
|
122
148
|
},
|
|
149
|
+
proposeFolder: (op) => ctx.syncControl?.current?.()?.proposeFolder?.(op) ?? null,
|
|
150
|
+
onMetaChanged: (file, text, baseText) => {
|
|
151
|
+
const proposed = ctx.syncControl
|
|
152
|
+
?.current?.()
|
|
153
|
+
?.proposeLane?.(api.fileSlug(file), 'meta', text, baseText ? { baseText } : {});
|
|
154
|
+
if (proposed) void proposed.catch(() => {});
|
|
155
|
+
},
|
|
123
156
|
flushAndDropRoom: async (slug) => {
|
|
124
157
|
if (collab) await collab.registry.forceDrop(slug);
|
|
125
158
|
},
|
|
@@ -136,6 +169,19 @@ inspectHandle = inspects;
|
|
|
136
169
|
// arrives without a vouched session.
|
|
137
170
|
await inspects.for('').load();
|
|
138
171
|
|
|
172
|
+
// Semantic structural notifications include remote sync operations as well as
|
|
173
|
+
// API actions. Keep every session's agent/inspector context on the live path.
|
|
174
|
+
ctx.bus.on('canvas-list-update', (change) => {
|
|
175
|
+
if (!change?.rel) return;
|
|
176
|
+
const file = path.posix.join(ctx.paths.designRel, change.rel);
|
|
177
|
+
if (change.action === 'moved' && change.fromRel) {
|
|
178
|
+
const fromFile = path.posix.join(ctx.paths.designRel, change.fromRel);
|
|
179
|
+
for (const inspect of inspects.all()) inspect.retarget(fromFile, file);
|
|
180
|
+
} else if (change.action === 'removed') {
|
|
181
|
+
for (const inspect of inspects.all()) inspect.remove(file);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
139
185
|
collab = createCollab(ctx, api);
|
|
140
186
|
const aiActivity = createAiActivity(ctx);
|
|
141
187
|
|
|
@@ -152,6 +198,16 @@ ctx.bus.on('ai-activity', (payload: { file: string; entry: AiActivityEntry | nul
|
|
|
152
198
|
payload.entry ? { name: payload.entry.author, since: payload.entry.startedAt } : null
|
|
153
199
|
);
|
|
154
200
|
});
|
|
201
|
+
// T16 — an AI edit whose heartbeat went silent (the driver crashed) did not
|
|
202
|
+
// finish: whatever it wrote stays unpublished for the person's decision. An
|
|
203
|
+
// explicit /end already closed its action, so this is a no-op then.
|
|
204
|
+
ctx.bus.on('ai-activity', (payload: { file: string; entry: AiActivityEntry | null }) => {
|
|
205
|
+
if (payload.entry) return;
|
|
206
|
+
void ctx.syncControl
|
|
207
|
+
?.current?.()
|
|
208
|
+
?.endAiAction?.(`edit:${payload.file}`, 'failed')
|
|
209
|
+
?.catch(() => {});
|
|
210
|
+
});
|
|
155
211
|
const gitLifecycle = createGitLifecycle(ctx, collab.registry);
|
|
156
212
|
// Phase 13 / DDR-029 — fs-watch-driven canvas activity overlay. Subscribes to
|
|
157
213
|
// `fs:any` and emits `activity:change`; ws.ts forwards it to canvas iframes.
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Cold start under accepted revisions — DDR-241 §3, plan T13/T14.
|
|
2
|
+
//
|
|
3
|
+
// The legacy cold start (`migrate-seed.ts`) had to CHOOSE a source and write it
|
|
4
|
+
// into the shared document, because the document was writable and whatever a
|
|
5
|
+
// peer put there was the project. Here the document is a read-only replica of
|
|
6
|
+
// the accepted state, so cold start only ever DECIDES, per lane:
|
|
7
|
+
//
|
|
8
|
+
// agreed — disk already holds the accepted value;
|
|
9
|
+
// materialize — disk is older than the project (or absent): the projection
|
|
10
|
+
// writes the accepted value, keeping local bytes in recovery;
|
|
11
|
+
// propose — disk carries a change made on top of a known base: it is
|
|
12
|
+
// proposed, and the hub merges it three-way from that base;
|
|
13
|
+
// hold — disk differs and nothing proves what it was derived from:
|
|
14
|
+
// both are kept, the lane stays blocked with a visible
|
|
15
|
+
// conflict, and the next save resolves it (T2's rule — never a
|
|
16
|
+
// silent overwrite of either side).
|
|
17
|
+
//
|
|
18
|
+
// A canvas the project does not know yet is proposed as `doc.create` with all
|
|
19
|
+
// of its lanes, so it arrives on every peer as ONE action.
|
|
20
|
+
//
|
|
21
|
+
// Comments and annotations are never re-proposed from disk here: every change
|
|
22
|
+
// the studio made to them went through the durable outbox (drained before any
|
|
23
|
+
// cold start), so a difference on disk is an older accepted state the room had
|
|
24
|
+
// not re-projected — the accepted value wins, and the local file is kept in a
|
|
25
|
+
// recovery slot in case a raw edit made while the studio was down lived there.
|
|
26
|
+
|
|
27
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
28
|
+
import type * as Y from 'yjs';
|
|
29
|
+
|
|
30
|
+
import { cssFromDoc, htmlFromDoc, laneValueFromFile, readLaneFromDoc } from './codec.ts';
|
|
31
|
+
import { hashBytes } from './echo-guard.ts';
|
|
32
|
+
import type { SyncJournal } from './journal.ts';
|
|
33
|
+
import type { DocProjection, ProjectionPaths, ProposalLane } from './projection.ts';
|
|
34
|
+
import { readRecoveryBody, saveRecoveryBody } from './source-recovery.ts';
|
|
35
|
+
import { sourceError } from './source-validation.ts';
|
|
36
|
+
|
|
37
|
+
export type LaneDecision = 'agreed' | 'materialize' | 'propose' | 'hold';
|
|
38
|
+
|
|
39
|
+
export interface LaneVerdict {
|
|
40
|
+
lane: ProposalLane;
|
|
41
|
+
decision: LaneDecision;
|
|
42
|
+
/** For `propose`: the value the local edit was derived from. For `hold`: the accepted value adopted as the base. */
|
|
43
|
+
base?: string;
|
|
44
|
+
local?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readText(p: string | undefined): string | null {
|
|
48
|
+
if (!p || !existsSync(p)) return null;
|
|
49
|
+
try {
|
|
50
|
+
return readFileSync(p, 'utf8');
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Pure decision for the source lanes. `knownBase` is the last value disk and
|
|
58
|
+
* the accepted replica agreed on (recovery slot), `baseHash` the journal's
|
|
59
|
+
* hash of it when the bytes are gone.
|
|
60
|
+
*/
|
|
61
|
+
export function decideSourceLane(input: {
|
|
62
|
+
local: string | null;
|
|
63
|
+
accepted: string;
|
|
64
|
+
knownBase: string | null;
|
|
65
|
+
baseHash?: string | null;
|
|
66
|
+
}): { decision: LaneDecision; base?: string } {
|
|
67
|
+
const { local, accepted } = input;
|
|
68
|
+
if (local === null) return { decision: 'materialize' };
|
|
69
|
+
if (local === accepted) return { decision: 'agreed' };
|
|
70
|
+
let base = input.knownBase;
|
|
71
|
+
if (base === null && input.baseHash) {
|
|
72
|
+
if (hashBytes(accepted) === input.baseHash) base = accepted;
|
|
73
|
+
else if (hashBytes(local) === input.baseHash) base = local;
|
|
74
|
+
}
|
|
75
|
+
if (base === null) {
|
|
76
|
+
// The project never had this lane: nothing of anybody's can be lost.
|
|
77
|
+
if (accepted === '') return { decision: 'propose', base: '' };
|
|
78
|
+
return { decision: 'hold', base: accepted };
|
|
79
|
+
}
|
|
80
|
+
if (base === local) return { decision: 'materialize' }; // an older accepted state
|
|
81
|
+
return { decision: 'propose', base };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface AcceptedColdStartInput {
|
|
85
|
+
slug: string;
|
|
86
|
+
doc: Y.Doc;
|
|
87
|
+
paths: ProjectionPaths;
|
|
88
|
+
/** Design-root-relative body path (`ui/home.tsx`). */
|
|
89
|
+
rel: string;
|
|
90
|
+
/** Is this canvas a live document of the project's accepted state? */
|
|
91
|
+
inProject: boolean;
|
|
92
|
+
projection: DocProjection;
|
|
93
|
+
historyDir?: string;
|
|
94
|
+
journal?: SyncJournal;
|
|
95
|
+
createDoc: (lanes: Partial<Record<ProposalLane, string>>) => Promise<{
|
|
96
|
+
status: 'accepted' | 'rejected';
|
|
97
|
+
code?: string;
|
|
98
|
+
}>;
|
|
99
|
+
log?: Pick<Console, 'log' | 'warn'>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type AcceptedColdStartResult =
|
|
103
|
+
| { kind: 'created' }
|
|
104
|
+
| { kind: 'create-refused'; code?: string }
|
|
105
|
+
| { kind: 'reconciled'; verdicts: LaneVerdict[] };
|
|
106
|
+
|
|
107
|
+
export async function acceptedColdStart(
|
|
108
|
+
i: AcceptedColdStartInput
|
|
109
|
+
): Promise<AcceptedColdStartResult> {
|
|
110
|
+
const log = i.log ?? console;
|
|
111
|
+
const localHtml = readText(i.paths.html);
|
|
112
|
+
|
|
113
|
+
if (!i.inProject) {
|
|
114
|
+
if (localHtml === null) return { kind: 'reconciled', verdicts: [] };
|
|
115
|
+
const lanes: Partial<Record<ProposalLane, string>> = { html: localHtml };
|
|
116
|
+
const css = readText(i.paths.css);
|
|
117
|
+
if (css) lanes.css = css;
|
|
118
|
+
const metaText = readText(i.paths.meta);
|
|
119
|
+
const meta = metaText === null ? null : laneValueFromFile('meta', metaText);
|
|
120
|
+
if (meta && meta !== '{}') lanes.meta = meta;
|
|
121
|
+
const ann = readText(i.paths.annotations);
|
|
122
|
+
if (ann) lanes.annotations = ann;
|
|
123
|
+
const commentsText = readText(i.paths.comments);
|
|
124
|
+
const comments = commentsText === null ? null : laneValueFromFile('comments', commentsText);
|
|
125
|
+
if (comments) lanes.comments = comments;
|
|
126
|
+
const r = await i.createDoc(lanes);
|
|
127
|
+
if (r.status === 'accepted') {
|
|
128
|
+
// What the project now holds IS the local file: the next save is based
|
|
129
|
+
// on it, even if it lands before the publication reaches this replica.
|
|
130
|
+
i.projection.adoptBase(localHtml);
|
|
131
|
+
log.log(`[sync/${i.slug}] added to the project (accepted).`);
|
|
132
|
+
return { kind: 'created' };
|
|
133
|
+
}
|
|
134
|
+
log.warn(
|
|
135
|
+
`[sync/${i.slug}] the project did not accept this canvas (${r.code ?? 'rejected'}) — it stays local.`
|
|
136
|
+
);
|
|
137
|
+
return { kind: 'create-refused', code: r.code };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const verdicts: LaneVerdict[] = [];
|
|
141
|
+
|
|
142
|
+
// ---- html — the canvas source
|
|
143
|
+
const acceptedHtml = htmlFromDoc(i.doc);
|
|
144
|
+
if (localHtml !== null && localHtml !== acceptedHtml && sourceError(i.paths.html, localHtml)) {
|
|
145
|
+
// An invalid local body cannot be proposed; the projection keeps its bytes
|
|
146
|
+
// in recovery and reports it when it materializes the accepted source.
|
|
147
|
+
verdicts.push({ lane: 'html', decision: 'materialize', local: localHtml });
|
|
148
|
+
} else {
|
|
149
|
+
const d = decideSourceLane({
|
|
150
|
+
local: localHtml,
|
|
151
|
+
accepted: acceptedHtml,
|
|
152
|
+
knownBase: i.historyDir ? readRecoveryBody(i.historyDir, i.paths.html, 'base') : null,
|
|
153
|
+
baseHash: i.journal?.get(i.slug)?.bodyHash ?? null,
|
|
154
|
+
});
|
|
155
|
+
verdicts.push({ lane: 'html', ...d, ...(localHtml !== null ? { local: localHtml } : {}) });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---- css — opaque text, journal-checkpointed
|
|
159
|
+
if (i.paths.css) {
|
|
160
|
+
const localCss = readText(i.paths.css);
|
|
161
|
+
const d = decideSourceLane({
|
|
162
|
+
local: localCss,
|
|
163
|
+
accepted: cssFromDoc(i.doc) ?? '',
|
|
164
|
+
knownBase: i.historyDir ? readRecoveryBody(i.historyDir, i.paths.css, 'base') : null,
|
|
165
|
+
baseHash: i.journal?.get(i.slug)?.cssHash ?? null,
|
|
166
|
+
});
|
|
167
|
+
verdicts.push({ lane: 'css', ...d, ...(localCss !== null ? { local: localCss } : {}) });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---- meta — the shared layout keys (viewport never travels)
|
|
171
|
+
if (i.paths.meta) {
|
|
172
|
+
const metaText = readText(i.paths.meta);
|
|
173
|
+
const local = metaText === null ? null : laneValueFromFile('meta', metaText);
|
|
174
|
+
const accepted = readLaneFromDoc(i.doc, 'meta');
|
|
175
|
+
if (local === null || local === accepted || (local === '{}' && accepted === '')) {
|
|
176
|
+
verdicts.push({ lane: 'meta', decision: local === null ? 'materialize' : 'agreed' });
|
|
177
|
+
} else if (accepted === '') {
|
|
178
|
+
verdicts.push({ lane: 'meta', decision: 'propose', base: '', local });
|
|
179
|
+
} else {
|
|
180
|
+
// No base is recorded for layout: the project's arrangement wins and the
|
|
181
|
+
// projection merges it into the local file's private keys.
|
|
182
|
+
verdicts.push({ lane: 'meta', decision: 'materialize', local });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---- comments / annotations — the accepted value wins (see header)
|
|
187
|
+
for (const lane of ['comments', 'annotations'] as const) {
|
|
188
|
+
const p = lane === 'comments' ? i.paths.comments : i.paths.annotations;
|
|
189
|
+
const text = readText(p);
|
|
190
|
+
const local = text === null ? null : laneValueFromFile(lane, text);
|
|
191
|
+
const accepted = readLaneFromDoc(i.doc, lane);
|
|
192
|
+
if (local === null || local === accepted) {
|
|
193
|
+
verdicts.push({ lane, decision: local === null ? 'materialize' : 'agreed' });
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (i.historyDir && text) {
|
|
197
|
+
try {
|
|
198
|
+
saveRecoveryBody(i.historyDir, p, 'local', text);
|
|
199
|
+
} catch {
|
|
200
|
+
/* recovery is best-effort for these lanes */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
verdicts.push({ lane, decision: 'materialize', local: text ?? undefined });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---- act
|
|
207
|
+
for (const v of verdicts) {
|
|
208
|
+
if (v.decision === 'propose' && v.local !== undefined) {
|
|
209
|
+
const value = v.lane === 'meta' ? v.local : (laneValueFromFile(v.lane, v.local) ?? v.local);
|
|
210
|
+
if (v.lane === 'html') i.projection.adoptBase(v.base ?? '');
|
|
211
|
+
// Stageable: a restored unfinished AI action (T16) keeps its canvases'
|
|
212
|
+
// differences for the person's decision instead of publishing them.
|
|
213
|
+
void i.projection.proposeLane(v.lane, value, { baseContent: v.base ?? '', stageable: true });
|
|
214
|
+
} else if (v.decision === 'hold' && v.local !== undefined) {
|
|
215
|
+
i.projection.hold(v.lane, v.base ?? '', v.local);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const moved = verdicts.filter((v) => v.decision === 'propose' || v.decision === 'hold');
|
|
219
|
+
if (moved.length) {
|
|
220
|
+
log.log(
|
|
221
|
+
`[sync/${i.slug}] cold start: ${moved.map((v) => `${v.lane}=${v.decision}`).join(', ')}`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return { kind: 'reconciled', verdicts };
|
|
225
|
+
}
|