pmx-canvas 0.1.14 → 0.1.16
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/CHANGELOG.md +153 -0
- package/Readme.md +108 -1058
- package/dist/canvas/global.css +141 -0
- package/dist/canvas/index.js +124 -74
- package/dist/json-render/index.css +1 -1
- package/dist/types/client/nodes/ContextNode.d.ts +11 -2
- package/dist/types/client/nodes/HtmlNode.d.ts +5 -0
- package/dist/types/client/nodes/StatusNode.d.ts +1 -0
- package/dist/types/client/state/canvas-store.d.ts +11 -3
- package/dist/types/client/state/intent-bridge.d.ts +5 -1
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/json-render/catalog.d.ts +1 -1
- package/dist/types/mcp/canvas-access.d.ts +7 -1
- package/dist/types/server/agent-context.d.ts +1 -0
- package/dist/types/server/canvas-operations.d.ts +4 -2
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-serialization.d.ts +3 -0
- package/dist/types/server/canvas-state.d.ts +51 -4
- package/dist/types/server/demo.d.ts +5 -0
- package/dist/types/server/index.d.ts +13 -3
- package/dist/types/server/web-artifacts.d.ts +18 -0
- package/dist/types/shared/canvas-node-kind.d.ts +5 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +43 -0
- package/skills/pmx-canvas-testing/SKILL.md +17 -0
- package/src/cli/agent.ts +52 -5
- package/src/cli/index.ts +2 -23
- package/src/client/canvas/AttentionHistory.tsx +14 -1
- package/src/client/canvas/CanvasNode.tsx +1 -1
- package/src/client/canvas/CanvasViewport.tsx +3 -0
- package/src/client/canvas/ContextPinBar.tsx +2 -1
- package/src/client/canvas/DockedNode.tsx +112 -13
- package/src/client/canvas/ExpandedNodeOverlay.tsx +5 -0
- package/src/client/canvas/Minimap.tsx +1 -0
- package/src/client/icons.tsx +1 -0
- package/src/client/nodes/ContextNode.tsx +128 -6
- package/src/client/nodes/HtmlNode.tsx +151 -0
- package/src/client/nodes/StatusNode.tsx +16 -1
- package/src/client/nodes/StatusSummary.tsx +2 -1
- package/src/client/state/canvas-store.ts +37 -7
- package/src/client/state/intent-bridge.ts +9 -4
- package/src/client/state/sse-bridge.ts +2 -1
- package/src/client/theme/global.css +141 -0
- package/src/client/types.ts +3 -0
- package/src/mcp/canvas-access.ts +34 -7
- package/src/mcp/server.ts +178 -25
- package/src/server/agent-context.ts +50 -3
- package/src/server/canvas-operations.ts +20 -3
- package/src/server/canvas-provenance.ts +2 -1
- package/src/server/canvas-serialization.ts +38 -13
- package/src/server/canvas-state.ts +305 -34
- package/src/server/demo.ts +792 -0
- package/src/server/index.ts +33 -3
- package/src/server/server.ts +98 -14
- package/src/server/web-artifacts.ts +116 -3
- package/src/shared/canvas-node-kind.ts +14 -0
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
* workspace root on every mutation (debounced). Auto-loads on `loadFromDisk()`.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
12
13
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, renameSync, unlinkSync } from 'node:fs';
|
|
13
|
-
import { join, dirname } from 'node:path';
|
|
14
|
+
import { isAbsolute, join, dirname, relative } from 'node:path';
|
|
15
|
+
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
14
16
|
import { normalizeCanvasNodeData } from './canvas-provenance.js';
|
|
15
17
|
import {
|
|
16
18
|
type CanvasPlacementRect,
|
|
@@ -25,12 +27,37 @@ function logCanvasStateWarning(action: string, error: unknown, details?: Record<
|
|
|
25
27
|
console.warn(`[canvas-state] ${action}`, { error, ...(details ?? {}) });
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
function normalizePositiveInteger(value: number | undefined): number | undefined {
|
|
31
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined;
|
|
32
|
+
return Math.floor(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
28
35
|
export const PMX_CANVAS_DIR = '.pmx-canvas';
|
|
29
36
|
const STATE_FILENAME = 'state.json';
|
|
30
37
|
const SNAPSHOTS_SUBDIR = 'snapshots';
|
|
38
|
+
const BLOBS_SUBDIR = 'blobs';
|
|
31
39
|
const LEGACY_STATE_FILENAME = '.pmx-canvas.json';
|
|
32
40
|
const LEGACY_SNAPSHOTS_DIR = '.pmx-canvas-snapshots';
|
|
33
41
|
const SAVE_DEBOUNCE_MS = 500;
|
|
42
|
+
const BLOB_JSON_THRESHOLD_BYTES = Number(process.env.PMX_CANVAS_BLOB_THRESHOLD_BYTES ?? '2048');
|
|
43
|
+
const BLOB_DATA_FIELDS = new Set([
|
|
44
|
+
'html',
|
|
45
|
+
'toolInput',
|
|
46
|
+
'toolResult',
|
|
47
|
+
'toolDefinition',
|
|
48
|
+
'resourceMeta',
|
|
49
|
+
'appModelContext',
|
|
50
|
+
'appCheckpoint',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
export interface PersistedBlobRef {
|
|
54
|
+
__pmxCanvasBlob: 'v1';
|
|
55
|
+
path: string;
|
|
56
|
+
sha256: string;
|
|
57
|
+
encoding: 'json+gzip';
|
|
58
|
+
bytes: number;
|
|
59
|
+
jsonBytes: number;
|
|
60
|
+
}
|
|
34
61
|
|
|
35
62
|
interface PersistedCanvasState {
|
|
36
63
|
version: number;
|
|
@@ -58,6 +85,24 @@ export interface CanvasSnapshot {
|
|
|
58
85
|
edgeCount: number;
|
|
59
86
|
}
|
|
60
87
|
|
|
88
|
+
export interface CanvasSnapshotListOptions {
|
|
89
|
+
limit?: number;
|
|
90
|
+
query?: string;
|
|
91
|
+
all?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface CanvasSnapshotGcOptions {
|
|
95
|
+
keep?: number;
|
|
96
|
+
dryRun?: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface CanvasSnapshotGcResult {
|
|
100
|
+
ok: boolean;
|
|
101
|
+
kept: number;
|
|
102
|
+
deleted: CanvasSnapshot[];
|
|
103
|
+
dryRun: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
61
106
|
export interface CanvasNodeState {
|
|
62
107
|
id: string;
|
|
63
108
|
type:
|
|
@@ -74,6 +119,7 @@ export interface CanvasNodeState {
|
|
|
74
119
|
| 'trace'
|
|
75
120
|
| 'file'
|
|
76
121
|
| 'image'
|
|
122
|
+
| 'html'
|
|
77
123
|
| 'group';
|
|
78
124
|
position: { x: number; y: number };
|
|
79
125
|
size: { width: number; height: number };
|
|
@@ -152,6 +198,20 @@ function formatBatchUpdateDescription(updates: CanvasNodeUpdate[]): string {
|
|
|
152
198
|
return parts.length > 0 ? `${prefix} (${parts.join(', ')})` : prefix;
|
|
153
199
|
}
|
|
154
200
|
|
|
201
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
202
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isPersistedBlobRef(value: unknown): value is PersistedBlobRef {
|
|
206
|
+
return isRecord(value) &&
|
|
207
|
+
value.__pmxCanvasBlob === 'v1' &&
|
|
208
|
+
typeof value.path === 'string' &&
|
|
209
|
+
typeof value.sha256 === 'string' &&
|
|
210
|
+
value.encoding === 'json+gzip' &&
|
|
211
|
+
typeof value.bytes === 'number' &&
|
|
212
|
+
typeof value.jsonBytes === 'number';
|
|
213
|
+
}
|
|
214
|
+
|
|
155
215
|
class CanvasStateManager {
|
|
156
216
|
private nodes = new Map<string, CanvasNodeState>();
|
|
157
217
|
private edges = new Map<string, CanvasEdge>();
|
|
@@ -179,7 +239,7 @@ class CanvasStateManager {
|
|
|
179
239
|
|
|
180
240
|
// ── Mutation recorder (for undo/redo history) ─────────────
|
|
181
241
|
private _mutationRecorder: ((info: MutationRecordInfo) => void) | null = null;
|
|
182
|
-
private
|
|
242
|
+
private _suppressRecordingDepth = 0;
|
|
183
243
|
|
|
184
244
|
/** Register a mutation recorder. Used by mutation-history to capture undo/redo closures. */
|
|
185
245
|
onMutation(cb: (info: MutationRecordInfo) => void): void {
|
|
@@ -188,8 +248,8 @@ class CanvasStateManager {
|
|
|
188
248
|
|
|
189
249
|
/** Run a function with mutation recording suppressed (for undo/redo replay and computed edges). */
|
|
190
250
|
withSuppressedRecording(fn: () => void): void {
|
|
191
|
-
this.
|
|
192
|
-
try { fn(); } finally { this.
|
|
251
|
+
this._suppressRecordingDepth++;
|
|
252
|
+
try { fn(); } finally { this._suppressRecordingDepth--; }
|
|
193
253
|
}
|
|
194
254
|
|
|
195
255
|
/** Create a closure that runs with recording suppressed. */
|
|
@@ -198,7 +258,7 @@ class CanvasStateManager {
|
|
|
198
258
|
}
|
|
199
259
|
|
|
200
260
|
private recordMutation(info: MutationRecordInfo): void {
|
|
201
|
-
if (this.
|
|
261
|
+
if (this._suppressRecordingDepth > 0 || !this._mutationRecorder) return;
|
|
202
262
|
try {
|
|
203
263
|
this._mutationRecorder(info);
|
|
204
264
|
} catch (error) {
|
|
@@ -258,10 +318,18 @@ class CanvasStateManager {
|
|
|
258
318
|
}
|
|
259
319
|
|
|
260
320
|
private normalizeNode(node: CanvasNodeState): CanvasNodeState {
|
|
261
|
-
|
|
321
|
+
const normalized: CanvasNodeState = {
|
|
262
322
|
...node,
|
|
263
323
|
data: normalizeCanvasNodeData(node.type, node.data),
|
|
264
324
|
};
|
|
325
|
+
// Context nodes are always docked to the right side as a pill/panel widget
|
|
326
|
+
// (see DockedNode.tsx). They start collapsed so the user sees the slim
|
|
327
|
+
// pill first; expanding reveals the full context overview panel.
|
|
328
|
+
if (normalized.type === 'context' && normalized.dockPosition !== 'right') {
|
|
329
|
+
normalized.dockPosition = 'right';
|
|
330
|
+
normalized.collapsed = true;
|
|
331
|
+
}
|
|
332
|
+
return normalized;
|
|
265
333
|
}
|
|
266
334
|
|
|
267
335
|
private reflowAllGroups(): void {
|
|
@@ -285,12 +353,13 @@ class CanvasStateManager {
|
|
|
285
353
|
}
|
|
286
354
|
}
|
|
287
355
|
|
|
288
|
-
private translateGroupChildren(groupId: string, deltaX: number, deltaY: number): void {
|
|
356
|
+
private translateGroupChildren(groupId: string, deltaX: number, deltaY: number, skipIds: ReadonlySet<string> = new Set()): void {
|
|
289
357
|
if (deltaX === 0 && deltaY === 0) return;
|
|
290
358
|
const snapshot = this.getGroupSnapshot(groupId);
|
|
291
359
|
if (!snapshot) return;
|
|
292
360
|
|
|
293
361
|
for (const child of snapshot.children) {
|
|
362
|
+
if (skipIds.has(child.id)) continue;
|
|
294
363
|
this.nodes.set(child.id, {
|
|
295
364
|
...child,
|
|
296
365
|
position: {
|
|
@@ -390,6 +459,116 @@ class CanvasStateManager {
|
|
|
390
459
|
this._stateFilePath = override || join(workspaceRoot, PMX_CANVAS_DIR, STATE_FILENAME);
|
|
391
460
|
}
|
|
392
461
|
|
|
462
|
+
private get blobsDir(): string | null {
|
|
463
|
+
if (!this._workspaceRoot) return null;
|
|
464
|
+
return join(this._workspaceRoot, PMX_CANVAS_DIR, BLOBS_SUBDIR);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private relativeBlobPath(filePath: string): string {
|
|
468
|
+
const base = join(this._workspaceRoot, PMX_CANVAS_DIR);
|
|
469
|
+
const rel = relative(base, filePath);
|
|
470
|
+
return rel || filePath;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private resolveBlobPath(ref: PersistedBlobRef): string | null {
|
|
474
|
+
if (isAbsolute(ref.path)) return null;
|
|
475
|
+
const base = join(this._workspaceRoot, PMX_CANVAS_DIR);
|
|
476
|
+
const resolved = join(base, ref.path);
|
|
477
|
+
const rel = relative(base, resolved);
|
|
478
|
+
if (rel === '' || rel.startsWith('..') || rel === '..' || isAbsolute(rel)) return null;
|
|
479
|
+
return resolved;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private writeBlobValue(value: unknown): PersistedBlobRef | null {
|
|
483
|
+
const dir = this.blobsDir;
|
|
484
|
+
if (!dir) return null;
|
|
485
|
+
const json = JSON.stringify(value);
|
|
486
|
+
if (typeof json !== 'string') return null;
|
|
487
|
+
const jsonBytes = Buffer.byteLength(json);
|
|
488
|
+
if (jsonBytes < BLOB_JSON_THRESHOLD_BYTES) return null;
|
|
489
|
+
const sha256 = createHash('sha256').update(json).digest('hex');
|
|
490
|
+
const prefix = sha256.slice(0, 2);
|
|
491
|
+
const filePath = join(dir, prefix, `${sha256}.json.gz`);
|
|
492
|
+
try {
|
|
493
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
494
|
+
if (!existsSync(dirname(filePath))) mkdirSync(dirname(filePath), { recursive: true });
|
|
495
|
+
const compressed = gzipSync(json);
|
|
496
|
+
if (!existsSync(filePath)) writeFileSync(filePath, compressed);
|
|
497
|
+
return {
|
|
498
|
+
__pmxCanvasBlob: 'v1',
|
|
499
|
+
path: this.relativeBlobPath(filePath),
|
|
500
|
+
sha256,
|
|
501
|
+
encoding: 'json+gzip',
|
|
502
|
+
bytes: compressed.byteLength,
|
|
503
|
+
jsonBytes,
|
|
504
|
+
};
|
|
505
|
+
} catch (error) {
|
|
506
|
+
logCanvasStateWarning('write blob failed', error, { filePath });
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private readBlobValue(ref: PersistedBlobRef): unknown {
|
|
512
|
+
const filePath = this.resolveBlobPath(ref);
|
|
513
|
+
if (!filePath) return ref;
|
|
514
|
+
try {
|
|
515
|
+
const compressed = readFileSync(filePath);
|
|
516
|
+
const json = gunzipSync(compressed).toString('utf-8');
|
|
517
|
+
const sha256 = createHash('sha256').update(json).digest('hex');
|
|
518
|
+
if (sha256 !== ref.sha256) {
|
|
519
|
+
logCanvasStateWarning('blob checksum mismatch', 'checksum mismatch', { filePath });
|
|
520
|
+
return ref;
|
|
521
|
+
}
|
|
522
|
+
return JSON.parse(json) as unknown;
|
|
523
|
+
} catch (error) {
|
|
524
|
+
logCanvasStateWarning('read blob failed', error, { filePath });
|
|
525
|
+
return ref;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private externalizeNodeDataBlobs(node: CanvasNodeState): CanvasNodeState {
|
|
530
|
+
if (node.type !== 'mcp-app') return node;
|
|
531
|
+
let changed = false;
|
|
532
|
+
const data = { ...node.data };
|
|
533
|
+
for (const [key, value] of Object.entries(data)) {
|
|
534
|
+
if (!BLOB_DATA_FIELDS.has(key) || isPersistedBlobRef(value)) continue;
|
|
535
|
+
const ref = this.writeBlobValue(value);
|
|
536
|
+
if (!ref) continue;
|
|
537
|
+
data[key] = ref;
|
|
538
|
+
changed = true;
|
|
539
|
+
}
|
|
540
|
+
return changed ? { ...node, data } : node;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private resolveNodeDataBlobs(node: CanvasNodeState): CanvasNodeState {
|
|
544
|
+
if (node.type !== 'mcp-app') return node;
|
|
545
|
+
let changed = false;
|
|
546
|
+
const data = { ...node.data };
|
|
547
|
+
for (const [key, value] of Object.entries(data)) {
|
|
548
|
+
if (!BLOB_DATA_FIELDS.has(key) || !isPersistedBlobRef(value)) continue;
|
|
549
|
+
data[key] = this.readBlobValue(value);
|
|
550
|
+
changed = true;
|
|
551
|
+
}
|
|
552
|
+
return changed ? { ...node, data } : node;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
isBlobReference(value: unknown): value is PersistedBlobRef {
|
|
556
|
+
return isPersistedBlobRef(value);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
resolveBlobReference(value: unknown): unknown {
|
|
560
|
+
return isPersistedBlobRef(value) ? this.readBlobValue(value) : value;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private externalizePersistedStateBlobs<T extends PersistedCanvasState>(state: T): T {
|
|
564
|
+
return {
|
|
565
|
+
...state,
|
|
566
|
+
nodes: Array.isArray(state.nodes)
|
|
567
|
+
? state.nodes.map((node) => this.externalizeNodeDataBlobs(node))
|
|
568
|
+
: [],
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
393
572
|
/**
|
|
394
573
|
* One-time migration: rename files from the pre-consolidation layout
|
|
395
574
|
* (`.pmx-canvas.json` + `.pmx-canvas-snapshots/`) into `.pmx-canvas/`.
|
|
@@ -479,13 +658,13 @@ class CanvasStateManager {
|
|
|
479
658
|
const dir = dirname(this._stateFilePath);
|
|
480
659
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
481
660
|
|
|
482
|
-
const payload
|
|
661
|
+
const payload = this.externalizePersistedStateBlobs({
|
|
483
662
|
version: 1,
|
|
484
663
|
viewport: this._viewport,
|
|
485
664
|
nodes: Array.from(this.nodes.values()),
|
|
486
665
|
edges: Array.from(this.edges.values()),
|
|
487
666
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
488
|
-
};
|
|
667
|
+
});
|
|
489
668
|
writeFileSync(this._stateFilePath, JSON.stringify(payload, null, 2), 'utf-8');
|
|
490
669
|
} catch (error) {
|
|
491
670
|
logCanvasStateWarning('save state to disk failed', error, {
|
|
@@ -552,15 +731,16 @@ class CanvasStateManager {
|
|
|
552
731
|
}
|
|
553
732
|
|
|
554
733
|
try {
|
|
555
|
-
const matches: Array<{ snapshot: CanvasSnapshot;
|
|
734
|
+
const matches: Array<{ snapshot: CanvasSnapshot; path: string }> = [];
|
|
556
735
|
const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
557
736
|
for (const file of files) {
|
|
558
737
|
try {
|
|
559
|
-
const
|
|
738
|
+
const snapshotPath = join(dir, file);
|
|
739
|
+
const raw = readFileSync(snapshotPath, 'utf-8');
|
|
560
740
|
const parsed = JSON.parse(raw) as PersistedCanvasState & { snapshot?: CanvasSnapshot };
|
|
561
741
|
if (!parsed.snapshot) continue;
|
|
562
742
|
if (parsed.snapshot.name === idOrName || parsed.snapshot.id === idOrName) {
|
|
563
|
-
matches.push({ snapshot: parsed.snapshot,
|
|
743
|
+
matches.push({ snapshot: parsed.snapshot, path: snapshotPath });
|
|
564
744
|
}
|
|
565
745
|
} catch (error) {
|
|
566
746
|
logCanvasStateWarning('skip unreadable snapshot while searching by name', error, {
|
|
@@ -570,13 +750,31 @@ class CanvasStateManager {
|
|
|
570
750
|
}
|
|
571
751
|
}
|
|
572
752
|
matches.sort((a, b) => b.snapshot.createdAt.localeCompare(a.snapshot.createdAt));
|
|
573
|
-
|
|
753
|
+
const match = matches[0];
|
|
754
|
+
if (!match) return null;
|
|
755
|
+
try {
|
|
756
|
+
const raw = readFileSync(match.path, 'utf-8');
|
|
757
|
+
const parsed = JSON.parse(raw) as PersistedCanvasState & { snapshot?: CanvasSnapshot };
|
|
758
|
+
if (parsed.snapshot) return { snapshot: parsed.snapshot, state: parsed };
|
|
759
|
+
} catch (error) {
|
|
760
|
+
logCanvasStateWarning('read matched snapshot by name failed', error, { idOrName, path: match.path });
|
|
761
|
+
}
|
|
762
|
+
return null;
|
|
574
763
|
} catch (error) {
|
|
575
764
|
logCanvasStateWarning('search snapshots by name failed', error, { idOrName, dir });
|
|
576
765
|
return null;
|
|
577
766
|
}
|
|
578
767
|
}
|
|
579
768
|
|
|
769
|
+
getSnapshotDataForPersistence(idOrName: string): { snapshot: CanvasSnapshot; state: PersistedCanvasState } | null {
|
|
770
|
+
const resolved = this.readResolvedSnapshot(idOrName);
|
|
771
|
+
if (!resolved) return null;
|
|
772
|
+
return {
|
|
773
|
+
snapshot: resolved.snapshot,
|
|
774
|
+
state: structuredClone(resolved.state),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
580
778
|
/** Save current canvas state as a named snapshot. */
|
|
581
779
|
saveSnapshot(name: string): CanvasSnapshot | null {
|
|
582
780
|
const dir = this.snapshotsDir;
|
|
@@ -594,14 +792,14 @@ class CanvasStateManager {
|
|
|
594
792
|
try {
|
|
595
793
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
596
794
|
|
|
597
|
-
const payload
|
|
795
|
+
const payload = this.externalizePersistedStateBlobs({
|
|
598
796
|
version: 1,
|
|
599
797
|
snapshot,
|
|
600
798
|
viewport: this._viewport,
|
|
601
799
|
nodes: Array.from(this.nodes.values()),
|
|
602
800
|
edges: Array.from(this.edges.values()),
|
|
603
801
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
604
|
-
};
|
|
802
|
+
});
|
|
605
803
|
writeFileSync(join(dir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
|
|
606
804
|
return snapshot;
|
|
607
805
|
} catch (error) {
|
|
@@ -610,8 +808,8 @@ class CanvasStateManager {
|
|
|
610
808
|
}
|
|
611
809
|
}
|
|
612
810
|
|
|
613
|
-
/** List
|
|
614
|
-
listSnapshots(): CanvasSnapshot[] {
|
|
811
|
+
/** List saved snapshots, newest first. */
|
|
812
|
+
listSnapshots(options: CanvasSnapshotListOptions = {}): CanvasSnapshot[] {
|
|
615
813
|
const dir = this.snapshotsDir;
|
|
616
814
|
if (!dir || !existsSync(dir)) return [];
|
|
617
815
|
|
|
@@ -627,25 +825,53 @@ class CanvasStateManager {
|
|
|
627
825
|
logCanvasStateWarning('skip corrupt snapshot file', error, { file });
|
|
628
826
|
}
|
|
629
827
|
}
|
|
630
|
-
|
|
828
|
+
const query = options.query?.trim().toLowerCase();
|
|
829
|
+
const filtered = query
|
|
830
|
+
? snapshots.filter((snapshot) =>
|
|
831
|
+
snapshot.id.toLowerCase().includes(query) || snapshot.name.toLowerCase().includes(query),
|
|
832
|
+
)
|
|
833
|
+
: snapshots;
|
|
834
|
+
const sorted = filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
835
|
+
const limit = options.all ? undefined : (normalizePositiveInteger(options.limit) ?? 20);
|
|
836
|
+
return limit === undefined ? sorted : sorted.slice(0, limit);
|
|
631
837
|
} catch (error) {
|
|
632
838
|
logCanvasStateWarning('list snapshots failed', error, { dir });
|
|
633
839
|
return [];
|
|
634
840
|
}
|
|
635
841
|
}
|
|
636
842
|
|
|
843
|
+
gcSnapshots(options: CanvasSnapshotGcOptions = {}): CanvasSnapshotGcResult {
|
|
844
|
+
const keep = normalizePositiveInteger(options.keep) ?? 20;
|
|
845
|
+
const dryRun = options.dryRun ?? false;
|
|
846
|
+
const snapshots = this.listSnapshots({ all: true });
|
|
847
|
+
const deleted = snapshots.slice(keep);
|
|
848
|
+
|
|
849
|
+
if (!dryRun) {
|
|
850
|
+
for (const snapshot of deleted) {
|
|
851
|
+
this.deleteSnapshot(snapshot.id);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return {
|
|
856
|
+
ok: true,
|
|
857
|
+
kept: Math.min(keep, snapshots.length),
|
|
858
|
+
deleted,
|
|
859
|
+
dryRun,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
637
863
|
/** Restore canvas state from a snapshot. */
|
|
638
864
|
restoreSnapshot(idOrName: string): boolean {
|
|
639
865
|
const resolved = this.readResolvedSnapshot(idOrName);
|
|
640
866
|
if (!resolved || resolved.state.version !== 1) return false;
|
|
641
867
|
|
|
642
|
-
const previousState: PersistedCanvasState = {
|
|
868
|
+
const previousState: PersistedCanvasState = this.externalizePersistedStateBlobs({
|
|
643
869
|
version: 1,
|
|
644
870
|
viewport: structuredClone(this._viewport),
|
|
645
871
|
nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
|
|
646
872
|
edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
|
|
647
873
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
648
|
-
};
|
|
874
|
+
});
|
|
649
875
|
const nextState: PersistedCanvasState = {
|
|
650
876
|
version: 1,
|
|
651
877
|
viewport: structuredClone(resolved.state.viewport),
|
|
@@ -690,10 +916,16 @@ class CanvasStateManager {
|
|
|
690
916
|
getSnapshotData(idOrName: string): { name: string; nodes: CanvasNodeState[]; edges: CanvasEdge[] } | null {
|
|
691
917
|
const resolved = this.readResolvedSnapshot(idOrName);
|
|
692
918
|
if (!resolved) return null;
|
|
919
|
+
const state = {
|
|
920
|
+
...resolved.state,
|
|
921
|
+
nodes: Array.isArray(resolved.state.nodes)
|
|
922
|
+
? resolved.state.nodes.map((node) => this.resolveNodeDataBlobs(node))
|
|
923
|
+
: [],
|
|
924
|
+
};
|
|
693
925
|
return {
|
|
694
926
|
name: resolved.snapshot.name,
|
|
695
|
-
nodes: Array.isArray(
|
|
696
|
-
edges: Array.isArray(
|
|
927
|
+
nodes: Array.isArray(state.nodes) ? state.nodes.map((node) => structuredClone(node)) : [],
|
|
928
|
+
edges: Array.isArray(state.edges) ? state.edges.map((edge) => structuredClone(edge)) : [],
|
|
697
929
|
};
|
|
698
930
|
}
|
|
699
931
|
|
|
@@ -821,7 +1053,12 @@ class CanvasStateManager {
|
|
|
821
1053
|
|
|
822
1054
|
getNode(id: string): CanvasNodeState | undefined {
|
|
823
1055
|
const node = this.nodes.get(id);
|
|
824
|
-
return node ? structuredClone(node) : undefined;
|
|
1056
|
+
return node ? structuredClone(this.resolveNodeDataBlobs(node)) : undefined;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
getNodeForPersistence(id: string): CanvasNodeState | undefined {
|
|
1060
|
+
const node = this.nodes.get(id);
|
|
1061
|
+
return node ? structuredClone(this.externalizeNodeDataBlobs(node)) : undefined;
|
|
825
1062
|
}
|
|
826
1063
|
|
|
827
1064
|
// ── Edge CRUD ──────────────────────────────────────────────
|
|
@@ -889,12 +1126,25 @@ class CanvasStateManager {
|
|
|
889
1126
|
};
|
|
890
1127
|
}
|
|
891
1128
|
|
|
1129
|
+
getLayoutForPersistence(): CanvasLayout {
|
|
1130
|
+
return {
|
|
1131
|
+
viewport: structuredClone(this._viewport),
|
|
1132
|
+
nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.externalizeNodeDataBlobs(node))),
|
|
1133
|
+
edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
|
|
892
1137
|
applyUpdates(updates: CanvasNodeUpdate[]): { applied: number; skipped: number } {
|
|
893
1138
|
let applied = 0;
|
|
894
1139
|
let skipped = 0;
|
|
895
1140
|
const touchedParentGroups = new Map<string, { compact: boolean }>();
|
|
896
1141
|
const oldSnapshots = new Map<string, CanvasNodeState>();
|
|
897
1142
|
const appliedUpdates: CanvasNodeUpdate[] = [];
|
|
1143
|
+
const explicitPositionUpdateIds = new Set(
|
|
1144
|
+
updates
|
|
1145
|
+
.filter((update) => update.position !== undefined)
|
|
1146
|
+
.map((update) => update.id),
|
|
1147
|
+
);
|
|
898
1148
|
|
|
899
1149
|
for (const update of updates) {
|
|
900
1150
|
const existing = this.nodes.get(update.id);
|
|
@@ -902,26 +1152,47 @@ class CanvasStateManager {
|
|
|
902
1152
|
skipped++;
|
|
903
1153
|
continue;
|
|
904
1154
|
}
|
|
1155
|
+
const nextPatch: Partial<CanvasNodeState> = {};
|
|
1156
|
+
if (
|
|
1157
|
+
update.position &&
|
|
1158
|
+
(update.position.x !== existing.position.x || update.position.y !== existing.position.y)
|
|
1159
|
+
) {
|
|
1160
|
+
nextPatch.position = update.position;
|
|
1161
|
+
}
|
|
1162
|
+
if (
|
|
1163
|
+
update.size &&
|
|
1164
|
+
(update.size.width !== existing.size.width || update.size.height !== existing.size.height)
|
|
1165
|
+
) {
|
|
1166
|
+
nextPatch.size = update.size;
|
|
1167
|
+
}
|
|
1168
|
+
if (update.collapsed !== undefined && update.collapsed !== existing.collapsed) {
|
|
1169
|
+
nextPatch.collapsed = update.collapsed;
|
|
1170
|
+
}
|
|
1171
|
+
if (update.dockPosition !== undefined && update.dockPosition !== existing.dockPosition) {
|
|
1172
|
+
nextPatch.dockPosition = update.dockPosition;
|
|
1173
|
+
}
|
|
1174
|
+
if (Object.keys(nextPatch).length === 0) {
|
|
1175
|
+
skipped++;
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
905
1178
|
oldSnapshots.set(update.id, structuredClone(existing));
|
|
906
|
-
appliedUpdates.push(structuredClone(
|
|
907
|
-
if (existing.type === 'group' &&
|
|
1179
|
+
appliedUpdates.push({ id: update.id, ...structuredClone(nextPatch) });
|
|
1180
|
+
if (existing.type === 'group' && nextPatch.position) {
|
|
908
1181
|
this.translateGroupChildren(
|
|
909
1182
|
update.id,
|
|
910
|
-
|
|
911
|
-
|
|
1183
|
+
nextPatch.position.x - existing.position.x,
|
|
1184
|
+
nextPatch.position.y - existing.position.y,
|
|
1185
|
+
explicitPositionUpdateIds,
|
|
912
1186
|
);
|
|
913
1187
|
}
|
|
914
|
-
this.nodes.set(update.id, {
|
|
1188
|
+
this.nodes.set(update.id, this.normalizeNode({
|
|
915
1189
|
...existing,
|
|
916
|
-
...
|
|
917
|
-
|
|
918
|
-
...(update.collapsed !== undefined && { collapsed: update.collapsed }),
|
|
919
|
-
...(update.dockPosition !== undefined && { dockPosition: update.dockPosition }),
|
|
920
|
-
});
|
|
1190
|
+
...nextPatch,
|
|
1191
|
+
}));
|
|
921
1192
|
const parentGroupId = existing.data.parentGroup as string | undefined;
|
|
922
1193
|
if (parentGroupId) {
|
|
923
1194
|
const entry = touchedParentGroups.get(parentGroupId) ?? { compact: false };
|
|
924
|
-
entry.compact = entry.compact ||
|
|
1195
|
+
entry.compact = entry.compact || nextPatch.size !== undefined;
|
|
925
1196
|
touchedParentGroups.set(parentGroupId, entry);
|
|
926
1197
|
}
|
|
927
1198
|
applied++;
|