@1agh/maude 1.4.1 → 1.4.2

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.
@@ -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
- fetch(`/_api/photo-edit?asset=${encodeURIComponent(asset)}`)
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(asset as string, edit && !isDefaultEdit(edit) ? (edit as PhotoEdit) : null);
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
- if (filtered[active]) run(filtered[active]);
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
+ }