@1agh/maude 0.19.1 → 0.21.0
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/README.md +7 -0
- package/cli/bin/maude.mjs +5 -1
- package/cli/commands/design-link.test.mjs +207 -0
- package/cli/commands/design.mjs +42 -12
- package/cli/commands/doctor.mjs +350 -0
- package/cli/commands/doctor.test.mjs +185 -0
- package/cli/commands/help.mjs +24 -0
- package/cli/commands/hub.mjs +231 -0
- package/cli/commands/hub.test.mjs +87 -0
- package/cli/lib/config-lint.mjs +141 -0
- package/cli/lib/config-lint.test.mjs +117 -0
- package/cli/lib/design-link.mjs +216 -0
- package/cli/lib/hubs-config.mjs +123 -0
- package/cli/lib/hubs-config.test.mjs +100 -0
- package/cli/lib/preflight.mjs +232 -0
- package/cli/lib/stack-detect.mjs +344 -0
- package/cli/lib/stack-detect.test.mjs +121 -0
- package/package.json +17 -9
- package/plugins/design/dependencies.json +147 -0
- package/plugins/design/dependencies.schema.json +107 -0
- package/plugins/design/dev-server/ai-banner.tsx +188 -0
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +115 -41
- package/plugins/design/dev-server/annotations-layer.tsx +256 -107
- package/plugins/design/dev-server/api.ts +17 -1
- package/plugins/design/dev-server/artboard-marquee.tsx +10 -6
- package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
- package/plugins/design/dev-server/bin/preflight.sh +32 -0
- package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
- package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
- package/plugins/design/dev-server/canvas-lib.tsx +534 -32
- package/plugins/design/dev-server/canvas-shell.tsx +479 -29
- package/plugins/design/dev-server/client/app.jsx +72 -0
- package/plugins/design/dev-server/client/hmr.mjs +28 -0
- package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
- package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
- package/plugins/design/dev-server/collab/index.ts +47 -0
- package/plugins/design/dev-server/collab/persistence.ts +123 -0
- package/plugins/design/dev-server/collab/protocol.ts +108 -0
- package/plugins/design/dev-server/collab/registry.ts +110 -0
- package/plugins/design/dev-server/collab/room.ts +215 -0
- package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
- package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
- package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
- package/plugins/design/dev-server/comments-overlay.tsx +41 -4
- package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
- package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
- package/plugins/design/dev-server/dist/client.bundle.js +75 -3
- package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
- package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +587 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
- package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -0
- package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
- package/plugins/design/dev-server/export-dialog.tsx +1 -1
- package/plugins/design/dev-server/handoff.ts +24 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
- package/plugins/design/dev-server/http.ts +91 -1
- package/plugins/design/dev-server/input-router.tsx +52 -2
- package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
- package/plugins/design/dev-server/participants-chrome.tsx +261 -0
- package/plugins/design/dev-server/runtime-bundle.ts +15 -0
- package/plugins/design/dev-server/server.ts +78 -11
- package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
- package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
- package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
- package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
- package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
- package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
- package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
- package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
- package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
- package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
- package/plugins/design/dev-server/test/input-router.test.ts +87 -1
- package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
- package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
- package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
- package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
- package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
- package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
- package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
- package/plugins/design/dev-server/tool-palette.tsx +7 -7
- package/plugins/design/dev-server/undo-hud.tsx +95 -0
- package/plugins/design/dev-server/undo-stack.ts +240 -0
- package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
- package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
- package/plugins/design/dev-server/use-collab.tsx +478 -0
- package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
- package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
- package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
- package/plugins/design/dev-server/ws.ts +123 -7
- package/plugins/design/templates/_shell.html +51 -6
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
- package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
- package/plugins/flow/.claude-plugin/config.schema.json +12 -0
- package/plugins/flow/dependencies.json +143 -0
- package/plugins/flow/dependencies.schema.json +107 -0
- /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file use-keyboard-discipline.tsx — T29 (Wave 3)
|
|
3
|
+
* @scope plugins/design/dev-server/use-keyboard-discipline.tsx
|
|
4
|
+
* @purpose Keyboard nudge + Cmd+A select-all-in-artboard. Bail when focus
|
|
5
|
+
* is inside an input / textarea / contenteditable so typing
|
|
6
|
+
* into the dev-server's own inputs (file tree filter, etc.)
|
|
7
|
+
* never collides.
|
|
8
|
+
*
|
|
9
|
+
* Scope decision — arrow nudge applies to **artboards** only.
|
|
10
|
+
* User content inside artboards has no live-position channel
|
|
11
|
+
* (canvas surface is HTML; element positions are CSS-derived
|
|
12
|
+
* from the compiled JSX, not directly manipulable). Element
|
|
13
|
+
* nudge would require either an ephemeral CSS-transform overlay
|
|
14
|
+
* (cleaner) or a TSX rewrite channel (heavier). Out of scope
|
|
15
|
+
* for this wave — the artboard channel + dragBus.commitPositions
|
|
16
|
+
* already exists, so we leverage that and document the
|
|
17
|
+
* limitation here.
|
|
18
|
+
*
|
|
19
|
+
* Cmd+D duplicate is also deferred — same architectural reason
|
|
20
|
+
* (no duplicate channel). Esc is owned by the input-router's
|
|
21
|
+
* onEscape callback, so we don't duplicate it here.
|
|
22
|
+
*
|
|
23
|
+
* Distance:
|
|
24
|
+
* • Arrow → 1 world-unit step (DDR-028 world units, not
|
|
25
|
+
* screen px).
|
|
26
|
+
* • Shift+Arrow → 10 world-unit step.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { useEffect } from 'react';
|
|
30
|
+
|
|
31
|
+
import { useArtboardsContext, useDragStateContext } from './canvas-lib.tsx';
|
|
32
|
+
import { isEditableTarget } from './input-router.tsx';
|
|
33
|
+
import { type Selection, useSelectionSet } from './use-selection-set.tsx';
|
|
34
|
+
|
|
35
|
+
const STEP_SMALL = 1;
|
|
36
|
+
const STEP_LARGE = 10;
|
|
37
|
+
|
|
38
|
+
export interface NudgeInput {
|
|
39
|
+
key: string;
|
|
40
|
+
shift: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Map a keyboard event into a (dx, dy) delta in world units. Returns null
|
|
45
|
+
* when the key isn't an arrow. Exported for unit tests.
|
|
46
|
+
*/
|
|
47
|
+
export function nudgeDelta(input: NudgeInput): { dx: number; dy: number } | null {
|
|
48
|
+
const step = input.shift ? STEP_LARGE : STEP_SMALL;
|
|
49
|
+
switch (input.key) {
|
|
50
|
+
case 'ArrowLeft':
|
|
51
|
+
return { dx: -step, dy: 0 };
|
|
52
|
+
case 'ArrowRight':
|
|
53
|
+
return { dx: step, dy: 0 };
|
|
54
|
+
case 'ArrowUp':
|
|
55
|
+
return { dx: 0, dy: -step };
|
|
56
|
+
case 'ArrowDown':
|
|
57
|
+
return { dx: 0, dy: step };
|
|
58
|
+
default:
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function useKeyboardDiscipline(): void {
|
|
64
|
+
const selSet = useSelectionSet();
|
|
65
|
+
const artboardsCtx = useArtboardsContext();
|
|
66
|
+
const dragBus = useDragStateContext();
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (typeof document === 'undefined') return;
|
|
70
|
+
|
|
71
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
72
|
+
if (isEditableTarget(e.target)) return;
|
|
73
|
+
|
|
74
|
+
// Cmd+A → select all stamped elements in the active artboard.
|
|
75
|
+
const isMeta = e.metaKey || e.ctrlKey;
|
|
76
|
+
if (isMeta && e.key.toLowerCase() === 'a') {
|
|
77
|
+
if (!artboardsCtx) return;
|
|
78
|
+
const id = artboardsCtx.activeArtboardId;
|
|
79
|
+
if (!id) return;
|
|
80
|
+
const root = document.querySelector(`[data-dc-screen="${id}"] .dc-artboard-body`);
|
|
81
|
+
if (!root) return;
|
|
82
|
+
const stamped = root.querySelectorAll('[data-cd-id]');
|
|
83
|
+
if (stamped.length === 0) return;
|
|
84
|
+
const hits: Selection[] = [];
|
|
85
|
+
for (const el of stamped) {
|
|
86
|
+
const cdId = el.getAttribute('data-cd-id');
|
|
87
|
+
if (!cdId) continue;
|
|
88
|
+
hits.push({
|
|
89
|
+
id: cdId,
|
|
90
|
+
selector: `[data-cd-id="${cdId}"]`,
|
|
91
|
+
artboardId: id,
|
|
92
|
+
tag: el.tagName.toLowerCase(),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (hits.length === 0) return;
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
selSet.replace(hits);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Arrow nudge — artboards only (see file header for the why).
|
|
102
|
+
const delta = nudgeDelta({ key: e.key, shift: e.shiftKey });
|
|
103
|
+
if (!delta) return;
|
|
104
|
+
if (isMeta || e.altKey) return; // modifier combos reserved for future
|
|
105
|
+
if (!artboardsCtx || !dragBus) return;
|
|
106
|
+
|
|
107
|
+
const artboardSelections = selSet.selected.filter((s) => !!s.artboardId && !s.id);
|
|
108
|
+
if (artboardSelections.length === 0) return;
|
|
109
|
+
const ids = new Set(artboardSelections.map((s) => s.artboardId as string));
|
|
110
|
+
const targets = artboardsCtx.artboards.filter((r) => ids.has(r.id));
|
|
111
|
+
if (targets.length === 0) return;
|
|
112
|
+
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
const moved = targets.map((r) => ({
|
|
115
|
+
id: r.id,
|
|
116
|
+
x: Math.round(r.x + delta.dx),
|
|
117
|
+
y: Math.round(r.y + delta.dy),
|
|
118
|
+
}));
|
|
119
|
+
dragBus.commitPositions(moved);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
123
|
+
return () => document.removeEventListener('keydown', onKeyDown, true);
|
|
124
|
+
}, [selSet, artboardsCtx, dragBus]);
|
|
125
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file use-undo-stack.tsx — React Context + Provider for the undo stack
|
|
3
|
+
* @scope plugins/design/dev-server/use-undo-stack.tsx
|
|
4
|
+
* @purpose Wraps the pure `undoReducer` (undo-stack.ts) in React state,
|
|
5
|
+
* owns the async runner that awaits side-effects before applying
|
|
6
|
+
* the next state transition, exposes the `lastLabel` HUD signal,
|
|
7
|
+
* AND persists the stack across canvas switches via
|
|
8
|
+
* `window.top.__maude_undo_stacks` keyed by canvas file path
|
|
9
|
+
* (DDR-050 rev 2).
|
|
10
|
+
*
|
|
11
|
+
* Two roles in one provider:
|
|
12
|
+
* 1. **State authority** — ref-as-store synchronously between awaited
|
|
13
|
+
* side-effects (Cmd+Z at 30 Hz key-repeat needs to see its own writes).
|
|
14
|
+
* 2. **Sinks registry** — descendants (DesignCanvasInner for layout,
|
|
15
|
+
* AnnotationsLayer for strokes) register their side-effect functions
|
|
16
|
+
* via `useUndoSinks().setSink(name, fn)`. The provider merges them
|
|
17
|
+
* and uses them when rebuilding commands from records.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
type ReactNode,
|
|
22
|
+
createContext,
|
|
23
|
+
useCallback,
|
|
24
|
+
useContext,
|
|
25
|
+
useEffect,
|
|
26
|
+
useMemo,
|
|
27
|
+
useRef,
|
|
28
|
+
useState,
|
|
29
|
+
} from 'react';
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
type CommandRecord,
|
|
33
|
+
type CommandSinks,
|
|
34
|
+
type EditCommand,
|
|
35
|
+
type UndoStackState,
|
|
36
|
+
canRedo as canRedoOf,
|
|
37
|
+
canUndo as canUndoOf,
|
|
38
|
+
loadStackState,
|
|
39
|
+
rebuildCommand,
|
|
40
|
+
saveStackState,
|
|
41
|
+
undoReducer,
|
|
42
|
+
} from './undo-stack.ts';
|
|
43
|
+
|
|
44
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
// Context shape
|
|
46
|
+
|
|
47
|
+
export interface UndoStackValue {
|
|
48
|
+
/**
|
|
49
|
+
* Push a fresh command. The runner calls the rebuilt command's `do()` —
|
|
50
|
+
* the new state is committed only after the side-effect resolves.
|
|
51
|
+
* Awaiting the returned promise is optional.
|
|
52
|
+
*/
|
|
53
|
+
push: (record: CommandRecord) => Promise<void>;
|
|
54
|
+
/** Undo the top of `past`. No-op when empty. */
|
|
55
|
+
undo: () => Promise<void>;
|
|
56
|
+
/** Redo the top of `future`. No-op when empty. */
|
|
57
|
+
redo: () => Promise<void>;
|
|
58
|
+
/** Drop both stacks (external edit). */
|
|
59
|
+
clear: (reason?: string) => void;
|
|
60
|
+
canUndo: boolean;
|
|
61
|
+
canRedo: boolean;
|
|
62
|
+
/** Most recent operation label. HUD reads this. */
|
|
63
|
+
lastLabel: string | null;
|
|
64
|
+
/** Monotonic counter — HUD subscribes to bump dismiss timer per op. */
|
|
65
|
+
lastTick: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const UndoStackContext = createContext<UndoStackValue | null>(null);
|
|
69
|
+
|
|
70
|
+
const NOOP_VALUE: UndoStackValue = {
|
|
71
|
+
push: () => Promise.resolve(),
|
|
72
|
+
undo: () => Promise.resolve(),
|
|
73
|
+
redo: () => Promise.resolve(),
|
|
74
|
+
clear: () => {},
|
|
75
|
+
canUndo: false,
|
|
76
|
+
canRedo: false,
|
|
77
|
+
lastLabel: null,
|
|
78
|
+
lastTick: 0,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
// Sinks context — descendants register side-effect functions here.
|
|
83
|
+
|
|
84
|
+
export interface UndoSinksValue {
|
|
85
|
+
/** Register or replace a sink. Pass `undefined` to unregister. */
|
|
86
|
+
setSink: <K extends keyof CommandSinks>(key: K, fn: CommandSinks[K]) => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const UndoSinksContext = createContext<UndoSinksValue | null>(null);
|
|
90
|
+
|
|
91
|
+
const NOOP_SINKS: UndoSinksValue = { setSink: () => {} };
|
|
92
|
+
|
|
93
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
94
|
+
// Provider
|
|
95
|
+
|
|
96
|
+
export interface UndoStackProviderProps {
|
|
97
|
+
children: ReactNode;
|
|
98
|
+
/**
|
|
99
|
+
* Canvas file path (e.g. `.design/ui/Foo.tsx` or repo-relative). Required
|
|
100
|
+
* for cross-canvas persistence — the stack is keyed by this string in the
|
|
101
|
+
* `window.top.__maude_undo_stacks` Map. Two iframes with the same canvas
|
|
102
|
+
* file share one stack; switching canvases gets a fresh stack keyed under
|
|
103
|
+
* a different path.
|
|
104
|
+
*
|
|
105
|
+
* Optional only because some test harnesses mount the provider standalone;
|
|
106
|
+
* an empty / undefined value disables persistence (stack is ephemeral).
|
|
107
|
+
*/
|
|
108
|
+
canvasFile?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Optional async-failure hook. Fires when a command's `do()` / `undo()`
|
|
111
|
+
* throws or rejects. The reducer never commits the transition for a failed
|
|
112
|
+
* side-effect (push: cmd not appended; undo: stays in past; redo: stays in
|
|
113
|
+
* future). Defaults to a console.warn.
|
|
114
|
+
*/
|
|
115
|
+
onCommandError?: (err: unknown, op: 'do' | 'undo', record: CommandRecord) => void;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function UndoStackProvider({
|
|
119
|
+
children,
|
|
120
|
+
canvasFile,
|
|
121
|
+
onCommandError,
|
|
122
|
+
}: UndoStackProviderProps) {
|
|
123
|
+
// Ref is the authoritative store — the async runner reads + writes it
|
|
124
|
+
// synchronously between awaited side-effects.
|
|
125
|
+
const stateRef = useRef<UndoStackState>(
|
|
126
|
+
canvasFile ? loadStackState(canvasFile) : { past: [], future: [] }
|
|
127
|
+
);
|
|
128
|
+
const [, setRenderToken] = useState(0);
|
|
129
|
+
|
|
130
|
+
// Sinks live in a ref so writes don't churn re-renders; the runner reads
|
|
131
|
+
// them on every push/undo/redo.
|
|
132
|
+
const sinksRef = useRef<CommandSinks>({});
|
|
133
|
+
|
|
134
|
+
// canvasFile ref tracks the current key — used for persistence + for
|
|
135
|
+
// re-hydration when the consumer remounts under a different file.
|
|
136
|
+
const fileRef = useRef<string | undefined>(canvasFile);
|
|
137
|
+
|
|
138
|
+
// Re-hydrate state when canvasFile changes (e.g. parent shell remounts
|
|
139
|
+
// the provider for a new canvas). For the typical iframe lifecycle the
|
|
140
|
+
// provider unmounts entirely on canvas switch so this branch is rarely
|
|
141
|
+
// hit — it's a safety net for test harnesses that swap files in place.
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (canvasFile === fileRef.current) return;
|
|
144
|
+
fileRef.current = canvasFile;
|
|
145
|
+
stateRef.current = canvasFile ? loadStackState(canvasFile) : { past: [], future: [] };
|
|
146
|
+
setRenderToken((t) => t + 1);
|
|
147
|
+
}, [canvasFile]);
|
|
148
|
+
|
|
149
|
+
const writeState = useCallback((next: UndoStackState) => {
|
|
150
|
+
stateRef.current = next;
|
|
151
|
+
if (fileRef.current) saveStackState(fileRef.current, next);
|
|
152
|
+
setRenderToken((t) => t + 1);
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
const [lastLabel, setLastLabel] = useState<string | null>(null);
|
|
156
|
+
const [lastTick, setLastTick] = useState(0);
|
|
157
|
+
|
|
158
|
+
// Serialize concurrent ops. Cmd+Z held down can repeat at ~30 Hz; the
|
|
159
|
+
// runner awaits each side-effect before applying state.
|
|
160
|
+
const inFlightRef = useRef<Promise<void>>(Promise.resolve());
|
|
161
|
+
|
|
162
|
+
const reportError = useCallback(
|
|
163
|
+
(err: unknown, op: 'do' | 'undo', record: CommandRecord) => {
|
|
164
|
+
if (onCommandError) {
|
|
165
|
+
onCommandError(err, op, record);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
console.warn(`[undo-stack] ${op} failed for "${record.label}":`, err);
|
|
169
|
+
},
|
|
170
|
+
[onCommandError]
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const bumpLabel = useCallback((label: string | null) => {
|
|
174
|
+
setLastLabel(label);
|
|
175
|
+
setLastTick((t) => t + 1);
|
|
176
|
+
}, []);
|
|
177
|
+
|
|
178
|
+
const enqueue = useCallback((task: () => Promise<void>): Promise<void> => {
|
|
179
|
+
const next = inFlightRef.current.then(task, task);
|
|
180
|
+
inFlightRef.current = next.catch(() => {
|
|
181
|
+
/* swallow — per-op error already reported */
|
|
182
|
+
});
|
|
183
|
+
return next;
|
|
184
|
+
}, []);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Build a runnable EditCommand from a record using the iframe's current
|
|
188
|
+
* sinks. Returns `null` when the kind isn't registered or its required
|
|
189
|
+
* sink isn't bound — the runner skips the entry and bumps a label so the
|
|
190
|
+
* user sees `"(skipped: …)"` in the HUD instead of silent failure.
|
|
191
|
+
*/
|
|
192
|
+
const build = useCallback((record: CommandRecord): EditCommand | null => {
|
|
193
|
+
return rebuildCommand(record, sinksRef.current);
|
|
194
|
+
}, []);
|
|
195
|
+
|
|
196
|
+
const push = useCallback(
|
|
197
|
+
(record: CommandRecord): Promise<void> =>
|
|
198
|
+
enqueue(async () => {
|
|
199
|
+
const cmd = build(record);
|
|
200
|
+
if (!cmd) {
|
|
201
|
+
bumpLabel(`(skipped: ${record.kind})`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
await cmd.do();
|
|
206
|
+
} catch (err) {
|
|
207
|
+
reportError(err, 'do', record);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
writeState(undoReducer(stateRef.current, { type: 'push', record }));
|
|
211
|
+
bumpLabel(record.label);
|
|
212
|
+
}),
|
|
213
|
+
[enqueue, build, reportError, bumpLabel, writeState]
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const undo = useCallback(
|
|
217
|
+
(): Promise<void> =>
|
|
218
|
+
enqueue(async () => {
|
|
219
|
+
const cur = stateRef.current;
|
|
220
|
+
if (!canUndoOf(cur)) return;
|
|
221
|
+
const top = cur.past[cur.past.length - 1];
|
|
222
|
+
if (!top) return;
|
|
223
|
+
const cmd = build(top);
|
|
224
|
+
if (!cmd) {
|
|
225
|
+
bumpLabel(`(skipped: ${top.kind})`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
await cmd.undo();
|
|
230
|
+
} catch (err) {
|
|
231
|
+
reportError(err, 'undo', top);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
writeState(undoReducer(stateRef.current, { type: 'undo' }));
|
|
235
|
+
bumpLabel(`Undo: ${top.label}`);
|
|
236
|
+
}),
|
|
237
|
+
[enqueue, build, reportError, bumpLabel, writeState]
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const redo = useCallback(
|
|
241
|
+
(): Promise<void> =>
|
|
242
|
+
enqueue(async () => {
|
|
243
|
+
const cur = stateRef.current;
|
|
244
|
+
if (!canRedoOf(cur)) return;
|
|
245
|
+
const top = cur.future[cur.future.length - 1];
|
|
246
|
+
if (!top) return;
|
|
247
|
+
const cmd = build(top);
|
|
248
|
+
if (!cmd) {
|
|
249
|
+
bumpLabel(`(skipped: ${top.kind})`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
await cmd.do();
|
|
254
|
+
} catch (err) {
|
|
255
|
+
reportError(err, 'do', top);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
writeState(undoReducer(stateRef.current, { type: 'redo' }));
|
|
259
|
+
bumpLabel(`Redo: ${top.label}`);
|
|
260
|
+
}),
|
|
261
|
+
[enqueue, build, reportError, bumpLabel, writeState]
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const clear = useCallback(
|
|
265
|
+
(reason?: string) => {
|
|
266
|
+
writeState({ past: [], future: [] });
|
|
267
|
+
bumpLabel(reason ?? null);
|
|
268
|
+
},
|
|
269
|
+
[bumpLabel, writeState]
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
// External-edit invalidation. Listen for an explicit window event
|
|
273
|
+
// dispatched by client/hmr.mjs when an external .meta.json change arrives.
|
|
274
|
+
// The self-echo guard ignores our own PATCH bouncing back through fs-watch
|
|
275
|
+
// (see canvas-lib's `__maude_last_meta_self_write_at`).
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
if (typeof window === 'undefined') return;
|
|
278
|
+
const onInvalidate = (ev: Event) => {
|
|
279
|
+
const w = window as unknown as { __maude_last_meta_self_write_at?: number };
|
|
280
|
+
const last = w.__maude_last_meta_self_write_at ?? 0;
|
|
281
|
+
if (Date.now() - last < 500) return;
|
|
282
|
+
const reason =
|
|
283
|
+
(ev as CustomEvent<{ reason?: string }>).detail?.reason ?? 'External edit detected';
|
|
284
|
+
writeState({ past: [], future: [] });
|
|
285
|
+
bumpLabel(reason);
|
|
286
|
+
};
|
|
287
|
+
window.addEventListener('maude:invalidate-undo', onInvalidate);
|
|
288
|
+
return () => window.removeEventListener('maude:invalidate-undo', onInvalidate);
|
|
289
|
+
}, [writeState, bumpLabel]);
|
|
290
|
+
|
|
291
|
+
// Sinks API exposed via a sibling context — descendants register their
|
|
292
|
+
// own side-effect functions without prop-drilling.
|
|
293
|
+
const sinksApi = useMemo<UndoSinksValue>(
|
|
294
|
+
() => ({
|
|
295
|
+
setSink: (key, fn) => {
|
|
296
|
+
if (fn === undefined) {
|
|
297
|
+
delete sinksRef.current[key];
|
|
298
|
+
} else {
|
|
299
|
+
(sinksRef.current as Record<string, unknown>)[key] = fn as unknown;
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
}),
|
|
303
|
+
[]
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const value = useMemo<UndoStackValue>(
|
|
307
|
+
() => ({
|
|
308
|
+
push,
|
|
309
|
+
undo,
|
|
310
|
+
redo,
|
|
311
|
+
clear,
|
|
312
|
+
canUndo: canUndoOf(stateRef.current),
|
|
313
|
+
canRedo: canRedoOf(stateRef.current),
|
|
314
|
+
lastLabel,
|
|
315
|
+
lastTick,
|
|
316
|
+
}),
|
|
317
|
+
// `stateRef.current` doesn't trigger memo invalidation; we depend on
|
|
318
|
+
// `lastTick` (bumped by every push/undo/redo/clear) as the proxy so the
|
|
319
|
+
// memoized canUndo/canRedo readouts re-evaluate on each transition.
|
|
320
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
321
|
+
[push, undo, redo, clear, lastLabel, lastTick]
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
return (
|
|
325
|
+
<UndoStackContext.Provider value={value}>
|
|
326
|
+
<UndoSinksContext.Provider value={sinksApi}>{children}</UndoSinksContext.Provider>
|
|
327
|
+
</UndoStackContext.Provider>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
332
|
+
// Hooks
|
|
333
|
+
|
|
334
|
+
export function useUndoStack(): UndoStackValue {
|
|
335
|
+
const ctx = useContext(UndoStackContext);
|
|
336
|
+
if (!ctx) throw new Error('useUndoStack must be used inside <UndoStackProvider>');
|
|
337
|
+
return ctx;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function useUndoStackOptional(): UndoStackValue {
|
|
341
|
+
return useContext(UndoStackContext) ?? NOOP_VALUE;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Sinks-registration hook. Use a setSink call inside a useEffect — register
|
|
346
|
+
* on mount, unregister on unmount.
|
|
347
|
+
*/
|
|
348
|
+
export function useUndoSinks(): UndoSinksValue {
|
|
349
|
+
return useContext(UndoSinksContext) ?? NOOP_SINKS;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export { UndoStackContext, UndoSinksContext };
|
|
353
|
+
|
|
354
|
+
// Re-export the EditCommand type for ergonomic single-import in consumers.
|
|
355
|
+
export type { CommandRecord, EditCommand } from './undo-stack.ts';
|
|
@@ -4,13 +4,60 @@
|
|
|
4
4
|
import type { ServerWebSocket, WebSocketHandler } from 'bun';
|
|
5
5
|
|
|
6
6
|
import type { Api } from './api.ts';
|
|
7
|
+
import type { Collab, RoomConn } from './collab/index.ts';
|
|
7
8
|
import type { Context } from './context.ts';
|
|
8
9
|
import { createHmrBroadcaster } from './hmr-broadcast.ts';
|
|
9
10
|
import type { Inspect } from './inspect.ts';
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Per-connection state. `kind` discriminates between the legacy JSON
|
|
14
|
+
* inspector channel (`/_ws`) and the Phase 8 binary collab channel
|
|
15
|
+
* (`/_ws/collab/:slug`). server.ts sets this at upgrade time.
|
|
16
|
+
*/
|
|
17
|
+
export type WsData =
|
|
18
|
+
| {
|
|
19
|
+
id: string;
|
|
20
|
+
remote: string;
|
|
21
|
+
kind: 'inspector';
|
|
22
|
+
}
|
|
23
|
+
| {
|
|
24
|
+
id: string;
|
|
25
|
+
remote: string;
|
|
26
|
+
kind: 'collab';
|
|
27
|
+
slug: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Match the collab URL pattern and return the slug, or `null` if the path
|
|
32
|
+
* isn't a collab endpoint. Exported so server.ts owns the routing decision.
|
|
33
|
+
*
|
|
34
|
+
* Slug grammar matches `api.fileSlug` output: `[a-z0-9_-]+`. URL-encoded chars
|
|
35
|
+
* are rejected here — the legacy inspector path stays the catch-all.
|
|
36
|
+
*/
|
|
37
|
+
export function parseCollabSlug(pathname: string): string | null {
|
|
38
|
+
const m = pathname.match(/^\/_ws\/collab\/([a-z0-9_-]+)$/i);
|
|
39
|
+
return m ? (m[1] ?? null) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* DDR-047 — collab WS upgrades MUST come from a loopback host. The header
|
|
44
|
+
* carries `<host>:<port>` (or `<host>` if default-port); strip the port and
|
|
45
|
+
* compare against the loopback aliases. Anything else returns false → server.ts
|
|
46
|
+
* answers 403.
|
|
47
|
+
*/
|
|
48
|
+
export function isLoopbackHost(host: string | null): boolean {
|
|
49
|
+
if (!host) return false;
|
|
50
|
+
// Strip port. `[::1]:4399` keeps the `::1` bracketed; handle both shapes.
|
|
51
|
+
let h = host.trim().toLowerCase();
|
|
52
|
+
if (h.startsWith('[')) {
|
|
53
|
+
const close = h.indexOf(']');
|
|
54
|
+
if (close === -1) return false;
|
|
55
|
+
h = h.slice(1, close);
|
|
56
|
+
} else {
|
|
57
|
+
const colon = h.lastIndexOf(':');
|
|
58
|
+
if (colon !== -1) h = h.slice(0, colon);
|
|
59
|
+
}
|
|
60
|
+
return h === '127.0.0.1' || h === '::1' || h === 'localhost';
|
|
14
61
|
}
|
|
15
62
|
|
|
16
63
|
export interface Ws {
|
|
@@ -19,7 +66,7 @@ export interface Ws {
|
|
|
19
66
|
clientCount(): number;
|
|
20
67
|
}
|
|
21
68
|
|
|
22
|
-
export function createWs(ctx: Context, api: Api, inspect: Inspect): Ws {
|
|
69
|
+
export function createWs(ctx: Context, api: Api, inspect: Inspect, collab: Collab): Ws {
|
|
23
70
|
const clients = new Set<ServerWebSocket<WsData>>();
|
|
24
71
|
|
|
25
72
|
function send(ws: ServerWebSocket<WsData>, payload: unknown) {
|
|
@@ -32,7 +79,10 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect): Ws {
|
|
|
32
79
|
|
|
33
80
|
function broadcast(payload: unknown) {
|
|
34
81
|
const msg = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
35
|
-
for (const ws of clients)
|
|
82
|
+
for (const ws of clients) {
|
|
83
|
+
if (ws.data.kind !== 'inspector') continue;
|
|
84
|
+
send(ws, msg);
|
|
85
|
+
}
|
|
36
86
|
}
|
|
37
87
|
|
|
38
88
|
// Wire bus -> WS broadcasts. inspect.ts emits 'selected' / 'active' after every
|
|
@@ -45,20 +95,86 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect): Ws {
|
|
|
45
95
|
ctx.bus.on('comments', ({ file, comments }: { file: string; comments: unknown[] }) =>
|
|
46
96
|
broadcast({ type: 'comments', file, comments })
|
|
47
97
|
);
|
|
98
|
+
// Phase 8 Task 4 — AI activity banner. `entry` is null on clear / explicit
|
|
99
|
+
// end / heartbeat-grace expiry; non-null carries { file, author, …timestamps }.
|
|
100
|
+
ctx.bus.on('ai-activity', ({ file, entry }: { file: string; entry: unknown }) =>
|
|
101
|
+
broadcast({ type: 'ai-activity', file, entry })
|
|
102
|
+
);
|
|
103
|
+
// Phase 8 Task 7 — git lifecycle. `.git/HEAD` watcher emits this AFTER
|
|
104
|
+
// registry.flushAll() so any in-flight Y.Doc state is already on disk by
|
|
105
|
+
// the time the client renders the reload prompt. Inspector clients +
|
|
106
|
+
// canvas iframes both subscribe.
|
|
107
|
+
ctx.bus.on('git-lifecycle', (payload: unknown) => broadcast({ type: 'git-lifecycle', payload }));
|
|
48
108
|
|
|
49
109
|
// HMR broadcaster — turns fs:any change events into `canvas-hmr` messages.
|
|
50
110
|
// The iframe-side client (in _shell.html) decides reload strategy from `mode`.
|
|
51
111
|
createHmrBroadcaster(ctx, (msg) => broadcast(msg));
|
|
52
112
|
|
|
113
|
+
// Bind a connection to its room. Stored per-socket so close() can find the
|
|
114
|
+
// right room to disconnect from. Multiplexed via ws.data.id.
|
|
115
|
+
const collabConns = new Map<string, { roomSlug: string; conn: RoomConn }>();
|
|
116
|
+
|
|
117
|
+
function bindCollab(ws: ServerWebSocket<WsData>, slug: string): RoomConn {
|
|
118
|
+
const conn: RoomConn = {
|
|
119
|
+
id: ws.data.id,
|
|
120
|
+
send(payload: Uint8Array) {
|
|
121
|
+
try {
|
|
122
|
+
// Bun's ws.send accepts Uint8Array directly as binary.
|
|
123
|
+
ws.send(payload);
|
|
124
|
+
} catch {
|
|
125
|
+
/* close handler will clean up */
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
collabConns.set(ws.data.id, { roomSlug: slug, conn });
|
|
130
|
+
return conn;
|
|
131
|
+
}
|
|
132
|
+
|
|
53
133
|
const handler: WebSocketHandler<WsData> = {
|
|
54
|
-
open(ws) {
|
|
134
|
+
async open(ws) {
|
|
135
|
+
if (ws.data.kind === 'collab') {
|
|
136
|
+
const room = collab.registry.get(ws.data.slug);
|
|
137
|
+
const conn = bindCollab(ws, ws.data.slug);
|
|
138
|
+
await room.connect(conn);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
55
141
|
clients.add(ws);
|
|
56
142
|
send(ws, { type: 'snapshot', state: inspect.state });
|
|
57
143
|
},
|
|
58
|
-
close(ws) {
|
|
144
|
+
async close(ws) {
|
|
145
|
+
if (ws.data.kind === 'collab') {
|
|
146
|
+
const binding = collabConns.get(ws.data.id);
|
|
147
|
+
if (binding) {
|
|
148
|
+
collabConns.delete(ws.data.id);
|
|
149
|
+
const room = collab.registry.get(binding.roomSlug);
|
|
150
|
+
room.disconnect(binding.conn);
|
|
151
|
+
if (room.size() === 0) {
|
|
152
|
+
// Drop the room when the last peer leaves so memory doesn't grow
|
|
153
|
+
// unbounded across canvases over a long session.
|
|
154
|
+
await collab.registry.drop(binding.roomSlug);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
59
159
|
clients.delete(ws);
|
|
60
160
|
},
|
|
61
161
|
async message(ws, raw) {
|
|
162
|
+
if (ws.data.kind === 'collab') {
|
|
163
|
+
const binding = collabConns.get(ws.data.id);
|
|
164
|
+
if (!binding) return;
|
|
165
|
+
// y-websocket frames are always binary. Coerce whatever Bun handed us.
|
|
166
|
+
const bytes =
|
|
167
|
+
typeof raw === 'string'
|
|
168
|
+
? new TextEncoder().encode(raw)
|
|
169
|
+
: raw instanceof Uint8Array
|
|
170
|
+
? raw
|
|
171
|
+
: new Uint8Array(raw as ArrayBufferLike);
|
|
172
|
+
const room = collab.registry.get(binding.roomSlug);
|
|
173
|
+
room.receive(binding.conn, bytes);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Inspector channel — legacy JSON message protocol.
|
|
62
178
|
// biome-ignore lint/suspicious/noExplicitAny: JSON.parse result; narrowed by runtime discriminator checks below.
|
|
63
179
|
let msg: any;
|
|
64
180
|
try {
|