@1agh/maude 1.4.4 → 1.4.5

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.
Files changed (34) hide show
  1. package/apps/studio/api.ts +4 -0
  2. package/apps/studio/canvas-comment-mount.tsx +13 -1
  3. package/apps/studio/canvas-shell.tsx +62 -13
  4. package/apps/studio/canvas-source-memo.ts +39 -0
  5. package/apps/studio/canvas-text-patch.ts +117 -0
  6. package/apps/studio/client/app.jsx +105 -60
  7. package/apps/studio/client/file-tree.jsx +157 -0
  8. package/apps/studio/client/index.html +1 -1
  9. package/apps/studio/client/panels/DiffView.jsx +37 -8
  10. package/apps/studio/client/panels/GitPanel.jsx +2 -1
  11. package/apps/studio/client/panels/SourceConflictPanel.jsx +19 -17
  12. package/apps/studio/client/styles/3-shell-maude.css +21 -9
  13. package/apps/studio/client/styles/4-components.css +4 -2
  14. package/apps/studio/dist/client.bundle.js +1086 -1086
  15. package/apps/studio/dist/comment-mount.js +2 -2
  16. package/apps/studio/dist/styles.css +1 -1
  17. package/apps/studio/hmr-broadcast.ts +59 -3
  18. package/apps/studio/http.ts +3 -0
  19. package/apps/studio/sync/accepted-cold-start.ts +35 -3
  20. package/apps/studio/sync/accepted-link.ts +165 -29
  21. package/apps/studio/sync/file-plane.ts +24 -1
  22. package/apps/studio/sync/index.ts +87 -17
  23. package/apps/studio/sync/journal-client.ts +9 -3
  24. package/apps/studio/sync/projection.ts +114 -11
  25. package/apps/studio/sync/remote-docs.ts +3 -2
  26. package/apps/studio/sync/source-recovery.ts +67 -0
  27. package/apps/studio/sync/status.ts +11 -3
  28. package/apps/studio/sync/transaction-client.ts +49 -4
  29. package/apps/studio/use-selection-set.tsx +9 -3
  30. package/apps/studio/use-undo-stack.tsx +22 -2
  31. package/apps/studio/whats-new.json +9 -0
  32. package/cli/lib/workspace-plan.mjs +10 -0
  33. package/package.json +8 -8
  34. package/plugins/design/templates/_shell.html +21 -0
@@ -4234,6 +4234,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4234
4234
  }
4235
4235
  const after = await Bun.file(abs).text();
4236
4236
  const seq = after !== before ? logUndo(abs, before, after) : undefined;
4237
+ // This API write is complete: accepted sync need not wait for an
4238
+ // external editor's quiet window. It is still only a proposal, not an
4239
+ // acknowledgement; the normal hub validation/acceptance path owns that.
4240
+ if (after !== before) ctx.bus.emit('source-written', { rel, content: after });
4237
4241
  return { ok: true, delta: res.delta, seq, ...previous };
4238
4242
  } catch (err) {
4239
4243
  ctx.bus.emit('activity:unsuppress', rel);
@@ -636,7 +636,19 @@ function CanvasHmrRuntime({
636
636
  lastGood.current = canvasRef.current;
637
637
  // The new canvas rendered clean → drop any holding state.
638
638
  setHoldingState((h) => (h.on ? { on: false } : h));
639
- }, []);
639
+ // A soft replacement keeps the iframe document, so inspect.ts does not
640
+ // repeat its initial loaded handshake. Re-announce only AFTER the new
641
+ // subtree commits: the shell then reselects the current element and reads
642
+ // fresh attributes/styles instead of retaining the pre-restore inspector.
643
+ // Failed renders keep the last good tree and must not claim a new load.
644
+ if (attempt > 0 && file) {
645
+ try {
646
+ window.parent.postMessage({ dgn: 'loaded', file }, '*');
647
+ } catch {
648
+ // The parent can detach while an imported canvas is committing.
649
+ }
650
+ }
651
+ }, [attempt, file]);
640
652
 
641
653
  const handleError = useCallback(() => {
642
654
  setHoldingState({ on: true, message: 'render error' });
@@ -2422,6 +2422,38 @@ function isLockedElement(el: Element | null, cdId: string | null): boolean {
2422
2422
  // ─────────────────────────────────────────────────────────────────────────────
2423
2423
  // Router wire-up
2424
2424
 
2425
+ // The shell writes an inspector edit, THEN posts `record-edit` for it. The
2426
+ // write reaches disk before the shell has its response, so a Cmd+Z pressed in
2427
+ // that window found the stack one entry short and inverted the edit BELOW the
2428
+ // one on screen — refused as "changed by someone else", or worse, applied.
2429
+ // Before undo/redo, ask the shell to answer once its recordable writes have
2430
+ // settled. Messages between two windows arrive in order, so every
2431
+ // `record-edit` those writes produce is delivered before the answer. Bounded:
2432
+ // a shell that never answers costs a short wait, not the undo.
2433
+ export function afterShellRecords(timeoutMs = 3000): Promise<void> {
2434
+ if (typeof window === 'undefined' || window.parent === window) return Promise.resolve();
2435
+ const requestId = `ub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
2436
+ return new Promise<void>((resolve) => {
2437
+ const done = () => {
2438
+ clearTimeout(timer);
2439
+ window.removeEventListener('message', onReply);
2440
+ resolve();
2441
+ };
2442
+ const onReply = (e: MessageEvent) => {
2443
+ const d = e.data as { dgn?: string; requestId?: string } | null;
2444
+ if (e.source === window.parent && d?.dgn === 'undo-barrier-ok' && d.requestId === requestId)
2445
+ done();
2446
+ };
2447
+ const timer = setTimeout(done, timeoutMs);
2448
+ window.addEventListener('message', onReply);
2449
+ try {
2450
+ window.parent.postMessage({ dgn: 'undo-barrier', requestId }, '*');
2451
+ } catch {
2452
+ done();
2453
+ }
2454
+ });
2455
+ }
2456
+
2425
2457
  function CanvasRouter({
2426
2458
  hostRef,
2427
2459
  children,
@@ -2443,6 +2475,11 @@ function CanvasRouter({
2443
2475
  // without making them a hook dependency — avoids re-binding on every undo op.
2444
2476
  const undoStackRef = useRef(undoStack);
2445
2477
  undoStackRef.current = undoStack;
2478
+ // Selection readback can refresh while the user is typing (e.g. the shell's
2479
+ // post-HMR halo restore). Keep the editor's lifetime tied to its host, not
2480
+ // the changing selection snapshot, or effect cleanup silently ends typing.
2481
+ const textSelectionRef = useRef(selSet);
2482
+ textSelectionRef.current = selSet;
2446
2483
 
2447
2484
  // Phase: inline-edit undo (DDR-103/104 follow-up). Wire the `editSourceApplyFn`
2448
2485
  // sink so an `edit-source` command's do()/undo() reaches the main-origin-only
@@ -2454,10 +2491,17 @@ function CanvasRouter({
2454
2491
  // or any failed write) rejects, the stack keeps the entry instead of moving
2455
2492
  // its cursor, and the user is told why.
2456
2493
  useEffect(() => {
2494
+ // HMR replaces this React subtree, not the iframe or the parent write.
2495
+ // Drain already-sent requests after cleanup instead of losing their ACK
2496
+ // (and, on conflict, their user-facing refusal). Timers remain bounded.
2497
+ let disposed = false;
2457
2498
  const pending = new Map<
2458
2499
  string,
2459
2500
  { resolve: () => void; reject: (err: Error) => void; timer: ReturnType<typeof setTimeout> }
2460
2501
  >();
2502
+ const detachIfIdle = () => {
2503
+ if (disposed && pending.size === 0) window.removeEventListener('message', onResult);
2504
+ };
2461
2505
  const onResult = (event: MessageEvent) => {
2462
2506
  if (event.source !== window.parent) return;
2463
2507
  const data = event.data as
@@ -2468,6 +2512,7 @@ function CanvasRouter({
2468
2512
  if (!entry) return;
2469
2513
  pending.delete(data.requestId);
2470
2514
  clearTimeout(entry.timer);
2515
+ detachIfIdle();
2471
2516
  if (data.ok === true) {
2472
2517
  entry.resolve();
2473
2518
  return;
@@ -2482,11 +2527,16 @@ function CanvasRouter({
2482
2527
  let sequence = 0;
2483
2528
  const applyFn: EditSourceApplyFn = (apply) =>
2484
2529
  new Promise<void>((resolve, reject) => {
2530
+ if (disposed) {
2531
+ reject(new Error('the canvas closed'));
2532
+ return;
2533
+ }
2485
2534
  sequence += 1;
2486
2535
  const requestId = `${Date.now().toString(36)}-${sequence}-${Math.random().toString(36).slice(2, 8)}`;
2487
2536
  // Generous: the shell serializes these writes behind any in-flight one.
2488
2537
  const timer = setTimeout(() => {
2489
2538
  pending.delete(requestId);
2539
+ detachIfIdle();
2490
2540
  reject(new Error('the edit was not confirmed'));
2491
2541
  }, 30_000);
2492
2542
  pending.set(requestId, { resolve, reject, timer });
@@ -2496,17 +2546,14 @@ function CanvasRouter({
2496
2546
  /* detached / cross-origin teardown — nothing to apply */
2497
2547
  pending.delete(requestId);
2498
2548
  clearTimeout(timer);
2549
+ detachIfIdle();
2499
2550
  reject(new Error('the canvas is detached'));
2500
2551
  }
2501
2552
  });
2502
2553
  undoSinks.setSink('editSourceApplyFn', applyFn);
2503
2554
  return () => {
2504
- window.removeEventListener('message', onResult);
2505
- for (const entry of pending.values()) {
2506
- clearTimeout(entry.timer);
2507
- entry.reject(new Error('the canvas closed'));
2508
- }
2509
- pending.clear();
2555
+ disposed = true;
2556
+ detachIfIdle();
2510
2557
  undoSinks.setSink('editSourceApplyFn', undefined);
2511
2558
  };
2512
2559
  }, [undoSinks]);
@@ -2734,11 +2781,11 @@ function CanvasRouter({
2734
2781
  }
2735
2782
  // Plan C — Edit menu (shell) bridges to the in-canvas undo stack.
2736
2783
  if (m.dgn === 'undo') {
2737
- void undoStack.undo();
2784
+ void afterShellRecords().then(() => undoStackRef.current.undo());
2738
2785
  return;
2739
2786
  }
2740
2787
  if (m.dgn === 'redo') {
2741
- void undoStack.redo();
2788
+ void afterShellRecords().then(() => undoStackRef.current.redo());
2742
2789
  return;
2743
2790
  }
2744
2791
  // Phase 12 Task 4 (DDR-103) — Layers-tree round-trip. select-by-id resolves
@@ -3144,7 +3191,7 @@ function CanvasRouter({
3144
3191
  // selected (the drill has reached it). Previously a dblclick on leaf
3145
3192
  // text jumped straight to the editor from any depth, so the drill ladder
3146
3193
  // never ran on text-bearing targets.
3147
- const sel0 = selSet.selected;
3194
+ const sel0 = textSelectionRef.current.selected;
3148
3195
  const currentId = sel0.length === 1 ? sel0[0]?.id : undefined;
3149
3196
  const leafReached = isLeafText && currentId === stamped.getAttribute('data-cd-id');
3150
3197
  if (!leafReached) {
@@ -3167,7 +3214,9 @@ function CanvasRouter({
3167
3214
  (next.closest('[data-dc-screen]') as HTMLElement | null)?.getAttribute(
3168
3215
  'data-dc-screen'
3169
3216
  ) ?? null;
3170
- selSet.replace(hoverTargetToSelection({ el: next, cdId, artboardId } as HoverTarget));
3217
+ textSelectionRef.current.replace(
3218
+ hoverTargetToSelection({ el: next, cdId, artboardId } as HoverTarget)
3219
+ );
3171
3220
  }
3172
3221
  }
3173
3222
  return;
@@ -3228,7 +3277,7 @@ function CanvasRouter({
3228
3277
  document.removeEventListener('maude:enter-text-edit', onEnterTextEdit);
3229
3278
  if (editing) teardown(editing);
3230
3279
  };
3231
- }, [hostRef, selSet]);
3280
+ }, [hostRef]);
3232
3281
 
3233
3282
  // Cleanup any pending rAF on unmount.
3234
3283
  useEffect(
@@ -3314,10 +3363,10 @@ function CanvasRouter({
3314
3363
  },
3315
3364
  onTool: ({ tool: t }) => setTool(t),
3316
3365
  onUndo: () => {
3317
- void undoStack.undo();
3366
+ void afterShellRecords().then(() => undoStackRef.current.undo());
3318
3367
  },
3319
3368
  onRedo: () => {
3320
- void undoStack.redo();
3369
+ void afterShellRecords().then(() => undoStackRef.current.redo());
3321
3370
  },
3322
3371
  onEscape: () => {
3323
3372
  // T21 — abort any mid-stroke draw FIRST. The annotations layer
@@ -0,0 +1,39 @@
1
+ // The last canvas source this server BUILT, with the locator that build
2
+ // produced, per file (F4).
3
+ //
4
+ // The HMR broadcaster diffs a changed canvas against it to find a text-only
5
+ // edit it can hand straight to the open iframe (`HmrMessage.patches`). Seeded
6
+ // by the module route after each build — so the memo is exactly the source the
7
+ // iframe renders, and its locator names that render's `data-cd-id`s — and
8
+ // advanced by every broadcast that carried patches. Bounded: a memo miss only
9
+ // means the remount renders the change without the head start.
10
+
11
+ import type { LocatorMap } from './locator.ts';
12
+
13
+ const MAX_FILES = 256;
14
+ const MAX_BYTES = 512 * 1024;
15
+
16
+ export interface BuiltCanvas {
17
+ source: string;
18
+ locator: LocatorMap;
19
+ }
20
+
21
+ const memo = new Map<string, BuiltCanvas>();
22
+
23
+ export function rememberCanvasBuild(absPath: string, built: BuiltCanvas): void {
24
+ memo.delete(absPath);
25
+ if (built.source.length > MAX_BYTES) return;
26
+ memo.set(absPath, built);
27
+ if (memo.size > MAX_FILES) {
28
+ const oldest = memo.keys().next().value;
29
+ if (oldest !== undefined) memo.delete(oldest);
30
+ }
31
+ }
32
+
33
+ export function recallCanvasBuild(absPath: string): BuiltCanvas | undefined {
34
+ return memo.get(absPath);
35
+ }
36
+
37
+ export function forgetCanvasBuilds(): void {
38
+ memo.clear();
39
+ }
@@ -0,0 +1,117 @@
1
+ // Text-only change detection for the F4 peer-render fast path.
2
+ //
3
+ // A teammate's text edit reaches this disk ~100 ms after they commit it, but
4
+ // re-importing and remounting the canvas module takes several hundred more.
5
+ // When the ONLY difference between the source the iframe renders and the new
6
+ // source is the text between an element's opening and closing tag, the iframe
7
+ // can show the new string at once and let the remount confirm it.
8
+ //
9
+ // DELIBERATELY PARSER-FREE. In a cell this runs in the process that holds the
10
+ // tenant's credentials, where canvas source is never parsed or built (DDR-209:
11
+ // that happens in the sandbox). So this is bounded string scanning over one
12
+ // changed region, and the element's identity comes from the LOCATOR the
13
+ // sandboxed build already produced for the rendered source — never from
14
+ // parsing tenant bytes here. Anything it cannot prove is a plain text run —
15
+ // markup, attributes, expressions, entities, several regions — returns null,
16
+ // and the remount alone renders the change, exactly as before.
17
+
18
+ import type { LocatorMap } from './locator.ts';
19
+
20
+ export interface TextPatch {
21
+ /** The element's `data-cd-id` in the rendered (before) build. */
22
+ id: string;
23
+ /** The string React renders for the element's text child. */
24
+ text: string;
25
+ }
26
+
27
+ const STRUCTURAL = /[<>{}&]/;
28
+
29
+ /** Babel's JSX text whitespace rule (cleanJSXElementLiteralChild). */
30
+ export function renderedJsxText(raw: string): string {
31
+ const lines = raw.split(/\r\n|\n|\r/);
32
+ let lastNonEmpty = 0;
33
+ for (let i = 0; i < lines.length; i++) if (/[^ \t]/.test(lines[i] as string)) lastNonEmpty = i;
34
+ let out = '';
35
+ for (let i = 0; i < lines.length; i++) {
36
+ let line = (lines[i] as string).replace(/\t/g, ' ');
37
+ if (i !== 0) line = line.replace(/^[ ]+/, '');
38
+ if (i !== lines.length - 1) line = line.replace(/[ ]+$/, '');
39
+ if (line) {
40
+ if (i !== lastNonEmpty) line += ' ';
41
+ out += line;
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** 1-based line / 0-based column, counted the way the build's locator counts. */
48
+ function lineCol(source: string, at: number): { line: number; col: number } {
49
+ let line = 1;
50
+ let col = 0;
51
+ for (let i = 0; i < at; i++) {
52
+ if (source.charCodeAt(i) === 10) {
53
+ line += 1;
54
+ col = 0;
55
+ } else col += 1;
56
+ }
57
+ return { line, col };
58
+ }
59
+
60
+ const TAG_NAME = /^[A-Za-z][\w.:-]*/;
61
+
62
+ /**
63
+ * The single text patch that turns `before`'s render into `after`'s, [] when
64
+ * they are identical, or null when the change is anything but the text run of
65
+ * one element that `locator` (the build of `before`) names.
66
+ */
67
+ export function textOnlyPatches(
68
+ before: string,
69
+ after: string,
70
+ locator: LocatorMap
71
+ ): TextPatch[] | null {
72
+ if (before === after) return [];
73
+ const limit = Math.min(before.length, after.length);
74
+ let p = 0;
75
+ while (p < limit && before.charCodeAt(p) === after.charCodeAt(p)) p++;
76
+ let s = 0;
77
+ while (
78
+ s < limit - p &&
79
+ before.charCodeAt(before.length - 1 - s) === after.charCodeAt(after.length - 1 - s)
80
+ )
81
+ s++;
82
+ if (STRUCTURAL.test(before.slice(p, before.length - s))) return null;
83
+ if (STRUCTURAL.test(after.slice(p, after.length - s))) return null;
84
+
85
+ // The text run around the change: from the `>` ending an opening tag to the
86
+ // `</` of a closing tag, with nothing structural in between (both sides).
87
+ let open = p - 1;
88
+ while (open >= 0 && !STRUCTURAL.test(before[open] as string)) open--;
89
+ if (open < 0 || before[open] !== '>' || before[open - 1] === '/') return null;
90
+ let close = before.length - s;
91
+ while (close < before.length && !STRUCTURAL.test(before[close] as string)) close++;
92
+ if (before[close] !== '<' || before[close + 1] !== '/') return null;
93
+ const closeAfter = close + (after.length - before.length);
94
+
95
+ // The opening tag this `>` ends: its `<`, a tag name, and a closing tag that
96
+ // names the same element.
97
+ const start = before.lastIndexOf('<', open);
98
+ if (start < 0) return null;
99
+ const name = TAG_NAME.exec(before.slice(start + 1))?.[0];
100
+ if (!name || before.slice(close + 2, close + 2 + name.length + 1) !== `${name}>`) return null;
101
+
102
+ const at = lineCol(before, start);
103
+ // The locator is the sandboxed build's output: trust its shape, not its size.
104
+ const ids = Object.entries(locator ?? {}).filter(
105
+ ([id, e]) =>
106
+ /^[0-9a-f]{8}$/.test(id) &&
107
+ e?.line === at.line &&
108
+ e?.col === at.col &&
109
+ Array.isArray(e?.jsxPath) &&
110
+ e.jsxPath.at(-1) === name
111
+ );
112
+ if (ids.length !== 1) return null;
113
+
114
+ const text = renderedJsxText(after.slice(open + 1, closeAfter));
115
+ if (!text) return null;
116
+ return [{ id: (ids[0] as [string, unknown])[0], text }];
117
+ }