@1agh/maude 0.36.0 → 0.36.1

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.
@@ -0,0 +1,119 @@
1
+ import { beforeAll, describe, expect, mock, test } from 'bun:test';
2
+
3
+ import {
4
+ buildEditSourceRecord,
5
+ createEditSourceCommand,
6
+ EDIT_SOURCE_KIND,
7
+ type EditSourceApplyFn,
8
+ type EditSourcePayload,
9
+ } from '../commands/edit-source-command.ts';
10
+ import { type CommandSinks, rebuildCommand, registerCommand } from '../undo-stack.ts';
11
+
12
+ const cssEdit: EditSourcePayload = {
13
+ op: 'css',
14
+ canvas: '.design/ui/Foo.tsx',
15
+ id: 'cd-1',
16
+ key: 'color',
17
+ before: 'red',
18
+ after: 'blue',
19
+ };
20
+
21
+ describe('createEditSourceCommand', () => {
22
+ test('do() applies the AFTER value', async () => {
23
+ const applyFn = mock(() => {});
24
+ const cmd = createEditSourceCommand({ payload: cssEdit, applyFn });
25
+ await cmd.do();
26
+ expect(applyFn).toHaveBeenCalledTimes(1);
27
+ expect(applyFn.mock.calls[0]?.[0]).toEqual({
28
+ op: 'css',
29
+ canvas: '.design/ui/Foo.tsx',
30
+ id: 'cd-1',
31
+ key: 'color',
32
+ value: 'blue',
33
+ });
34
+ });
35
+
36
+ test('undo() applies the BEFORE value', async () => {
37
+ const applyFn = mock(() => {});
38
+ const cmd = createEditSourceCommand({ payload: cssEdit, applyFn });
39
+ await cmd.undo();
40
+ expect(applyFn.mock.calls[0]?.[0]?.value).toBe('red');
41
+ });
42
+
43
+ test('undo() of a NEW prop (before:null) applies null = reset', async () => {
44
+ const applyFn = mock(() => {});
45
+ const cmd = createEditSourceCommand({
46
+ payload: { ...cssEdit, before: null, after: 'blue' },
47
+ applyFn,
48
+ });
49
+ await cmd.undo();
50
+ expect(applyFn.mock.calls[0]?.[0]?.value).toBeNull();
51
+ });
52
+
53
+ test('do() of a RESET (after:null) applies null = remove the prop', async () => {
54
+ const applyFn = mock(() => {});
55
+ const cmd = createEditSourceCommand({
56
+ payload: { ...cssEdit, before: 'red', after: null },
57
+ applyFn,
58
+ });
59
+ await cmd.do();
60
+ expect(applyFn.mock.calls[0]?.[0]?.value).toBeNull();
61
+ });
62
+
63
+ test('label reflects op + key', () => {
64
+ expect(createEditSourceCommand({ payload: cssEdit, applyFn: () => {} }).label).toBe(
65
+ 'edit color'
66
+ );
67
+ expect(
68
+ createEditSourceCommand({ payload: { ...cssEdit, after: null }, applyFn: () => {} }).label
69
+ ).toBe('reset color');
70
+ expect(
71
+ createEditSourceCommand({
72
+ payload: { op: 'attr', canvas: 'x', id: 'y', key: 'data-x', before: null, after: '1' },
73
+ applyFn: () => {},
74
+ }).label
75
+ ).toBe('edit @data-x');
76
+ expect(
77
+ createEditSourceCommand({
78
+ payload: { op: 'text', canvas: 'x', id: 'y', key: '', before: 'a', after: 'b' },
79
+ applyFn: () => {},
80
+ }).label
81
+ ).toBe('edit text');
82
+ });
83
+ });
84
+
85
+ describe('buildEditSourceRecord', () => {
86
+ test('produces a serializable record under the edit-source kind', () => {
87
+ const rec = buildEditSourceRecord(cssEdit);
88
+ expect(rec.kind).toBe(EDIT_SOURCE_KIND);
89
+ expect(rec.payload).toEqual(cssEdit);
90
+ expect(JSON.parse(JSON.stringify(rec))).toEqual(rec); // round-trips
91
+ });
92
+ });
93
+
94
+ describe('edit-source registry', () => {
95
+ // The module self-registers on import, but sibling test files wipe the shared
96
+ // builder registry in their beforeEach — re-register here so this block is
97
+ // order-independent. Mirrors the real registration in edit-source-command.ts.
98
+ beforeAll(() => {
99
+ registerCommand<EditSourcePayload>(EDIT_SOURCE_KIND, (record, sinks) => {
100
+ const applyFn = sinks.editSourceApplyFn as EditSourceApplyFn | undefined;
101
+ if (!applyFn) return null;
102
+ return createEditSourceCommand({ payload: record.payload, applyFn, label: record.label });
103
+ });
104
+ });
105
+
106
+ test('rebuildCommand returns null when the editSourceApplyFn sink is unbound', () => {
107
+ expect(rebuildCommand(buildEditSourceRecord(cssEdit), {} as CommandSinks)).toBeNull();
108
+ });
109
+
110
+ test('rebuildCommand wires the bound sink into a runnable command', async () => {
111
+ const applyFn = mock(() => {});
112
+ const cmd = rebuildCommand(buildEditSourceRecord(cssEdit), {
113
+ editSourceApplyFn: applyFn,
114
+ } as CommandSinks);
115
+ expect(cmd).not.toBeNull();
116
+ await cmd?.do();
117
+ expect(applyFn.mock.calls[0]?.[0]?.value).toBe('blue');
118
+ });
119
+ });
@@ -155,6 +155,36 @@ describe('use-undo-stack / runner side-effects', () => {
155
155
  });
156
156
  });
157
157
 
158
+ describe('use-undo-stack / record (already-applied edits)', () => {
159
+ test('record() appends WITHOUT running do()', async () => {
160
+ const v = captureProvider(undefined, { layoutPatchFn: () => {} });
161
+ v.record(rec('inline-edit', 'X'));
162
+ // undo() is enqueued after record(), so awaiting it flushes record first.
163
+ await v.undo();
164
+ expect(doSpy).toHaveBeenCalledTimes(0); // record never runs do()
165
+ expect(undoSpy).toHaveBeenCalledTimes(1);
166
+ expect(undoSpy.mock.calls[0]?.[0]).toBe('X');
167
+ });
168
+
169
+ test('record() then redo() (after undo) re-runs do()', async () => {
170
+ const v = captureProvider(undefined, { layoutPatchFn: () => {} });
171
+ v.record(rec('e', 'Y'));
172
+ await v.undo();
173
+ await v.redo();
174
+ expect(doSpy).toHaveBeenCalledTimes(1); // only redo's do(), not the record
175
+ expect(doSpy.mock.calls[0]?.[0]).toBe('Y');
176
+ });
177
+
178
+ test('record() clears the redo future', async () => {
179
+ const v = captureProvider(undefined, { layoutPatchFn: () => {} });
180
+ await v.push(rec('a', 'A')); // doSpy: 1
181
+ await v.undo(); // future = [a]
182
+ v.record(rec('b', 'B')); // clears future
183
+ await v.redo(); // future empty → no-op, no extra do()
184
+ expect(doSpy).toHaveBeenCalledTimes(1);
185
+ });
186
+ });
187
+
158
188
  describe('use-undo-stack / cross-canvas persistence', () => {
159
189
  test('history under a canvasFile survives a fresh provider mount with the same canvasFile', async () => {
160
190
  // First mount: push 2 records.
@@ -71,6 +71,16 @@ export interface CommandSinks {
71
71
  layoutPatchFn?: unknown;
72
72
  /** Wired by `AnnotationsLayer`. */
73
73
  strokesPutFn?: unknown;
74
+ /**
75
+ * Wired by `CanvasShell` (canvas-shell.tsx). Applies a single inline source
76
+ * edit (CSS / text / attr) by posting `dgn:'apply-edit'` to the parent shell,
77
+ * which owns the main-origin-only `/_api/edit-*` write (DDR-054 — the canvas
78
+ * iframe can't call those routes directly). Drives undo/redo of inspector
79
+ * CSS edits, inline text rewrites, and custom-attribute edits, which write
80
+ * the canvas `.tsx` source and were previously unrecoverable (no command, no
81
+ * snapshot). See `commands/edit-source-command.ts`.
82
+ */
83
+ editSourceApplyFn?: unknown;
74
84
  }
75
85
 
76
86
  export type CommandBuilder<P = unknown> = (
@@ -51,6 +51,14 @@ export interface UndoStackValue {
51
51
  * Awaiting the returned promise is optional.
52
52
  */
53
53
  push: (record: CommandRecord) => Promise<void>;
54
+ /**
55
+ * Record an ALREADY-APPLIED command — appends to `past` + clears `future`
56
+ * WITHOUT running `do()`. Use when the side-effect happened outside the
57
+ * stack (e.g. an inspector CSS commit / inline text edit that already POSTed
58
+ * `/_api/edit-*`), so re-running `do()` would double-apply. `undo()` / `redo()`
59
+ * still drive the command's `undo()` / `do()` normally afterwards.
60
+ */
61
+ record: (record: CommandRecord) => void;
54
62
  /** Undo the top of `past`. No-op when empty. */
55
63
  undo: () => Promise<void>;
56
64
  /** Redo the top of `future`. No-op when empty. */
@@ -69,6 +77,7 @@ const UndoStackContext = createContext<UndoStackValue | null>(null);
69
77
 
70
78
  const NOOP_VALUE: UndoStackValue = {
71
79
  push: () => Promise.resolve(),
80
+ record: () => {},
72
81
  undo: () => Promise.resolve(),
73
82
  redo: () => Promise.resolve(),
74
83
  clear: () => {},
@@ -213,6 +222,16 @@ export function UndoStackProvider({
213
222
  [enqueue, build, reportError, bumpLabel, writeState]
214
223
  );
215
224
 
225
+ const record = useCallback(
226
+ (rec: CommandRecord): void => {
227
+ void enqueue(async () => {
228
+ writeState(undoReducer(stateRef.current, { type: 'push', record: rec }));
229
+ bumpLabel(rec.label);
230
+ });
231
+ },
232
+ [enqueue, writeState, bumpLabel]
233
+ );
234
+
216
235
  const undo = useCallback(
217
236
  (): Promise<void> =>
218
237
  enqueue(async () => {
@@ -306,6 +325,7 @@ export function UndoStackProvider({
306
325
  const value = useMemo<UndoStackValue>(
307
326
  () => ({
308
327
  push,
328
+ record,
309
329
  undo,
310
330
  redo,
311
331
  clear,
@@ -318,7 +338,7 @@ export function UndoStackProvider({
318
338
  // `lastTick` (bumped by every push/undo/redo/clear) as the proxy so the
319
339
  // memoized canUndo/canRedo readouts re-evaluate on each transition.
320
340
  // eslint-disable-next-line react-hooks/exhaustive-deps
321
- [push, undo, redo, clear, lastLabel, lastTick]
341
+ [push, record, undo, redo, clear, lastLabel, lastTick]
322
342
  );
323
343
 
324
344
  return (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.36.0",
3
+ "version": "0.36.1",
4
4
  "description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -48,13 +48,13 @@
48
48
  "test:e2e:desktop:switchrepos": "pnpm --filter @maude/desktop-e2e e2e:switchrepos"
49
49
  },
50
50
  "optionalDependencies": {
51
- "@1agh/maude-darwin-arm64": "0.36.0",
52
- "@1agh/maude-darwin-x64": "0.36.0",
53
- "@1agh/maude-linux-arm64": "0.36.0",
54
- "@1agh/maude-linux-arm64-musl": "0.36.0",
55
- "@1agh/maude-linux-x64": "0.36.0",
56
- "@1agh/maude-linux-x64-musl": "0.36.0",
57
- "@1agh/maude-win32-x64": "0.36.0"
51
+ "@1agh/maude-darwin-arm64": "0.36.1",
52
+ "@1agh/maude-darwin-x64": "0.36.1",
53
+ "@1agh/maude-linux-arm64": "0.36.1",
54
+ "@1agh/maude-linux-arm64-musl": "0.36.1",
55
+ "@1agh/maude-linux-x64": "0.36.1",
56
+ "@1agh/maude-linux-x64-musl": "0.36.1",
57
+ "@1agh/maude-win32-x64": "0.36.1"
58
58
  },
59
59
  "files": [
60
60
  "cli",