@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.
- package/apps/studio/canvas-shell.tsx +73 -2
- package/apps/studio/client/app.jsx +135 -8
- package/apps/studio/commands/edit-source-command.ts +118 -0
- package/apps/studio/dist/client.bundle.js +11 -11
- package/apps/studio/test/edit-source-command.test.ts +119 -0
- package/apps/studio/test/use-undo-stack.test.tsx +30 -0
- package/apps/studio/undo-stack.ts +10 -0
- package/apps/studio/use-undo-stack.tsx +21 -1
- package/package.json +8 -8
|
@@ -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.
|
|
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.
|
|
52
|
-
"@1agh/maude-darwin-x64": "0.36.
|
|
53
|
-
"@1agh/maude-linux-arm64": "0.36.
|
|
54
|
-
"@1agh/maude-linux-arm64-musl": "0.36.
|
|
55
|
-
"@1agh/maude-linux-x64": "0.36.
|
|
56
|
-
"@1agh/maude-linux-x64-musl": "0.36.
|
|
57
|
-
"@1agh/maude-win32-x64": "0.36.
|
|
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",
|