@dfosco/storyboard 0.6.12 → 0.6.13
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/dist/storyboard-ui.css +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +6 -2
- package/src/core/canvas/materializer.js +39 -13
- package/src/core/canvas/server.js +245 -46
- package/src/core/canvas/server.test.js +226 -0
- package/src/core/canvas/undoRedo.js +174 -0
- package/src/core/canvas/undoRedo.test.js +250 -0
- package/src/core/cli/canvasRedo.js +44 -0
- package/src/core/cli/canvasUndo.js +40 -0
- package/src/core/cli/dev.js +5 -10
- package/src/core/cli/index.js +6 -0
- package/src/internals/canvas/CanvasPage.jsx +247 -124
- package/src/internals/canvas/CanvasPage.multiselect.test.jsx +39 -26
- package/src/internals/canvas/canvasApi.js +29 -0
- package/src/internals/canvas/prototypeRoutes.jsx +131 -0
- package/src/internals/canvas/prototypesEntry.jsx +66 -0
- package/src/internals/canvas/useUndoRedo.js +75 -60
- package/src/internals/canvas/useUndoRedo.test.js +46 -178
- package/src/internals/vite/data-plugin.js +72 -0
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* GET /list — list all canvases
|
|
13
13
|
* GET /folders — list canvas folders
|
|
14
14
|
* PUT /update — append update events (widgets, sources, settings)
|
|
15
|
+
* POST /undo — append the inverse of a previously-applied event
|
|
16
|
+
* POST /redo — re-apply an undone event (mirror of /undo)
|
|
15
17
|
* PUT /rename-page — rename a canvas page file
|
|
16
18
|
* PUT /reorder-pages — save page order for a canvas folder
|
|
17
19
|
* GET /page-order — read page order for a folder
|
|
@@ -38,7 +40,9 @@ import fs from 'node:fs'
|
|
|
38
40
|
import path from 'node:path'
|
|
39
41
|
import { Buffer } from 'node:buffer'
|
|
40
42
|
import { createHash } from 'node:crypto'
|
|
41
|
-
import { materializeFromText, serializeEvent } from './materializer.js'
|
|
43
|
+
import { materializeFromText, parseCanvasJsonl, serializeEvent } from './materializer.js'
|
|
44
|
+
import { buildInverseEvent } from './undoRedo.js'
|
|
45
|
+
import { compactCanvas } from './compact.js'
|
|
42
46
|
import { toCanvasId, parseCanvasId } from './identity.js'
|
|
43
47
|
import {
|
|
44
48
|
GH_INSTALL_URL,
|
|
@@ -240,6 +244,53 @@ function appendEventRaw(filePath, event) {
|
|
|
240
244
|
fs.appendFileSync(filePath, serializeEvent(event) + '\n', 'utf-8')
|
|
241
245
|
}
|
|
242
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Generate a short random event id. Stamped on every canvas event so
|
|
249
|
+
* undo/redo can reference the inverse target by id alone.
|
|
250
|
+
*/
|
|
251
|
+
function generateEventId() {
|
|
252
|
+
return `evt_${Math.random().toString(36).slice(2, 10)}`
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Hard ceiling for runaway sessions: when a JSONL file grows past this size,
|
|
257
|
+
* compact it once (collapses history into a single `canvas_created` baseline).
|
|
258
|
+
* Set above the normal startup-compaction threshold (500 KB) so most files
|
|
259
|
+
* never hit it — only marathon sessions that don't restart the dev server.
|
|
260
|
+
*/
|
|
261
|
+
const SOFT_COMPACT_THRESHOLD_BYTES = 2 * 1024 * 1024
|
|
262
|
+
|
|
263
|
+
/** Per-file lock to avoid compacting the same file repeatedly on bursts. */
|
|
264
|
+
const inFlightCompactions = new Set()
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Check the file size after a write; if past the runaway threshold, kick off
|
|
268
|
+
* a one-shot compaction. Best-effort — failures are swallowed and logged.
|
|
269
|
+
*/
|
|
270
|
+
function maybeCompactRunawayFile(filePath) {
|
|
271
|
+
if (inFlightCompactions.has(filePath)) return
|
|
272
|
+
let size
|
|
273
|
+
try { size = fs.statSync(filePath).size } catch { return }
|
|
274
|
+
if (size < SOFT_COMPACT_THRESHOLD_BYTES) return
|
|
275
|
+
inFlightCompactions.add(filePath)
|
|
276
|
+
// Run on the next tick so this append's HMR push fires first and the
|
|
277
|
+
// compaction's HMR push lands after — the client treats both consistently
|
|
278
|
+
// (compaction's push is a single canvas_created event that reconciles).
|
|
279
|
+
setImmediate(() => {
|
|
280
|
+
try {
|
|
281
|
+
const result = compactCanvas(filePath, { force: true })
|
|
282
|
+
devLog().logEvent('info', `[canvas] runaway compaction ${path.basename(filePath)}`, {
|
|
283
|
+
before: result.before,
|
|
284
|
+
after: result.after,
|
|
285
|
+
})
|
|
286
|
+
} catch (err) {
|
|
287
|
+
devLog().logEvent('warn', `[canvas] runaway compaction failed for ${filePath}`, { error: err.message })
|
|
288
|
+
} finally {
|
|
289
|
+
inFlightCompactions.delete(filePath)
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
243
294
|
/**
|
|
244
295
|
* Generate a unique widget ID.
|
|
245
296
|
*/
|
|
@@ -702,13 +753,25 @@ export function createCanvasHandler(ctx) {
|
|
|
702
753
|
// Append an event to an existing canvas file.
|
|
703
754
|
// Marks the file in the write guard so the data plugin's watcher handler
|
|
704
755
|
// skips sending a duplicate HMR event (the server pushes its own via
|
|
705
|
-
// pushCanvasUpdate after the write).
|
|
756
|
+
// pushCanvasUpdate after the write). Stamps a stable random `id` so
|
|
757
|
+
// undo/redo markers can target events by id.
|
|
758
|
+
//
|
|
759
|
+
// Also runs the runaway-session hard ceiling: if the file grows past
|
|
760
|
+
// SOFT_COMPACT_THRESHOLD after writing, kick off a one-shot compaction
|
|
761
|
+
// to keep the JSONL → HMR → React pipeline responsive. The user's per-tab
|
|
762
|
+
// undo stack will lose pre-compaction targets (post-compaction undo calls
|
|
763
|
+
// return 404 which the client interprets as "clear the stack").
|
|
706
764
|
function appendEvent(filePath, event) {
|
|
765
|
+
if (event && typeof event === 'object' && !event.id) {
|
|
766
|
+
event.id = generateEventId()
|
|
767
|
+
}
|
|
707
768
|
markCanvasWrite(filePath)
|
|
708
769
|
appendEventRaw(filePath, event)
|
|
770
|
+
maybeCompactRunawayFile(filePath)
|
|
709
771
|
// Unmark after enough time for the watcher to fire and be suppressed.
|
|
710
772
|
// macOS FSEvents latency is typically 100-500ms; 1s covers edge cases.
|
|
711
773
|
setTimeout(() => unmarkCanvasWrite(filePath), 1000)
|
|
774
|
+
return event.id
|
|
712
775
|
}
|
|
713
776
|
|
|
714
777
|
/**
|
|
@@ -1007,6 +1070,9 @@ export function createCanvasHandler(ctx) {
|
|
|
1007
1070
|
|
|
1008
1071
|
try {
|
|
1009
1072
|
const ts = new Date().toISOString()
|
|
1073
|
+
// Snapshot of pre-update state so we can stamp prev* payloads on
|
|
1074
|
+
// source_updated and settings_updated events without re-reading.
|
|
1075
|
+
const current = readCanvas(filePath)
|
|
1010
1076
|
|
|
1011
1077
|
if (widgets) {
|
|
1012
1078
|
// Guard against accidental canvas wipes. Any widget IDs present in
|
|
@@ -1015,7 +1081,6 @@ export function createCanvasHandler(ctx) {
|
|
|
1015
1081
|
// This protects against stale-state writers (e.g. a debounced client
|
|
1016
1082
|
// save fired before an HMR push containing newly-added widgets was
|
|
1017
1083
|
// reconciled) silently wiping freshly-created widgets.
|
|
1018
|
-
const current = readCanvas(filePath)
|
|
1019
1084
|
const currentWidgets = current.widgets || []
|
|
1020
1085
|
if (body.replaceAll !== true) {
|
|
1021
1086
|
const incomingIds = new Set(widgets.map((w) => w && w.id).filter(Boolean))
|
|
@@ -1032,14 +1097,23 @@ export function createCanvasHandler(ctx) {
|
|
|
1032
1097
|
}
|
|
1033
1098
|
}
|
|
1034
1099
|
const stamped = stampBoundsAll(widgets)
|
|
1100
|
+
// widgets_replaced is not undoable — emitted only by compaction or
|
|
1101
|
+
// explicit replaceAll. The client should use PATCH/POST/DELETE for
|
|
1102
|
+
// granular, undoable changes.
|
|
1035
1103
|
appendEvent(filePath, { event: 'widgets_replaced', timestamp: ts, widgets: stamped })
|
|
1036
1104
|
}
|
|
1037
1105
|
|
|
1038
1106
|
if (sources) {
|
|
1039
|
-
appendEvent(filePath, {
|
|
1107
|
+
appendEvent(filePath, {
|
|
1108
|
+
event: 'source_updated',
|
|
1109
|
+
timestamp: ts,
|
|
1110
|
+
sources,
|
|
1111
|
+
prevSources: Array.isArray(current.sources) ? current.sources : [],
|
|
1112
|
+
})
|
|
1040
1113
|
}
|
|
1041
1114
|
|
|
1042
1115
|
if (connectors) {
|
|
1116
|
+
// connectors_replaced is not undoable — emitted only by compaction.
|
|
1043
1117
|
appendEvent(filePath, { event: 'connectors_replaced', timestamp: ts, connectors })
|
|
1044
1118
|
}
|
|
1045
1119
|
|
|
@@ -1051,7 +1125,17 @@ export function createCanvasHandler(ctx) {
|
|
|
1051
1125
|
}
|
|
1052
1126
|
}
|
|
1053
1127
|
if (Object.keys(filtered).length > 0) {
|
|
1054
|
-
|
|
1128
|
+
// Capture prior values for the keys being changed.
|
|
1129
|
+
const prevSettings = {}
|
|
1130
|
+
for (const key of Object.keys(filtered)) {
|
|
1131
|
+
prevSettings[key] = current[key]
|
|
1132
|
+
}
|
|
1133
|
+
appendEvent(filePath, {
|
|
1134
|
+
event: 'settings_updated',
|
|
1135
|
+
timestamp: ts,
|
|
1136
|
+
settings: filtered,
|
|
1137
|
+
prevSettings,
|
|
1138
|
+
})
|
|
1055
1139
|
}
|
|
1056
1140
|
}
|
|
1057
1141
|
|
|
@@ -1063,6 +1147,61 @@ export function createCanvasHandler(ctx) {
|
|
|
1063
1147
|
return
|
|
1064
1148
|
}
|
|
1065
1149
|
|
|
1150
|
+
// POST /undo — append the inverse of a previously-applied event, tagged
|
|
1151
|
+
// with meta.kind = 'undo' / meta.of = <targetEventId>. Body:
|
|
1152
|
+
// { name, eventId }
|
|
1153
|
+
// Returns { success, eventId, inverseEvent: {...} }.
|
|
1154
|
+
//
|
|
1155
|
+
// POST /redo behaves identically but stamps meta.kind = 'redo'. Per
|
|
1156
|
+
// convention the client passes the id of the *undo* event when redoing
|
|
1157
|
+
// (so the redo cancels the undo and brings back the original change).
|
|
1158
|
+
if ((routePath === '/undo' || routePath === '/redo') && method === 'POST') {
|
|
1159
|
+
const { name, eventId } = body
|
|
1160
|
+
const kind = routePath === '/undo' ? 'undo' : 'redo'
|
|
1161
|
+
|
|
1162
|
+
if (!name || !eventId) {
|
|
1163
|
+
sendJson(res, 400, { error: 'Canvas name and eventId are required' })
|
|
1164
|
+
return
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const filePath = findCanvasPath(root, name)
|
|
1168
|
+
if (!filePath) {
|
|
1169
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
1170
|
+
return
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
try {
|
|
1174
|
+
const text = fs.readFileSync(filePath, 'utf-8')
|
|
1175
|
+
const events = parseCanvasJsonl(text)
|
|
1176
|
+
const target = events.find((evt) => evt && evt.id === eventId)
|
|
1177
|
+
if (!target) {
|
|
1178
|
+
sendJson(res, 404, { error: `Event "${eventId}" not found in canvas "${name}"` })
|
|
1179
|
+
return
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const inverse = buildInverseEvent(target, kind)
|
|
1183
|
+
if (!inverse) {
|
|
1184
|
+
sendJson(res, 400, {
|
|
1185
|
+
error: `Event "${eventId}" (type "${target.event}") cannot be ${kind === 'undo' ? 'undone' : 'redone'}: missing inverse payload or unsupported event type`,
|
|
1186
|
+
})
|
|
1187
|
+
return
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
inverse.timestamp = new Date().toISOString()
|
|
1191
|
+
const inverseId = appendEvent(filePath, inverse)
|
|
1192
|
+
|
|
1193
|
+
sendJson(res, 200, {
|
|
1194
|
+
success: true,
|
|
1195
|
+
eventId: inverseId,
|
|
1196
|
+
inverseEvent: { ...inverse, id: inverseId },
|
|
1197
|
+
})
|
|
1198
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1199
|
+
} catch (err) {
|
|
1200
|
+
sendJson(res, 500, { error: `Failed to ${kind} event: ${err.message}` })
|
|
1201
|
+
}
|
|
1202
|
+
return
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1066
1205
|
// POST /widget — append a widget_added event
|
|
1067
1206
|
if (routePath === '/widget' && method === 'POST') {
|
|
1068
1207
|
const { name, type, props = {}, pool, near, direction, resolve, source, gap } = body
|
|
@@ -1150,13 +1289,13 @@ export function createCanvasHandler(ctx) {
|
|
|
1150
1289
|
|
|
1151
1290
|
const widget = stampBounds({ id: widgetId, type, position, props })
|
|
1152
1291
|
|
|
1153
|
-
appendEvent(filePath, {
|
|
1292
|
+
const eventId = appendEvent(filePath, {
|
|
1154
1293
|
event: 'widget_added',
|
|
1155
1294
|
timestamp: new Date().toISOString(),
|
|
1156
1295
|
widget,
|
|
1157
1296
|
})
|
|
1158
1297
|
|
|
1159
|
-
const response = { success: true, widget }
|
|
1298
|
+
const response = { success: true, widget, eventId }
|
|
1160
1299
|
if (hotProbe.ready) response.hotSession = { id: null, tmuxName: null, webglReady: hotProbe.webglReady }
|
|
1161
1300
|
sendJson(res, 201, response)
|
|
1162
1301
|
pushCanvasUpdate(name, filePath, __viteWs)
|
|
@@ -1190,10 +1329,13 @@ export function createCanvasHandler(ctx) {
|
|
|
1190
1329
|
return
|
|
1191
1330
|
}
|
|
1192
1331
|
|
|
1193
|
-
appendEvent(filePath, {
|
|
1332
|
+
const eventId = appendEvent(filePath, {
|
|
1194
1333
|
event: 'widget_removed',
|
|
1195
1334
|
timestamp: new Date().toISOString(),
|
|
1196
1335
|
widgetId,
|
|
1336
|
+
// Carry the full removed widget so undo can re-add it without
|
|
1337
|
+
// walking history. Symmetric to widget_added.widget.
|
|
1338
|
+
widget,
|
|
1197
1339
|
})
|
|
1198
1340
|
|
|
1199
1341
|
// Orphan terminal session when a terminal widget is deleted (not killed)
|
|
@@ -1206,7 +1348,7 @@ export function createCanvasHandler(ctx) {
|
|
|
1206
1348
|
}
|
|
1207
1349
|
}
|
|
1208
1350
|
|
|
1209
|
-
sendJson(res, 200, { success: true, removed: 1 })
|
|
1351
|
+
sendJson(res, 200, { success: true, removed: 1, eventId })
|
|
1210
1352
|
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1211
1353
|
} catch (err) {
|
|
1212
1354
|
sendJson(res, 500, { error: `Failed to remove widget: ${err.message}` })
|
|
@@ -1242,25 +1384,34 @@ export function createCanvasHandler(ctx) {
|
|
|
1242
1384
|
}
|
|
1243
1385
|
|
|
1244
1386
|
const ts = new Date().toISOString()
|
|
1387
|
+
const eventIds = []
|
|
1245
1388
|
|
|
1246
1389
|
if (props) {
|
|
1247
|
-
|
|
1390
|
+
// Capture only the keys being changed so undo restores them
|
|
1391
|
+
// precisely. Missing keys mean "this prop didn't exist before".
|
|
1392
|
+
const prevProps = {}
|
|
1393
|
+
for (const key of Object.keys(props)) {
|
|
1394
|
+
prevProps[key] = widget.props ? widget.props[key] : undefined
|
|
1395
|
+
}
|
|
1396
|
+
eventIds.push(appendEvent(filePath, {
|
|
1248
1397
|
event: 'widget_updated',
|
|
1249
1398
|
timestamp: ts,
|
|
1250
1399
|
widgetId,
|
|
1251
1400
|
props,
|
|
1252
|
-
|
|
1401
|
+
prevProps,
|
|
1402
|
+
}))
|
|
1253
1403
|
}
|
|
1254
1404
|
|
|
1255
1405
|
if (position) {
|
|
1256
1406
|
// Merge with existing position so partial updates (only --x or --y) are safe
|
|
1257
1407
|
const mergedPosition = { ...widget.position, ...position }
|
|
1258
|
-
appendEvent(filePath, {
|
|
1408
|
+
eventIds.push(appendEvent(filePath, {
|
|
1259
1409
|
event: 'widget_moved',
|
|
1260
1410
|
timestamp: ts,
|
|
1261
1411
|
widgetId,
|
|
1262
1412
|
position: mergedPosition,
|
|
1263
|
-
|
|
1413
|
+
prevPosition: widget.position || { x: 0, y: 0 },
|
|
1414
|
+
}))
|
|
1264
1415
|
}
|
|
1265
1416
|
|
|
1266
1417
|
// Return the merged widget for convenience
|
|
@@ -1269,7 +1420,7 @@ export function createCanvasHandler(ctx) {
|
|
|
1269
1420
|
props: { ...widget.props, ...(props || {}) },
|
|
1270
1421
|
position: position ? { ...widget.position, ...position } : widget.position,
|
|
1271
1422
|
}
|
|
1272
|
-
sendJson(res, 200, { success: true, widget: merged })
|
|
1423
|
+
sendJson(res, 200, { success: true, widget: merged, eventIds })
|
|
1273
1424
|
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1274
1425
|
} catch (err) {
|
|
1275
1426
|
sendJson(res, 500, { error: `Failed to update widget: ${err.message}` })
|
|
@@ -1417,13 +1568,13 @@ export function createCanvasHandler(ctx) {
|
|
|
1417
1568
|
meta: meta && typeof meta === 'object' ? { ...meta } : {},
|
|
1418
1569
|
}
|
|
1419
1570
|
|
|
1420
|
-
appendEvent(filePath, {
|
|
1571
|
+
const eventId = appendEvent(filePath, {
|
|
1421
1572
|
event: 'connector_added',
|
|
1422
1573
|
timestamp: new Date().toISOString(),
|
|
1423
1574
|
connector,
|
|
1424
1575
|
})
|
|
1425
1576
|
|
|
1426
|
-
sendJson(res, 201, { success: true, connector })
|
|
1577
|
+
sendJson(res, 201, { success: true, connector, eventId })
|
|
1427
1578
|
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1428
1579
|
} catch (err) {
|
|
1429
1580
|
sendJson(res, 500, { error: `Failed to add connector: ${err.message}` })
|
|
@@ -1469,14 +1620,28 @@ export function createCanvasHandler(ctx) {
|
|
|
1469
1620
|
if (startAnchor) updates.startAnchor = startAnchor
|
|
1470
1621
|
if (endAnchor) updates.endAnchor = endAnchor
|
|
1471
1622
|
|
|
1472
|
-
|
|
1623
|
+
// Capture the previous values for the keys we're changing so undo
|
|
1624
|
+
// can restore them precisely.
|
|
1625
|
+
const prevUpdates = {}
|
|
1626
|
+
if (meta) {
|
|
1627
|
+
const prevMeta = {}
|
|
1628
|
+
for (const key of Object.keys(meta)) {
|
|
1629
|
+
prevMeta[key] = connector.meta ? connector.meta[key] : undefined
|
|
1630
|
+
}
|
|
1631
|
+
prevUpdates.meta = prevMeta
|
|
1632
|
+
}
|
|
1633
|
+
if (startAnchor) prevUpdates.startAnchor = connector.start?.anchor
|
|
1634
|
+
if (endAnchor) prevUpdates.endAnchor = connector.end?.anchor
|
|
1635
|
+
|
|
1636
|
+
const eventId = appendEvent(filePath, {
|
|
1473
1637
|
event: 'connector_updated',
|
|
1474
1638
|
timestamp: new Date().toISOString(),
|
|
1475
1639
|
connectorId,
|
|
1476
1640
|
updates,
|
|
1641
|
+
prevUpdates,
|
|
1477
1642
|
})
|
|
1478
1643
|
|
|
1479
|
-
sendJson(res, 200, { success: true })
|
|
1644
|
+
sendJson(res, 200, { success: true, eventId })
|
|
1480
1645
|
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1481
1646
|
|
|
1482
1647
|
// Inject messaging skill into both terminals when mode changes
|
|
@@ -1562,8 +1727,8 @@ export function createCanvasHandler(ctx) {
|
|
|
1562
1727
|
|
|
1563
1728
|
try {
|
|
1564
1729
|
const data = readCanvas(filePath)
|
|
1565
|
-
const
|
|
1566
|
-
if (!
|
|
1730
|
+
const existing = (data.connectors || []).find((c) => c.id === connectorId)
|
|
1731
|
+
if (!existing) {
|
|
1567
1732
|
sendJson(res, 404, { error: `Connector "${connectorId}" not found in canvas "${name}"` })
|
|
1568
1733
|
return
|
|
1569
1734
|
}
|
|
@@ -1576,14 +1741,17 @@ export function createCanvasHandler(ctx) {
|
|
|
1576
1741
|
return out
|
|
1577
1742
|
})
|
|
1578
1743
|
|
|
1579
|
-
appendEvent(filePath, {
|
|
1744
|
+
const eventId = appendEvent(filePath, {
|
|
1580
1745
|
event: 'connector_waypoints_set',
|
|
1581
1746
|
timestamp: new Date().toISOString(),
|
|
1582
1747
|
connectorId,
|
|
1583
1748
|
waypoints: normalized,
|
|
1749
|
+
// null prevWaypoints means "this connector had no manual routing
|
|
1750
|
+
// before"; undo emits connector_waypoints_cleared in that case.
|
|
1751
|
+
prevWaypoints: Array.isArray(existing.waypoints) ? existing.waypoints : null,
|
|
1584
1752
|
})
|
|
1585
1753
|
|
|
1586
|
-
sendJson(res, 200, { success: true, waypoints: normalized })
|
|
1754
|
+
sendJson(res, 200, { success: true, waypoints: normalized, eventId })
|
|
1587
1755
|
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1588
1756
|
} catch (err) {
|
|
1589
1757
|
sendJson(res, 500, { error: `Failed to set waypoints: ${err.message}` })
|
|
@@ -1608,19 +1776,20 @@ export function createCanvasHandler(ctx) {
|
|
|
1608
1776
|
|
|
1609
1777
|
try {
|
|
1610
1778
|
const data = readCanvas(filePath)
|
|
1611
|
-
const
|
|
1612
|
-
if (!
|
|
1779
|
+
const existing = (data.connectors || []).find((c) => c.id === connectorId)
|
|
1780
|
+
if (!existing) {
|
|
1613
1781
|
sendJson(res, 404, { error: `Connector "${connectorId}" not found in canvas "${name}"` })
|
|
1614
1782
|
return
|
|
1615
1783
|
}
|
|
1616
1784
|
|
|
1617
|
-
appendEvent(filePath, {
|
|
1785
|
+
const eventId = appendEvent(filePath, {
|
|
1618
1786
|
event: 'connector_waypoints_cleared',
|
|
1619
1787
|
timestamp: new Date().toISOString(),
|
|
1620
1788
|
connectorId,
|
|
1789
|
+
prevWaypoints: Array.isArray(existing.waypoints) ? existing.waypoints : null,
|
|
1621
1790
|
})
|
|
1622
1791
|
|
|
1623
|
-
sendJson(res, 200, { success: true })
|
|
1792
|
+
sendJson(res, 200, { success: true, eventId })
|
|
1624
1793
|
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1625
1794
|
} catch (err) {
|
|
1626
1795
|
sendJson(res, 500, { error: `Failed to clear waypoints: ${err.message}` })
|
|
@@ -1645,19 +1814,22 @@ export function createCanvasHandler(ctx) {
|
|
|
1645
1814
|
|
|
1646
1815
|
try {
|
|
1647
1816
|
const data = readCanvas(filePath)
|
|
1648
|
-
const
|
|
1649
|
-
if (!
|
|
1817
|
+
const connector = (data.connectors || []).find((c) => c.id === connectorId)
|
|
1818
|
+
if (!connector) {
|
|
1650
1819
|
sendJson(res, 404, { error: `Connector "${connectorId}" not found in canvas "${name}"` })
|
|
1651
1820
|
return
|
|
1652
1821
|
}
|
|
1653
1822
|
|
|
1654
|
-
appendEvent(filePath, {
|
|
1823
|
+
const eventId = appendEvent(filePath, {
|
|
1655
1824
|
event: 'connector_removed',
|
|
1656
1825
|
timestamp: new Date().toISOString(),
|
|
1657
1826
|
connectorId,
|
|
1827
|
+
// Carry the full removed connector so undo can re-add it without
|
|
1828
|
+
// walking history. Symmetric to connector_added.connector.
|
|
1829
|
+
connector,
|
|
1658
1830
|
})
|
|
1659
1831
|
|
|
1660
|
-
sendJson(res, 200, { success: true, removed: 1 })
|
|
1832
|
+
sendJson(res, 200, { success: true, removed: 1, eventId })
|
|
1661
1833
|
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1662
1834
|
} catch (err) {
|
|
1663
1835
|
sendJson(res, 500, { error: `Failed to remove connector: ${err.message}` })
|
|
@@ -1901,14 +2073,14 @@ export function createCanvasHandler(ctx) {
|
|
|
1901
2073
|
|
|
1902
2074
|
const widget = stampBounds({ id: widgetId, type, position, props })
|
|
1903
2075
|
|
|
1904
|
-
appendEvent(filePath, { event: 'widget_added', timestamp: ts, widget })
|
|
2076
|
+
const eventId = appendEvent(filePath, { event: 'widget_added', timestamp: ts, widget })
|
|
1905
2077
|
|
|
1906
2078
|
widgetIds.add(widgetId)
|
|
1907
2079
|
widgetMap.set(widgetId, widget)
|
|
1908
2080
|
refs[String(i)] = widgetId
|
|
1909
2081
|
if (ref) refs[ref] = widgetId
|
|
1910
2082
|
|
|
1911
|
-
const result = { index: i, op: 'create-widget', ref: ref || undefined, widgetId, widget }
|
|
2083
|
+
const result = { index: i, op: 'create-widget', ref: ref || undefined, widgetId, widget, eventId }
|
|
1912
2084
|
if (hotProbe.ready) result.hotSession = { id: null, tmuxName: null, webglReady: hotProbe.webglReady }
|
|
1913
2085
|
results.push(result)
|
|
1914
2086
|
break
|
|
@@ -1921,12 +2093,16 @@ export function createCanvasHandler(ctx) {
|
|
|
1921
2093
|
if (!props) throw new Error('props is required')
|
|
1922
2094
|
if (!widgetIds.has(widgetId)) throw new Error(`Widget "${widgetId}" not found`)
|
|
1923
2095
|
|
|
1924
|
-
appendEvent(filePath, { event: 'widget_updated', timestamp: ts, widgetId, props })
|
|
1925
|
-
|
|
1926
2096
|
const existing = widgetMap.get(widgetId)
|
|
2097
|
+
const prevProps = {}
|
|
2098
|
+
for (const key of Object.keys(props)) {
|
|
2099
|
+
prevProps[key] = existing?.props ? existing.props[key] : undefined
|
|
2100
|
+
}
|
|
2101
|
+
const eventId = appendEvent(filePath, { event: 'widget_updated', timestamp: ts, widgetId, props, prevProps })
|
|
2102
|
+
|
|
1927
2103
|
if (existing) existing.props = { ...existing.props, ...props }
|
|
1928
2104
|
|
|
1929
|
-
results.push({ index: i, op: 'update-widget', widgetId, success: true })
|
|
2105
|
+
results.push({ index: i, op: 'update-widget', widgetId, success: true, eventId })
|
|
1930
2106
|
break
|
|
1931
2107
|
}
|
|
1932
2108
|
|
|
@@ -1939,12 +2115,13 @@ export function createCanvasHandler(ctx) {
|
|
|
1939
2115
|
|
|
1940
2116
|
const existing = widgetMap.get(widgetId)
|
|
1941
2117
|
const mergedPosition = { ...(existing?.position || {}), ...position }
|
|
2118
|
+
const prevPosition = existing?.position || { x: 0, y: 0 }
|
|
1942
2119
|
|
|
1943
|
-
appendEvent(filePath, { event: 'widget_moved', timestamp: ts, widgetId, position: mergedPosition })
|
|
2120
|
+
const eventId = appendEvent(filePath, { event: 'widget_moved', timestamp: ts, widgetId, position: mergedPosition, prevPosition })
|
|
1944
2121
|
|
|
1945
2122
|
if (existing) existing.position = mergedPosition
|
|
1946
2123
|
|
|
1947
|
-
results.push({ index: i, op: 'move-widget', widgetId, success: true })
|
|
2124
|
+
results.push({ index: i, op: 'move-widget', widgetId, success: true, eventId })
|
|
1948
2125
|
break
|
|
1949
2126
|
}
|
|
1950
2127
|
|
|
@@ -1953,12 +2130,18 @@ export function createCanvasHandler(ctx) {
|
|
|
1953
2130
|
if (!widgetId) throw new Error('widgetId is required')
|
|
1954
2131
|
if (!widgetIds.has(widgetId)) throw new Error(`Widget "${widgetId}" not found`)
|
|
1955
2132
|
|
|
1956
|
-
|
|
2133
|
+
const existing = widgetMap.get(widgetId)
|
|
2134
|
+
const eventId = appendEvent(filePath, {
|
|
2135
|
+
event: 'widget_removed',
|
|
2136
|
+
timestamp: ts,
|
|
2137
|
+
widgetId,
|
|
2138
|
+
widget: existing,
|
|
2139
|
+
})
|
|
1957
2140
|
|
|
1958
2141
|
widgetIds.delete(widgetId)
|
|
1959
2142
|
widgetMap.delete(widgetId)
|
|
1960
2143
|
|
|
1961
|
-
results.push({ index: i, op: 'delete-widget', widgetId, success: true })
|
|
2144
|
+
results.push({ index: i, op: 'delete-widget', widgetId, success: true, eventId })
|
|
1962
2145
|
break
|
|
1963
2146
|
}
|
|
1964
2147
|
|
|
@@ -1993,14 +2176,14 @@ export function createCanvasHandler(ctx) {
|
|
|
1993
2176
|
meta: op.meta && typeof op.meta === 'object' ? { ...op.meta } : {},
|
|
1994
2177
|
}
|
|
1995
2178
|
|
|
1996
|
-
appendEvent(filePath, { event: 'connector_added', timestamp: ts, connector })
|
|
2179
|
+
const eventId = appendEvent(filePath, { event: 'connector_added', timestamp: ts, connector })
|
|
1997
2180
|
|
|
1998
2181
|
connectorIds.add(connectorId)
|
|
1999
2182
|
connectorMap.set(connectorId, connector)
|
|
2000
2183
|
refs[String(i)] = connectorId
|
|
2001
2184
|
if (ref) refs[ref] = connectorId
|
|
2002
2185
|
|
|
2003
|
-
results.push({ index: i, op: 'create-connector', ref: ref || undefined, connectorId, success: true })
|
|
2186
|
+
results.push({ index: i, op: 'create-connector', ref: ref || undefined, connectorId, success: true, eventId })
|
|
2004
2187
|
break
|
|
2005
2188
|
}
|
|
2006
2189
|
|
|
@@ -2009,11 +2192,17 @@ export function createCanvasHandler(ctx) {
|
|
|
2009
2192
|
if (!connectorId) throw new Error('connectorId is required')
|
|
2010
2193
|
if (!connectorIds.has(connectorId)) throw new Error(`Connector "${connectorId}" not found`)
|
|
2011
2194
|
|
|
2012
|
-
|
|
2195
|
+
const existing = connectorMap.get(connectorId)
|
|
2196
|
+
const eventId = appendEvent(filePath, {
|
|
2197
|
+
event: 'connector_removed',
|
|
2198
|
+
timestamp: ts,
|
|
2199
|
+
connectorId,
|
|
2200
|
+
connector: existing,
|
|
2201
|
+
})
|
|
2013
2202
|
connectorIds.delete(connectorId)
|
|
2014
2203
|
connectorMap.delete(connectorId)
|
|
2015
2204
|
|
|
2016
|
-
results.push({ index: i, op: 'delete-connector', connectorId, success: true })
|
|
2205
|
+
results.push({ index: i, op: 'delete-connector', connectorId, success: true, eventId })
|
|
2017
2206
|
break
|
|
2018
2207
|
}
|
|
2019
2208
|
|
|
@@ -2024,12 +2213,22 @@ export function createCanvasHandler(ctx) {
|
|
|
2024
2213
|
if (!meta) throw new Error('meta is required')
|
|
2025
2214
|
if (!connectorIds.has(connectorId)) throw new Error(`Connector "${connectorId}" not found`)
|
|
2026
2215
|
|
|
2027
|
-
appendEvent(filePath, { event: 'connector_updated', timestamp: ts, connectorId, updates: { meta } })
|
|
2028
|
-
|
|
2029
2216
|
const existing = connectorMap.get(connectorId)
|
|
2217
|
+
const prevMeta = {}
|
|
2218
|
+
for (const key of Object.keys(meta)) {
|
|
2219
|
+
prevMeta[key] = existing?.meta ? existing.meta[key] : undefined
|
|
2220
|
+
}
|
|
2221
|
+
const eventId = appendEvent(filePath, {
|
|
2222
|
+
event: 'connector_updated',
|
|
2223
|
+
timestamp: ts,
|
|
2224
|
+
connectorId,
|
|
2225
|
+
updates: { meta },
|
|
2226
|
+
prevUpdates: { meta: prevMeta },
|
|
2227
|
+
})
|
|
2228
|
+
|
|
2030
2229
|
if (existing) existing.meta = { ...(existing.meta || {}), ...meta }
|
|
2031
2230
|
|
|
2032
|
-
results.push({ index: i, op: 'update-connector', connectorId, success: true })
|
|
2231
|
+
results.push({ index: i, op: 'update-connector', connectorId, success: true, eventId })
|
|
2033
2232
|
break
|
|
2034
2233
|
}
|
|
2035
2234
|
|