@1agh/maude 1.4.1 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/canvas-lib.tsx +28 -6
- package/apps/studio/client/app.jsx +4 -10
- package/apps/studio/client/command-palette-match.js +35 -0
- package/apps/studio/collab/index.ts +40 -10
- package/apps/studio/collab/persistence.ts +16 -4
- package/apps/studio/collab/registry.ts +26 -10
- package/apps/studio/dist/client.bundle.js +480 -480
- package/apps/studio/http.ts +27 -8
- package/apps/studio/sync/index.ts +129 -22
- package/apps/studio/whats-new.json +9 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +32 -1
- package/plugins/flow/.claude-plugin/config.schema.json +33 -0
|
@@ -3200,6 +3200,21 @@ export function PhotoPreviewBridge() {
|
|
|
3200
3200
|
// clobbering a NEWER edit that already resolved first.
|
|
3201
3201
|
const tokenRef = useRef<Map<string, number>>(new Map());
|
|
3202
3202
|
const bakeTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
3203
|
+
// Per-asset FETCH generation — the same guard one step earlier. Two synced
|
|
3204
|
+
// edits in a row start two sidecar fetches, and the older answer can arrive
|
|
3205
|
+
// last: applying it put the previous edit back on screen while every disk had
|
|
3206
|
+
// the newer one (surface run 2026-09-16, L13 on the hub: shown, then
|
|
3207
|
+
// reverted). A live preview counts as newer than any fetch in flight.
|
|
3208
|
+
const fetchGenRef = useRef<Map<string, number>>(new Map());
|
|
3209
|
+
const nextFetchGen = useCallback((asset: string): number => {
|
|
3210
|
+
const g = (fetchGenRef.current.get(asset) ?? 0) + 1;
|
|
3211
|
+
fetchGenRef.current.set(asset, g);
|
|
3212
|
+
return g;
|
|
3213
|
+
}, []);
|
|
3214
|
+
const isLatestFetch = useCallback(
|
|
3215
|
+
(asset: string, g: number): boolean => fetchGenRef.current.get(asset) === g,
|
|
3216
|
+
[]
|
|
3217
|
+
);
|
|
3203
3218
|
|
|
3204
3219
|
const bake = useCallback((asset: string, original: string, edit: PhotoEdit) => {
|
|
3205
3220
|
const token = (tokenRef.current.get(asset) ?? 0) + 1;
|
|
@@ -3231,6 +3246,8 @@ export function PhotoPreviewBridge() {
|
|
|
3231
3246
|
clearTimeout(timers.get(asset));
|
|
3232
3247
|
if (!edit || isDefaultEdit(edit)) {
|
|
3233
3248
|
timers.delete(asset);
|
|
3249
|
+
// A bake already rendering an earlier edit must not land over this.
|
|
3250
|
+
tokenRef.current.set(asset, (tokenRef.current.get(asset) ?? 0) + 1);
|
|
3234
3251
|
setPhotoElSrc(el, original);
|
|
3235
3252
|
return;
|
|
3236
3253
|
}
|
|
@@ -3270,17 +3287,20 @@ export function PhotoPreviewBridge() {
|
|
|
3270
3287
|
}
|
|
3271
3288
|
}
|
|
3272
3289
|
if (!asset) return; // no photo on this canvas uses that asset
|
|
3273
|
-
|
|
3290
|
+
const target = asset;
|
|
3291
|
+
const g = nextFetchGen(target);
|
|
3292
|
+
fetch(`/_api/photo-edit?asset=${encodeURIComponent(target)}`)
|
|
3274
3293
|
.then((r) => (r.ok ? r.json() : null))
|
|
3275
3294
|
.then((edit) => {
|
|
3295
|
+
if (!isLatestFetch(target, g)) return; // a newer answer owns the render
|
|
3276
3296
|
// A deleted/reset edit applies as null → restores the original src.
|
|
3277
|
-
apply(
|
|
3297
|
+
apply(target, edit && !isDefaultEdit(edit) ? (edit as PhotoEdit) : null);
|
|
3278
3298
|
})
|
|
3279
3299
|
.catch(() => {});
|
|
3280
3300
|
};
|
|
3281
3301
|
document.addEventListener('maude:photo-edit-refreshed', onRefreshed);
|
|
3282
3302
|
return () => document.removeEventListener('maude:photo-edit-refreshed', onRefreshed);
|
|
3283
|
-
}, [apply]);
|
|
3303
|
+
}, [apply, nextFetchGen, isLatestFetch]);
|
|
3284
3304
|
|
|
3285
3305
|
useEffect(() => {
|
|
3286
3306
|
const onMsg = (e: MessageEvent) => {
|
|
@@ -3304,11 +3324,12 @@ export function PhotoPreviewBridge() {
|
|
|
3304
3324
|
if (m.dgn !== 'photo-preview' || typeof m.asset !== 'string') return;
|
|
3305
3325
|
const asset = m.asset;
|
|
3306
3326
|
const edit = (m.edit ?? null) as PhotoEdit | null;
|
|
3327
|
+
nextFetchGen(asset);
|
|
3307
3328
|
apply(asset, edit);
|
|
3308
3329
|
};
|
|
3309
3330
|
window.addEventListener('message', onMsg);
|
|
3310
3331
|
return () => window.removeEventListener('message', onMsg);
|
|
3311
|
-
}, [apply]);
|
|
3332
|
+
}, [apply, nextFetchGen]);
|
|
3312
3333
|
// Boot-time (+ ongoing) hydration from the PERSISTED sidecar, not just the
|
|
3313
3334
|
// live `photo-preview` message above. Without this, a saved PhotoEdit is
|
|
3314
3335
|
// invisible after anything that re-mounts the canvas doc (Cmd+R, an
|
|
@@ -3342,10 +3363,11 @@ export function PhotoPreviewBridge() {
|
|
|
3342
3363
|
if (!asset || attempted.has(asset)) continue;
|
|
3343
3364
|
if (++scanned > MAX_SCAN_PER_PASS) break;
|
|
3344
3365
|
attempted.add(asset);
|
|
3366
|
+
const g = nextFetchGen(asset);
|
|
3345
3367
|
fetch(`/_api/photo-edit?asset=${encodeURIComponent(asset)}`)
|
|
3346
3368
|
.then((r) => (r.ok ? r.json() : null))
|
|
3347
3369
|
.then((edit) => {
|
|
3348
|
-
if (cancelled || !edit || isDefaultEdit(edit)) return;
|
|
3370
|
+
if (cancelled || !isLatestFetch(asset, g) || !edit || isDefaultEdit(edit)) return;
|
|
3349
3371
|
apply(asset, edit);
|
|
3350
3372
|
})
|
|
3351
3373
|
.catch(() => {});
|
|
@@ -3367,7 +3389,7 @@ export function PhotoPreviewBridge() {
|
|
|
3367
3389
|
cancelAnimationFrame(raf);
|
|
3368
3390
|
observer.disconnect();
|
|
3369
3391
|
};
|
|
3370
|
-
}, [apply]);
|
|
3392
|
+
}, [apply, nextFetchGen, isLatestFetch]);
|
|
3371
3393
|
return null; // mutates the real elements directly — no visible DOM of its own.
|
|
3372
3394
|
}
|
|
3373
3395
|
PhotoPreviewBridge.displayName = 'PhotoPreviewBridge';
|
|
@@ -12,6 +12,7 @@ import { createRoot } from 'react-dom/client';
|
|
|
12
12
|
// import that Bun erases), so this pulls only string constants into the client
|
|
13
13
|
// bundle — no React, no input-router. See the tool-cursor handler below.
|
|
14
14
|
import { resolveToolCursor } from '../canvas-cursors.ts';
|
|
15
|
+
import { commandForEnter, matchCommands } from './command-palette-match.js';
|
|
15
16
|
import {
|
|
16
17
|
defaultScopeForFormat,
|
|
17
18
|
isScopeValidForFormat,
|
|
@@ -878,15 +879,7 @@ function CommandPalette({ open, onClose, actions }) {
|
|
|
878
879
|
?.querySelector('.st-pal-item.is-active')
|
|
879
880
|
?.scrollIntoView({ block: 'nearest' });
|
|
880
881
|
}, [active]);
|
|
881
|
-
const filtered = useMemo(() =>
|
|
882
|
-
const needle = q.trim().toLowerCase();
|
|
883
|
-
if (!needle) return actions;
|
|
884
|
-
return actions.filter(
|
|
885
|
-
(a) =>
|
|
886
|
-
a.label.toLowerCase().includes(needle) ||
|
|
887
|
-
(a.group && a.group.toLowerCase().includes(needle))
|
|
888
|
-
);
|
|
889
|
-
}, [q, actions]);
|
|
882
|
+
const filtered = useMemo(() => matchCommands(actions, q), [q, actions]);
|
|
890
883
|
useEffect(() => {
|
|
891
884
|
if (active >= filtered.length) setActive(0);
|
|
892
885
|
}, [filtered.length, active]);
|
|
@@ -925,7 +918,8 @@ function CommandPalette({ open, onClose, actions }) {
|
|
|
925
918
|
setActive((i) => Math.max(0, i - 1));
|
|
926
919
|
} else if (e.key === 'Enter') {
|
|
927
920
|
e.preventDefault();
|
|
928
|
-
|
|
921
|
+
const pick = commandForEnter(actions, q, e.currentTarget.value, active);
|
|
922
|
+
if (pick) run(pick);
|
|
929
923
|
}
|
|
930
924
|
}}
|
|
931
925
|
/>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// command-palette-match.js — which ⌘K command a query names.
|
|
2
|
+
//
|
|
3
|
+
// Kept out of app.jsx so the one decision that matters — what Enter runs —
|
|
4
|
+
// can be tested on its own.
|
|
5
|
+
|
|
6
|
+
/** The commands a query matches, in list order (label or group substring). */
|
|
7
|
+
export function matchCommands(actions, query) {
|
|
8
|
+
const needle = String(query ?? '')
|
|
9
|
+
.trim()
|
|
10
|
+
.toLowerCase();
|
|
11
|
+
if (!needle) return actions;
|
|
12
|
+
return actions.filter(
|
|
13
|
+
(a) => a.label.toLowerCase().includes(needle) || (a.group && a.group.toLowerCase().includes(needle))
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The command Enter runs.
|
|
19
|
+
*
|
|
20
|
+
* WHAT IS IN THE BOX, not what the last render saw. A query typed or pasted
|
|
21
|
+
* and confirmed at once can reach Enter before the list re-rendered for it;
|
|
22
|
+
* the rendered list is then the unfiltered one and its first row — "New brief
|
|
23
|
+
* board" — ran instead of the command that was asked for (surface run
|
|
24
|
+
* 2026-09-16, "New video" on the hub). When the box and the rendered query
|
|
25
|
+
* disagree, the box wins and its first match runs; when they agree, the
|
|
26
|
+
* highlighted row does.
|
|
27
|
+
*/
|
|
28
|
+
export function commandForEnter(actions, renderedQuery, liveQuery, activeIndex) {
|
|
29
|
+
const norm = (s) =>
|
|
30
|
+
String(s ?? '')
|
|
31
|
+
.trim()
|
|
32
|
+
.toLowerCase();
|
|
33
|
+
if (norm(liveQuery) !== norm(renderedQuery)) return matchCommands(actions, liveQuery)[0] ?? null;
|
|
34
|
+
return matchCommands(actions, renderedQuery)[activeIndex] ?? null;
|
|
35
|
+
}
|
|
@@ -1,14 +1,15 @@
|
|
|
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';
|
|
4
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
7
|
import type { Api } from '../api.ts';
|
|
8
8
|
import type { Context } from '../context.ts';
|
|
9
9
|
|
|
10
|
+
import { applyCommentsToDoc } from '../sync/codec.ts';
|
|
10
11
|
import { createPersistence, Y_TYPES } from './persistence.ts';
|
|
11
|
-
import { createRegistry, type Registry } from './registry.ts';
|
|
12
|
+
import { applyAnnotationsToDoc, createRegistry, type Registry } from './registry.ts';
|
|
12
13
|
|
|
13
14
|
export type { CollabConn } from './protocol.ts';
|
|
14
15
|
export type { Registry } from './registry.ts';
|
|
@@ -63,6 +64,29 @@ export function createCollab(ctx: Context, api: Api): Collab {
|
|
|
63
64
|
api,
|
|
64
65
|
fileForSlug,
|
|
65
66
|
shouldSeed: (slug) => !(ctx.sharedDoc && registryRef?.isPinned(slug)),
|
|
67
|
+
// A room restored from its own `.ydoc.bin` must not outrank a sidecar the
|
|
68
|
+
// hub (or an editor) wrote after that cache — see `reconcileAfterCache`.
|
|
69
|
+
// Only for rooms no hub provider owns: a pinned doc is the hub's replica
|
|
70
|
+
// and file→doc imports belong to the sync agent's diff-aware lane.
|
|
71
|
+
reconcileAfterCache: async (slug, doc, cachedAtMs) => {
|
|
72
|
+
if (registryRef?.isPinned(slug)) return;
|
|
73
|
+
const newerThanCache = (abs: string): boolean => {
|
|
74
|
+
try {
|
|
75
|
+
return statSync(abs).mtimeMs > cachedAtMs;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const file = await fileForSlug(slug);
|
|
81
|
+
if (!file) return;
|
|
82
|
+
if (newerThanCache(path.join(ctx.paths.designRoot, `${slug}.annotations.svg`))) {
|
|
83
|
+
const svg = await api.loadAnnotations(file);
|
|
84
|
+
if (svg) applyAnnotationsToDoc(doc, svg, 'seed');
|
|
85
|
+
}
|
|
86
|
+
if (newerThanCache(path.join(ctx.paths.commentsDir, `${slug}.json`))) {
|
|
87
|
+
applyCommentsToDoc(doc, await api.loadCommentsForFile(file), 'seed');
|
|
88
|
+
}
|
|
89
|
+
},
|
|
66
90
|
});
|
|
67
91
|
// Accepted revisions: browser writes to a synced canvas's room are refused
|
|
68
92
|
// (the room doc is the hub's accepted replica). Asked per frame — the save
|
|
@@ -85,6 +109,10 @@ export function createCollab(ctx: Context, api: Api): Collab {
|
|
|
85
109
|
// a hub-pushed comment shows up on the peer's sidebar without a reload.
|
|
86
110
|
// The registry's no-op guards make an identical re-seed free, so this can't
|
|
87
111
|
// loop against the room's own persist (which also writes the file).
|
|
112
|
+
// A mounted room with no hub provider takes external disk changes; a pinned
|
|
113
|
+
// one leaves them to the sync agent.
|
|
114
|
+
const ownsRoomFromDisk = (slug: string): boolean =>
|
|
115
|
+
registry.peek(slug) !== null && !registry.isPinned(slug);
|
|
88
116
|
const reseedFromDisk = async (rel: string): Promise<void> => {
|
|
89
117
|
const cm = /^_comments\/(.+)\.json$/.exec(rel);
|
|
90
118
|
const am = /^(.+)\.annotations\.svg$/.exec(rel);
|
|
@@ -101,15 +129,17 @@ export function createCollab(ctx: Context, api: Api): Collab {
|
|
|
101
129
|
? await api.loadCommentsForFile(file)
|
|
102
130
|
: JSON.parse(readFileSync(abs, 'utf8'));
|
|
103
131
|
if (!Array.isArray(parsed)) return;
|
|
104
|
-
// Phase 9.2 (DDR-064):
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// clobber path. The
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
132
|
+
// Phase 9.2 (DDR-064): when a hub provider is attached to the room
|
|
133
|
+
// (pinned), a hub-pushed comment is ALREADY in the doc and the agent's
|
|
134
|
+
// diff-aware applyFromFs owns external file→doc imports — re-seeding
|
|
135
|
+
// would be the retired clobber path. The test is the PIN, not
|
|
136
|
+
// `ctx.sharedDoc`: sharedDoc defaults on everywhere, including a cell
|
|
137
|
+
// without live pairing, where no provider exists and disk is the only
|
|
138
|
+
// way the hub's accepted state reaches the room. The sidebar still
|
|
139
|
+
// needs the 'comments' bus emit, so keep that unconditionally.
|
|
140
|
+
if (ownsRoomFromDisk(slug)) registry.syncRoomFromComments(slug, parsed);
|
|
111
141
|
if (file) ctx.bus.emit('comments', { file, comments: parsed });
|
|
112
|
-
} else if (
|
|
142
|
+
} else if (ownsRoomFromDisk(slug)) {
|
|
113
143
|
registry.syncRoomFromAnnotations(slug, readFileSync(abs, 'utf8'));
|
|
114
144
|
}
|
|
115
145
|
} catch {
|
|
@@ -49,6 +49,17 @@ export interface PersistenceDeps {
|
|
|
49
49
|
* slugs. Absent → always seed (flag-OFF behavior, unchanged).
|
|
50
50
|
*/
|
|
51
51
|
shouldSeed?: (slug: string) => boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Called after `seed` restored a room from its `.ydoc.bin` cache, with the
|
|
54
|
+
* cache's mtime. The cache is this process's own snapshot, so it can be older
|
|
55
|
+
* than the on-disk sidecars: in a cell without live pairing the hub projects
|
|
56
|
+
* accepted comments/annotations onto disk while the room is closed, and a room
|
|
57
|
+
* rebuilt from the cache alone would serve the pre-projection board forever.
|
|
58
|
+
* The callback brings the doc forward from any sidecar written after the cache
|
|
59
|
+
* (a causal update ON TOP of the cached state, never a second independent
|
|
60
|
+
* seed). Absent → cache-only restore, the previous behavior.
|
|
61
|
+
*/
|
|
62
|
+
reconcileAfterCache?: (slug: string, doc: Y.Doc, cachedAtMs: number) => Promise<void>;
|
|
52
63
|
}
|
|
53
64
|
|
|
54
65
|
/**
|
|
@@ -173,12 +184,12 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
|
|
|
173
184
|
return path.join(stateDir, `${slug}.ydoc.bin`);
|
|
174
185
|
}
|
|
175
186
|
|
|
176
|
-
async function readBinary(slug: string): Promise<Uint8Array | null> {
|
|
187
|
+
async function readBinary(slug: string): Promise<{ bytes: Uint8Array; mtimeMs: number } | null> {
|
|
177
188
|
try {
|
|
178
189
|
const file = Bun.file(ydocBinPath(slug));
|
|
179
190
|
if (!(await file.exists())) return null;
|
|
180
191
|
const buf = await file.arrayBuffer();
|
|
181
|
-
return new Uint8Array(buf);
|
|
192
|
+
return { bytes: new Uint8Array(buf), mtimeMs: file.lastModified };
|
|
182
193
|
} catch {
|
|
183
194
|
return null;
|
|
184
195
|
}
|
|
@@ -201,8 +212,9 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
|
|
|
201
212
|
|
|
202
213
|
// Step 1 — try the binary cache.
|
|
203
214
|
const binary = await readBinary(slug);
|
|
204
|
-
if (binary && binary.byteLength > 0) {
|
|
205
|
-
Y.applyUpdate(doc, binary);
|
|
215
|
+
if (binary && binary.bytes.byteLength > 0) {
|
|
216
|
+
Y.applyUpdate(doc, binary.bytes);
|
|
217
|
+
await deps.reconcileAfterCache?.(slug, doc, binary.mtimeMs);
|
|
206
218
|
return;
|
|
207
219
|
}
|
|
208
220
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// prompt — see DDR-051 §3.
|
|
8
8
|
|
|
9
9
|
import type { Awareness } from 'y-protocols/awareness';
|
|
10
|
+
import type * as Y from 'yjs';
|
|
10
11
|
|
|
11
12
|
import { ANNOTATION_WRITE_ID, validAnnotationWriteId } from '../annotations-sync.ts';
|
|
12
13
|
import { applyCommentsToDoc } from '../sync/codec.ts';
|
|
@@ -108,6 +109,30 @@ export interface Registry {
|
|
|
108
109
|
size(): number;
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Replace a doc's annotation SVG — the one write shape shared by the live
|
|
114
|
+
* disk→room bridge and the cache-restore reconcile (`collab/index.ts`).
|
|
115
|
+
* Identical disk notifications stay no-ops (no update → no persist loop); a UI
|
|
116
|
+
* operation may deliberately restore identical content under a new write id.
|
|
117
|
+
* Returns whether the doc changed.
|
|
118
|
+
*/
|
|
119
|
+
export function applyAnnotationsToDoc(
|
|
120
|
+
doc: Y.Doc,
|
|
121
|
+
svg: string,
|
|
122
|
+
origin: unknown,
|
|
123
|
+
writeId?: string
|
|
124
|
+
): boolean {
|
|
125
|
+
const map = doc.getMap<string>(Y_TYPES.annotations);
|
|
126
|
+
const id = validAnnotationWriteId(writeId) ? writeId : undefined;
|
|
127
|
+
if (map.get('svg') === svg && (!id || map.get(ANNOTATION_WRITE_ID) === id)) return false;
|
|
128
|
+
doc.transact(() => {
|
|
129
|
+
map.set('svg', svg);
|
|
130
|
+
if (id) map.set(ANNOTATION_WRITE_ID, id);
|
|
131
|
+
else map.delete(ANNOTATION_WRITE_ID);
|
|
132
|
+
}, origin);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
111
136
|
export function createRegistry(callbacks: RoomCallbacks): Registry {
|
|
112
137
|
const rooms = new Map<string, Room>();
|
|
113
138
|
// Hub-side Awareness per slug (lives as long as the sync provider). Rooms
|
|
@@ -196,16 +221,7 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
|
|
|
196
221
|
function syncRoomFromAnnotations(slug: string, svg: string, writeId?: string): void {
|
|
197
222
|
const room = rooms.get(slug);
|
|
198
223
|
if (!room) return;
|
|
199
|
-
|
|
200
|
-
// Identical disk notifications remain no-ops. A new UI operation may
|
|
201
|
-
// deliberately restore identical content while an author has pending work.
|
|
202
|
-
const id = validAnnotationWriteId(writeId) ? writeId : undefined;
|
|
203
|
-
if (map.get('svg') === svg && (!id || map.get(ANNOTATION_WRITE_ID) === id)) return;
|
|
204
|
-
room.doc.transact(() => {
|
|
205
|
-
map.set('svg', svg);
|
|
206
|
-
if (id) map.set(ANNOTATION_WRITE_ID, id);
|
|
207
|
-
else map.delete(ANNOTATION_WRITE_ID);
|
|
208
|
-
}, 'inspector-write');
|
|
224
|
+
applyAnnotationsToDoc(room.doc, svg, 'inspector-write', writeId);
|
|
209
225
|
}
|
|
210
226
|
|
|
211
227
|
function setAgentEditing(slug: string, state: { name: string; since: number } | null): void {
|