pmx-canvas 0.1.22 → 0.1.24
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/.github/extensions/pmx-canvas/extension.mjs +591 -0
- package/CHANGELOG.md +140 -0
- package/Readme.md +40 -8
- package/dist/canvas/global.css +36 -3
- package/dist/canvas/index.js +54 -54
- package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -0
- package/dist/types/client/nodes/iframe-document-url.d.ts +8 -0
- package/dist/types/client/state/intent-bridge.d.ts +4 -0
- package/dist/types/client/types.d.ts +1 -0
- package/dist/types/json-render/catalog.d.ts +1 -1
- package/dist/types/mcp/canvas-access.d.ts +9 -0
- package/dist/types/server/ax-context.d.ts +3 -0
- package/dist/types/server/ax-state.d.ts +43 -0
- package/dist/types/server/canvas-db.d.ts +38 -0
- package/dist/types/server/canvas-state.d.ts +36 -16
- package/dist/types/server/index.d.ts +6 -0
- package/dist/types/server/mutation-history.d.ts +1 -1
- package/docs/cli.md +13 -0
- package/docs/http-api.md +24 -0
- package/docs/mcp.md +20 -2
- package/docs/plans/plan-004-pmx-ax-primitives.md +463 -0
- package/docs/screenshot.png +0 -0
- package/docs/sdk.md +5 -0
- package/package.json +3 -2
- package/skills/pmx-canvas/SKILL.md +22 -4
- package/skills/pmx-canvas/references/codex-app-adapter.md +107 -0
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +111 -0
- package/src/cli/agent.ts +34 -0
- package/src/cli/index.ts +2 -1
- package/src/client/App.tsx +2 -0
- package/src/client/canvas/CanvasNode.tsx +7 -0
- package/src/client/canvas/CommandPalette.tsx +2 -1
- package/src/client/canvas/use-node-drag.ts +29 -7
- package/src/client/canvas/use-node-resize.ts +27 -7
- package/src/client/nodes/ExtAppFrame.tsx +51 -10
- package/src/client/nodes/HtmlNode.tsx +5 -2
- package/src/client/nodes/iframe-document-url.ts +58 -0
- package/src/client/state/intent-bridge.ts +8 -0
- package/src/client/state/sse-bridge.ts +2 -2
- package/src/client/theme/global.css +36 -3
- package/src/client/types.ts +1 -0
- package/src/mcp/canvas-access.ts +38 -0
- package/src/mcp/server.ts +113 -4
- package/src/server/ax-context.ts +38 -0
- package/src/server/ax-state.ts +130 -0
- package/src/server/canvas-db.ts +745 -0
- package/src/server/canvas-operations.ts +80 -1
- package/src/server/canvas-schema.ts +3 -3
- package/src/server/canvas-state.ts +390 -50
- package/src/server/canvas-validation.ts +6 -0
- package/src/server/index.ts +18 -0
- package/src/server/mutation-history.ts +1 -0
- package/src/server/server.ts +197 -11
|
@@ -5,15 +5,34 @@
|
|
|
5
5
|
* - Agent tools (Phase 3) can read/mutate canvas state
|
|
6
6
|
* - Client syncs bidirectionally (SSE for server→client, POST for client→server)
|
|
7
7
|
*
|
|
8
|
-
* Persistence: canvas state auto-saves to `.pmx-canvas/
|
|
9
|
-
* workspace root on every mutation (debounced). Auto-loads on `loadFromDisk()`.
|
|
8
|
+
* Persistence: canvas state auto-saves to `.pmx-canvas/canvas.db` (SQLite WAL mode)
|
|
9
|
+
* in the workspace root on every mutation (debounced). Auto-loads on `loadFromDisk()`.
|
|
10
|
+
* Legacy `.pmx-canvas/state.json` is auto-migrated on first boot.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { createHash } from 'node:crypto';
|
|
13
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, renameSync, unlinkSync } from 'node:fs';
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, renameSync, rmSync, unlinkSync } from 'node:fs';
|
|
14
15
|
import { isAbsolute, join, dirname, relative } from 'node:path';
|
|
15
16
|
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
16
17
|
import { normalizeCanvasNodeData } from './canvas-provenance.js';
|
|
18
|
+
import {
|
|
19
|
+
openCanvasDb,
|
|
20
|
+
saveStateToDB,
|
|
21
|
+
loadStateFromDB,
|
|
22
|
+
saveSnapshotToDB,
|
|
23
|
+
loadSnapshotFromDB,
|
|
24
|
+
listSnapshotsFromDB,
|
|
25
|
+
deleteSnapshotFromDB,
|
|
26
|
+
writeBlobToDB,
|
|
27
|
+
readBlobFromDB,
|
|
28
|
+
hasBlobInDB,
|
|
29
|
+
isDbPopulated,
|
|
30
|
+
checkpointCanvasDb,
|
|
31
|
+
finalizeCanvasDbForClose,
|
|
32
|
+
type PersistedCanvasState,
|
|
33
|
+
type CanvasTheme,
|
|
34
|
+
} from './canvas-db.js';
|
|
35
|
+
import { normalizeCanvasTheme } from './canvas-db.js';
|
|
17
36
|
import {
|
|
18
37
|
type CanvasPlacementRect,
|
|
19
38
|
computeGroupBounds,
|
|
@@ -22,6 +41,13 @@ import {
|
|
|
22
41
|
GROUP_TITLEBAR_HEIGHT,
|
|
23
42
|
resolveGroupCollision,
|
|
24
43
|
} from './placement.js';
|
|
44
|
+
import {
|
|
45
|
+
createEmptyAxState,
|
|
46
|
+
normalizeAxState,
|
|
47
|
+
type PmxAxFocusState,
|
|
48
|
+
type PmxAxSource,
|
|
49
|
+
type PmxAxState,
|
|
50
|
+
} from './ax-state.js';
|
|
25
51
|
|
|
26
52
|
function logCanvasStateWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
|
|
27
53
|
console.warn(`[canvas-state] ${action}`, { error, ...(details ?? {}) });
|
|
@@ -40,6 +66,7 @@ function normalizeSnapshotTimestamp(value: string | undefined): string | undefin
|
|
|
40
66
|
|
|
41
67
|
export const PMX_CANVAS_DIR = '.pmx-canvas';
|
|
42
68
|
const STATE_FILENAME = 'state.json';
|
|
69
|
+
const DB_FILENAME = 'canvas.db';
|
|
43
70
|
const SNAPSHOTS_SUBDIR = 'snapshots';
|
|
44
71
|
const BLOBS_SUBDIR = 'blobs';
|
|
45
72
|
const LEGACY_STATE_FILENAME = '.pmx-canvas.json';
|
|
@@ -65,14 +92,8 @@ export interface PersistedBlobRef {
|
|
|
65
92
|
jsonBytes: number;
|
|
66
93
|
}
|
|
67
94
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
viewport: ViewportState;
|
|
71
|
-
nodes: CanvasNodeState[];
|
|
72
|
-
edges: CanvasEdge[];
|
|
73
|
-
annotations?: CanvasAnnotation[];
|
|
74
|
-
contextPins: string[];
|
|
75
|
-
}
|
|
95
|
+
// Re-export for backward compat — canonical definition is now in canvas-db.ts
|
|
96
|
+
export type { PersistedCanvasState } from './canvas-db.js';
|
|
76
97
|
|
|
77
98
|
interface LoadFromDiskOptions {
|
|
78
99
|
clearExisting?: boolean;
|
|
@@ -174,6 +195,7 @@ export interface CanvasAnnotation {
|
|
|
174
195
|
|
|
175
196
|
export interface CanvasLayout {
|
|
176
197
|
viewport: ViewportState;
|
|
198
|
+
theme: CanvasTheme;
|
|
177
199
|
nodes: CanvasNodeState[];
|
|
178
200
|
edges: CanvasEdge[];
|
|
179
201
|
annotations: CanvasAnnotation[];
|
|
@@ -187,10 +209,10 @@ export interface CanvasNodeUpdate {
|
|
|
187
209
|
dockPosition?: 'left' | 'right' | null;
|
|
188
210
|
}
|
|
189
211
|
|
|
190
|
-
export type CanvasChangeType = 'pins' | 'nodes';
|
|
212
|
+
export type CanvasChangeType = 'pins' | 'nodes' | 'ax';
|
|
191
213
|
|
|
192
214
|
export interface MutationRecordInfo {
|
|
193
|
-
operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'restoreSnapshot' | 'setPins' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
|
|
215
|
+
operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'restoreSnapshot' | 'setPins' | 'setAxFocus' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
|
|
194
216
|
description: string;
|
|
195
217
|
forward: () => void;
|
|
196
218
|
inverse: () => void;
|
|
@@ -248,7 +270,9 @@ class CanvasStateManager {
|
|
|
248
270
|
private edges = new Map<string, CanvasEdge>();
|
|
249
271
|
private annotations = new Map<string, CanvasAnnotation>();
|
|
250
272
|
private _viewport: ViewportState = { x: 0, y: 0, scale: 1 };
|
|
273
|
+
private _theme: CanvasTheme = 'dark';
|
|
251
274
|
private _contextPinnedNodeIds = new Set<string>();
|
|
275
|
+
private _axState: PmxAxState = createEmptyAxState();
|
|
252
276
|
private _workspaceRoot = process.cwd();
|
|
253
277
|
|
|
254
278
|
// ── Change listeners (for MCP resource notifications) ──────
|
|
@@ -298,6 +322,18 @@ class CanvasStateManager {
|
|
|
298
322
|
}
|
|
299
323
|
}
|
|
300
324
|
|
|
325
|
+
private currentNodeIdSet(): Set<string> {
|
|
326
|
+
return new Set(this.nodes.keys());
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private normalizeAxForCurrentNodes(state: unknown): PmxAxState {
|
|
330
|
+
return normalizeAxState(state, this.currentNodeIdSet());
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private applyAxState(state: PmxAxState): void {
|
|
334
|
+
this._axState = this.normalizeAxForCurrentNodes(state);
|
|
335
|
+
}
|
|
336
|
+
|
|
301
337
|
private applyResolvedGroupBounds(
|
|
302
338
|
group: CanvasNodeState,
|
|
303
339
|
groupId: string,
|
|
@@ -481,14 +517,38 @@ class CanvasStateManager {
|
|
|
481
517
|
|
|
482
518
|
// ── Persistence ────────────────────────────────────────────
|
|
483
519
|
private _stateFilePath: string | null = null;
|
|
520
|
+
private _db: import('bun:sqlite').Database | null = null;
|
|
484
521
|
private _saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
485
522
|
|
|
486
523
|
/** Set the workspace root to enable auto-persistence. */
|
|
487
524
|
setWorkspaceRoot(workspaceRoot: string): void {
|
|
525
|
+
this.close();
|
|
488
526
|
this._workspaceRoot = workspaceRoot;
|
|
489
527
|
this.migrateLegacyLayout(workspaceRoot);
|
|
490
|
-
|
|
491
|
-
|
|
528
|
+
|
|
529
|
+
// Determine DB path
|
|
530
|
+
const dbOverride = (process.env.PMX_CANVAS_DB_PATH ?? '').trim();
|
|
531
|
+
const stateFileOverride = (process.env.PMX_CANVAS_STATE_FILE ?? '').trim();
|
|
532
|
+
let dbPath: string;
|
|
533
|
+
if (dbOverride) {
|
|
534
|
+
dbPath = dbOverride;
|
|
535
|
+
} else if (stateFileOverride && stateFileOverride.endsWith('.db')) {
|
|
536
|
+
dbPath = stateFileOverride;
|
|
537
|
+
} else {
|
|
538
|
+
dbPath = join(workspaceRoot, PMX_CANVAS_DIR, DB_FILENAME);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Keep legacy _stateFilePath for JSON migration detection
|
|
542
|
+
this._stateFilePath = stateFileOverride && !stateFileOverride.endsWith('.db')
|
|
543
|
+
? stateFileOverride
|
|
544
|
+
: join(workspaceRoot, PMX_CANVAS_DIR, STATE_FILENAME);
|
|
545
|
+
|
|
546
|
+
try {
|
|
547
|
+
this._db = openCanvasDb(dbPath);
|
|
548
|
+
this.migrateJsonToSqlite();
|
|
549
|
+
} catch (error) {
|
|
550
|
+
logCanvasStateWarning('open canvas database failed', error, { dbPath });
|
|
551
|
+
}
|
|
492
552
|
}
|
|
493
553
|
|
|
494
554
|
private get blobsDir(): string | null {
|
|
@@ -512,13 +572,33 @@ class CanvasStateManager {
|
|
|
512
572
|
}
|
|
513
573
|
|
|
514
574
|
private writeBlobValue(value: unknown): PersistedBlobRef | null {
|
|
515
|
-
const dir = this.blobsDir;
|
|
516
|
-
if (!dir) return null;
|
|
517
575
|
const json = JSON.stringify(value);
|
|
518
576
|
if (typeof json !== 'string') return null;
|
|
519
577
|
const jsonBytes = Buffer.byteLength(json);
|
|
520
578
|
if (jsonBytes < BLOB_JSON_THRESHOLD_BYTES) return null;
|
|
521
579
|
const sha256 = createHash('sha256').update(json).digest('hex');
|
|
580
|
+
|
|
581
|
+
// Write to SQLite if DB is available
|
|
582
|
+
if (this._db) {
|
|
583
|
+
try {
|
|
584
|
+
const bytes = writeBlobToDB(this._db, sha256, json);
|
|
585
|
+
return {
|
|
586
|
+
__pmxCanvasBlob: 'v1',
|
|
587
|
+
path: `blobs/${sha256}`,
|
|
588
|
+
sha256,
|
|
589
|
+
encoding: 'json+gzip',
|
|
590
|
+
bytes,
|
|
591
|
+
jsonBytes,
|
|
592
|
+
};
|
|
593
|
+
} catch (error) {
|
|
594
|
+
logCanvasStateWarning('write blob to db failed', error, { sha256 });
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Fallback to filesystem (for when DB is not yet initialized)
|
|
600
|
+
const dir = this.blobsDir;
|
|
601
|
+
if (!dir) return null;
|
|
522
602
|
const prefix = sha256.slice(0, 2);
|
|
523
603
|
const filePath = join(dir, prefix, `${sha256}.json.gz`);
|
|
524
604
|
try {
|
|
@@ -541,6 +621,24 @@ class CanvasStateManager {
|
|
|
541
621
|
}
|
|
542
622
|
|
|
543
623
|
private readBlobValue(ref: PersistedBlobRef): unknown {
|
|
624
|
+
// Try SQLite first
|
|
625
|
+
if (this._db) {
|
|
626
|
+
try {
|
|
627
|
+
const json = readBlobFromDB(this._db, ref.sha256);
|
|
628
|
+
if (json) {
|
|
629
|
+
const sha256 = createHash('sha256').update(json).digest('hex');
|
|
630
|
+
if (sha256 !== ref.sha256) {
|
|
631
|
+
logCanvasStateWarning('blob checksum mismatch (db)', 'checksum mismatch', { sha256: ref.sha256 });
|
|
632
|
+
return ref;
|
|
633
|
+
}
|
|
634
|
+
return JSON.parse(json) as unknown;
|
|
635
|
+
}
|
|
636
|
+
} catch (error) {
|
|
637
|
+
logCanvasStateWarning('read blob from db failed', error, { sha256: ref.sha256 });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Fallback to filesystem (for legacy blobs not yet migrated)
|
|
544
642
|
const filePath = this.resolveBlobPath(ref);
|
|
545
643
|
if (!filePath) return ref;
|
|
546
644
|
try {
|
|
@@ -629,6 +727,84 @@ class CanvasStateManager {
|
|
|
629
727
|
}
|
|
630
728
|
}
|
|
631
729
|
|
|
730
|
+
/**
|
|
731
|
+
* One-time migration: import state.json + snapshot JSON files + blob files
|
|
732
|
+
* into the SQLite database. Renames originals to `.bak`.
|
|
733
|
+
*/
|
|
734
|
+
private migrateJsonToSqlite(): void {
|
|
735
|
+
if (!this._db || !this._stateFilePath) return;
|
|
736
|
+
const db = this._db;
|
|
737
|
+
|
|
738
|
+
if (isDbPopulated(this._db)) return; // DB already initialized
|
|
739
|
+
|
|
740
|
+
if (existsSync(this._stateFilePath)) {
|
|
741
|
+
try {
|
|
742
|
+
const raw = readFileSync(this._stateFilePath, 'utf-8');
|
|
743
|
+
const parsed = JSON.parse(raw) as PersistedCanvasState;
|
|
744
|
+
if (parsed && parsed.version === 1) {
|
|
745
|
+
saveStateToDB(db, parsed);
|
|
746
|
+
renameSync(this._stateFilePath, `${this._stateFilePath}.bak`);
|
|
747
|
+
}
|
|
748
|
+
} catch (error) {
|
|
749
|
+
logCanvasStateWarning('migrate state.json to sqlite failed', error, {
|
|
750
|
+
path: this._stateFilePath,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Migrate snapshot JSON files
|
|
756
|
+
const snapshotsDir = this.snapshotsDir;
|
|
757
|
+
if (snapshotsDir && existsSync(snapshotsDir)) {
|
|
758
|
+
try {
|
|
759
|
+
const files = readdirSync(snapshotsDir).filter((f) => f.endsWith('.json'));
|
|
760
|
+
for (const file of files) {
|
|
761
|
+
try {
|
|
762
|
+
const filePath = join(snapshotsDir, file);
|
|
763
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
764
|
+
const parsed = JSON.parse(raw) as PersistedCanvasState & { snapshot?: CanvasSnapshot };
|
|
765
|
+
if (parsed.snapshot && parsed.version === 1) {
|
|
766
|
+
saveSnapshotToDB(db, parsed.snapshot, parsed);
|
|
767
|
+
renameSync(filePath, `${filePath}.bak`);
|
|
768
|
+
}
|
|
769
|
+
} catch (error) {
|
|
770
|
+
logCanvasStateWarning('migrate snapshot file to sqlite failed', error, { file });
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
} catch (error) {
|
|
774
|
+
logCanvasStateWarning('migrate snapshots dir failed', error, { snapshotsDir });
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Migrate blob files
|
|
779
|
+
const blobsDir = this.blobsDir;
|
|
780
|
+
if (blobsDir && existsSync(blobsDir)) {
|
|
781
|
+
try {
|
|
782
|
+
const prefixes = readdirSync(blobsDir).filter((d) => d.length === 2);
|
|
783
|
+
for (const prefix of prefixes) {
|
|
784
|
+
const prefixDir = join(blobsDir, prefix);
|
|
785
|
+
const blobFiles = readdirSync(prefixDir).filter((f) => f.endsWith('.json.gz'));
|
|
786
|
+
for (const blobFile of blobFiles) {
|
|
787
|
+
try {
|
|
788
|
+
const blobPath = join(prefixDir, blobFile);
|
|
789
|
+
const sha256 = blobFile.replace('.json.gz', '');
|
|
790
|
+
if (!hasBlobInDB(db, sha256)) {
|
|
791
|
+
const compressed = readFileSync(blobPath);
|
|
792
|
+
const json = gunzipSync(compressed).toString('utf-8');
|
|
793
|
+
writeBlobToDB(db, sha256, json);
|
|
794
|
+
}
|
|
795
|
+
const backupPath = `${blobPath}.bak`;
|
|
796
|
+
if (!existsSync(backupPath)) renameSync(blobPath, backupPath);
|
|
797
|
+
} catch (error) {
|
|
798
|
+
logCanvasStateWarning('migrate blob file to sqlite failed', error, { blobFile });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
} catch (error) {
|
|
803
|
+
logCanvasStateWarning('migrate blobs dir failed', error, { blobsDir });
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
632
808
|
getWorkspaceRoot(): string {
|
|
633
809
|
return this._workspaceRoot;
|
|
634
810
|
}
|
|
@@ -636,39 +812,55 @@ class CanvasStateManager {
|
|
|
636
812
|
private emptyPersistedState(): PersistedCanvasState {
|
|
637
813
|
return {
|
|
638
814
|
version: 1,
|
|
815
|
+
theme: this._theme,
|
|
639
816
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
640
817
|
nodes: [],
|
|
641
818
|
edges: [],
|
|
642
819
|
annotations: [],
|
|
643
820
|
contextPins: [],
|
|
821
|
+
ax: createEmptyAxState(),
|
|
644
822
|
};
|
|
645
823
|
}
|
|
646
824
|
|
|
647
|
-
/** Load canvas state from
|
|
825
|
+
/** Load canvas state from SQLite (or legacy JSON fallback). Call once on server startup. */
|
|
648
826
|
loadFromDisk(options: LoadFromDiskOptions = {}): boolean {
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
827
|
+
// Try SQLite first (only if DB has been populated)
|
|
828
|
+
if (this._db && isDbPopulated(this._db)) {
|
|
829
|
+
try {
|
|
830
|
+
const state = loadStateFromDB(this._db);
|
|
831
|
+
if (state) {
|
|
832
|
+
this.applyPersistedState(state);
|
|
833
|
+
return true;
|
|
834
|
+
}
|
|
835
|
+
} catch (error) {
|
|
836
|
+
logCanvasStateWarning('load state from sqlite failed', error, {});
|
|
652
837
|
}
|
|
653
|
-
return false;
|
|
654
838
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
})
|
|
665
|
-
|
|
839
|
+
|
|
840
|
+
// Fallback to JSON (for edge cases where migration hasn't happened)
|
|
841
|
+
if (this._stateFilePath && existsSync(this._stateFilePath)) {
|
|
842
|
+
try {
|
|
843
|
+
const raw = readFileSync(this._stateFilePath, 'utf-8');
|
|
844
|
+
const parsed = JSON.parse(raw) as PersistedCanvasState;
|
|
845
|
+
if (!parsed || parsed.version !== 1) return false;
|
|
846
|
+
this.applyPersistedState(parsed);
|
|
847
|
+
return true;
|
|
848
|
+
} catch (error) {
|
|
849
|
+
logCanvasStateWarning('load state from json fallback failed', error, {
|
|
850
|
+
path: this._stateFilePath,
|
|
851
|
+
});
|
|
852
|
+
}
|
|
666
853
|
}
|
|
854
|
+
|
|
855
|
+
if (options.clearExisting) {
|
|
856
|
+
this.applyPersistedState(this.emptyPersistedState());
|
|
857
|
+
}
|
|
858
|
+
return false;
|
|
667
859
|
}
|
|
668
860
|
|
|
669
|
-
/** Debounced save — coalesces rapid mutations into a single
|
|
861
|
+
/** Debounced save — coalesces rapid mutations into a single write. */
|
|
670
862
|
private scheduleSave(): void {
|
|
671
|
-
if (!this.
|
|
863
|
+
if (!this._db) return;
|
|
672
864
|
if (this._saveTimer) clearTimeout(this._saveTimer);
|
|
673
865
|
this._saveTimer = setTimeout(() => {
|
|
674
866
|
this._saveTimer = null;
|
|
@@ -682,28 +874,54 @@ class CanvasStateManager {
|
|
|
682
874
|
this._saveTimer = null;
|
|
683
875
|
}
|
|
684
876
|
this.saveToDisk();
|
|
877
|
+
if (this._db) {
|
|
878
|
+
try {
|
|
879
|
+
checkpointCanvasDb(this._db);
|
|
880
|
+
} catch (error) {
|
|
881
|
+
logCanvasStateWarning('checkpoint database failed', error, {});
|
|
882
|
+
}
|
|
883
|
+
}
|
|
685
884
|
}
|
|
686
885
|
|
|
687
|
-
/** Write current state to
|
|
886
|
+
/** Write current state to SQLite immediately. */
|
|
688
887
|
private saveToDisk(): void {
|
|
689
|
-
if (!this.
|
|
888
|
+
if (!this._db) return;
|
|
690
889
|
try {
|
|
691
|
-
const dir = dirname(this._stateFilePath);
|
|
692
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
693
|
-
|
|
694
890
|
const payload = this.externalizePersistedStateBlobs({
|
|
695
891
|
version: 1,
|
|
892
|
+
theme: this._theme,
|
|
696
893
|
viewport: this._viewport,
|
|
697
894
|
nodes: Array.from(this.nodes.values()),
|
|
698
895
|
edges: Array.from(this.edges.values()),
|
|
699
896
|
annotations: Array.from(this.annotations.values()),
|
|
700
897
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
898
|
+
ax: this.getAxState(),
|
|
701
899
|
});
|
|
702
|
-
|
|
900
|
+
saveStateToDB(this._db, payload);
|
|
703
901
|
} catch (error) {
|
|
704
|
-
logCanvasStateWarning('save state to
|
|
705
|
-
|
|
706
|
-
|
|
902
|
+
logCanvasStateWarning('save state to sqlite failed', error, {});
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** Close the SQLite database cleanly. Call on server shutdown. */
|
|
907
|
+
close(): void {
|
|
908
|
+
if (this._saveTimer) {
|
|
909
|
+
clearTimeout(this._saveTimer);
|
|
910
|
+
this._saveTimer = null;
|
|
911
|
+
this.saveToDisk();
|
|
912
|
+
}
|
|
913
|
+
if (this._db) {
|
|
914
|
+
try {
|
|
915
|
+
finalizeCanvasDbForClose(this._db);
|
|
916
|
+
} catch (error) {
|
|
917
|
+
logCanvasStateWarning('finalize database failed', error, {});
|
|
918
|
+
}
|
|
919
|
+
try {
|
|
920
|
+
this._db.close();
|
|
921
|
+
} catch (error) {
|
|
922
|
+
logCanvasStateWarning('close database failed', error, {});
|
|
923
|
+
}
|
|
924
|
+
this._db = null;
|
|
707
925
|
}
|
|
708
926
|
}
|
|
709
927
|
|
|
@@ -719,12 +937,14 @@ class CanvasStateManager {
|
|
|
719
937
|
this.edges.clear();
|
|
720
938
|
this.annotations.clear();
|
|
721
939
|
this._contextPinnedNodeIds.clear();
|
|
940
|
+
this._axState = createEmptyAxState();
|
|
722
941
|
|
|
723
942
|
this._viewport = {
|
|
724
943
|
x: state.viewport?.x ?? 0,
|
|
725
944
|
y: state.viewport?.y ?? 0,
|
|
726
945
|
scale: state.viewport?.scale ?? 1,
|
|
727
946
|
};
|
|
947
|
+
this._theme = normalizeCanvasTheme(state.theme, this._theme);
|
|
728
948
|
|
|
729
949
|
if (Array.isArray(state.nodes)) {
|
|
730
950
|
for (const node of state.nodes) {
|
|
@@ -748,12 +968,20 @@ class CanvasStateManager {
|
|
|
748
968
|
if (this.nodes.has(pinId)) this._contextPinnedNodeIds.add(pinId);
|
|
749
969
|
}
|
|
750
970
|
}
|
|
971
|
+
this._axState = this.normalizeAxForCurrentNodes(state.ax);
|
|
751
972
|
}
|
|
752
973
|
|
|
753
974
|
private readResolvedSnapshot(idOrName: string): {
|
|
754
975
|
snapshot: CanvasSnapshot;
|
|
755
976
|
state: PersistedCanvasState;
|
|
756
977
|
} | null {
|
|
978
|
+
// Try SQLite first
|
|
979
|
+
if (this._db) {
|
|
980
|
+
const result = loadSnapshotFromDB(this._db, idOrName);
|
|
981
|
+
if (result) return result;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Fallback to filesystem (for legacy snapshots not yet migrated)
|
|
757
985
|
const dir = this.snapshotsDir;
|
|
758
986
|
if (!dir || !existsSync(dir)) return null;
|
|
759
987
|
|
|
@@ -817,8 +1045,7 @@ class CanvasStateManager {
|
|
|
817
1045
|
|
|
818
1046
|
/** Save current canvas state as a named snapshot. */
|
|
819
1047
|
saveSnapshot(name: string): CanvasSnapshot | null {
|
|
820
|
-
|
|
821
|
-
if (!dir) return null;
|
|
1048
|
+
if (!this._db) return null;
|
|
822
1049
|
|
|
823
1050
|
const id = `snap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
824
1051
|
const snapshot: CanvasSnapshot = {
|
|
@@ -830,18 +1057,17 @@ class CanvasStateManager {
|
|
|
830
1057
|
};
|
|
831
1058
|
|
|
832
1059
|
try {
|
|
833
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
834
|
-
|
|
835
1060
|
const payload = this.externalizePersistedStateBlobs({
|
|
836
1061
|
version: 1,
|
|
837
|
-
|
|
1062
|
+
theme: this._theme,
|
|
838
1063
|
viewport: this._viewport,
|
|
839
1064
|
nodes: Array.from(this.nodes.values()),
|
|
840
1065
|
edges: Array.from(this.edges.values()),
|
|
841
1066
|
annotations: Array.from(this.annotations.values()),
|
|
842
1067
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
1068
|
+
ax: this.getAxState(),
|
|
843
1069
|
});
|
|
844
|
-
|
|
1070
|
+
saveSnapshotToDB(this._db, snapshot, payload);
|
|
845
1071
|
snapshot.nodeCount = payload.nodes.length;
|
|
846
1072
|
snapshot.edgeCount = payload.edges.length;
|
|
847
1073
|
return snapshot;
|
|
@@ -853,6 +1079,15 @@ class CanvasStateManager {
|
|
|
853
1079
|
|
|
854
1080
|
/** List saved snapshots, newest first. */
|
|
855
1081
|
listSnapshots(options: CanvasSnapshotListOptions = {}): CanvasSnapshot[] {
|
|
1082
|
+
if (this._db) {
|
|
1083
|
+
try {
|
|
1084
|
+
return listSnapshotsFromDB(this._db, options);
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
logCanvasStateWarning('list snapshots from db failed', error, {});
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Fallback to filesystem
|
|
856
1091
|
const dir = this.snapshotsDir;
|
|
857
1092
|
if (!dir || !existsSync(dir)) return [];
|
|
858
1093
|
|
|
@@ -915,19 +1150,23 @@ class CanvasStateManager {
|
|
|
915
1150
|
|
|
916
1151
|
const previousState: PersistedCanvasState = this.externalizePersistedStateBlobs({
|
|
917
1152
|
version: 1,
|
|
1153
|
+
theme: this._theme,
|
|
918
1154
|
viewport: structuredClone(this._viewport),
|
|
919
1155
|
nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
|
|
920
1156
|
edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
|
|
921
1157
|
annotations: Array.from(this.annotations.values(), (annotation) => structuredClone(annotation)),
|
|
922
1158
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
1159
|
+
ax: this.getAxState(),
|
|
923
1160
|
});
|
|
924
1161
|
const nextState: PersistedCanvasState = {
|
|
925
1162
|
version: 1,
|
|
1163
|
+
theme: normalizeCanvasTheme(resolved.state.theme, this._theme),
|
|
926
1164
|
viewport: structuredClone(resolved.state.viewport),
|
|
927
1165
|
nodes: Array.isArray(resolved.state.nodes) ? resolved.state.nodes.map((node) => structuredClone(node)) : [],
|
|
928
1166
|
edges: Array.isArray(resolved.state.edges) ? resolved.state.edges.map((edge) => structuredClone(edge)) : [],
|
|
929
1167
|
annotations: Array.isArray(resolved.state.annotations) ? resolved.state.annotations.map((annotation) => structuredClone(annotation)) : [],
|
|
930
1168
|
contextPins: Array.isArray(resolved.state.contextPins) ? [...resolved.state.contextPins] : [],
|
|
1169
|
+
ax: resolved.state.ax ? structuredClone(resolved.state.ax) : createEmptyAxState(),
|
|
931
1170
|
};
|
|
932
1171
|
|
|
933
1172
|
try {
|
|
@@ -935,6 +1174,7 @@ class CanvasStateManager {
|
|
|
935
1174
|
this.scheduleSave();
|
|
936
1175
|
this.notifyChange('nodes');
|
|
937
1176
|
this.notifyChange('pins');
|
|
1177
|
+
this.notifyChange('ax');
|
|
938
1178
|
this.recordMutation({
|
|
939
1179
|
operationType: 'restoreSnapshot',
|
|
940
1180
|
description: `Restored snapshot "${resolved.snapshot.name}"`,
|
|
@@ -943,12 +1183,14 @@ class CanvasStateManager {
|
|
|
943
1183
|
this.scheduleSave();
|
|
944
1184
|
this.notifyChange('nodes');
|
|
945
1185
|
this.notifyChange('pins');
|
|
1186
|
+
this.notifyChange('ax');
|
|
946
1187
|
}),
|
|
947
1188
|
inverse: this.suppressed(() => {
|
|
948
1189
|
this.applyPersistedState(previousState);
|
|
949
1190
|
this.scheduleSave();
|
|
950
1191
|
this.notifyChange('nodes');
|
|
951
1192
|
this.notifyChange('pins');
|
|
1193
|
+
this.notifyChange('ax');
|
|
952
1194
|
}),
|
|
953
1195
|
});
|
|
954
1196
|
return true;
|
|
@@ -982,6 +1224,16 @@ class CanvasStateManager {
|
|
|
982
1224
|
|
|
983
1225
|
/** Delete a snapshot. */
|
|
984
1226
|
deleteSnapshot(id: string): boolean {
|
|
1227
|
+
// Try SQLite first
|
|
1228
|
+
if (this._db) {
|
|
1229
|
+
try {
|
|
1230
|
+
if (deleteSnapshotFromDB(this._db, id)) return true;
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
logCanvasStateWarning('delete snapshot from db failed', error, { id });
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// Fallback to filesystem
|
|
985
1237
|
const dir = this.snapshotsDir;
|
|
986
1238
|
if (!dir) return false;
|
|
987
1239
|
const filePath = join(dir, `${id}.json`);
|
|
@@ -995,6 +1247,23 @@ class CanvasStateManager {
|
|
|
995
1247
|
}
|
|
996
1248
|
}
|
|
997
1249
|
|
|
1250
|
+
/** Remove all snapshots from the DB. Used by test teardown. */
|
|
1251
|
+
clearAllSnapshots(): void {
|
|
1252
|
+
if (this._db) {
|
|
1253
|
+
this._db.run('DELETE FROM snapshots');
|
|
1254
|
+
this._db.run('DELETE FROM snapshot_nodes');
|
|
1255
|
+
this._db.run('DELETE FROM snapshot_edges');
|
|
1256
|
+
this._db.run('DELETE FROM snapshot_annotations');
|
|
1257
|
+
this._db.run('DELETE FROM snapshot_pins');
|
|
1258
|
+
this._db.run('DELETE FROM snapshot_meta');
|
|
1259
|
+
}
|
|
1260
|
+
// Also clear filesystem snapshots dir
|
|
1261
|
+
const dir = this.snapshotsDir;
|
|
1262
|
+
if (dir && existsSync(dir)) {
|
|
1263
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
998
1267
|
// ── Node CRUD ──────────────────────────────────────────────
|
|
999
1268
|
|
|
1000
1269
|
get viewport(): ViewportState {
|
|
@@ -1058,6 +1327,7 @@ class CanvasStateManager {
|
|
|
1058
1327
|
const existing = this.nodes.get(id);
|
|
1059
1328
|
const connectedEdges = existing ? this.getEdgesForNode(id).map((e) => structuredClone(e)) : [];
|
|
1060
1329
|
const cloned = existing ? structuredClone(existing) : null;
|
|
1330
|
+
const oldAxState = this.getAxState();
|
|
1061
1331
|
|
|
1062
1332
|
// Prune from parent group's children list
|
|
1063
1333
|
if (existing) {
|
|
@@ -1086,9 +1356,11 @@ class CanvasStateManager {
|
|
|
1086
1356
|
this.nodes.delete(id);
|
|
1087
1357
|
this.removeEdgesForNode(id);
|
|
1088
1358
|
this._contextPinnedNodeIds.delete(id);
|
|
1359
|
+
this.applyAxState(this._axState);
|
|
1089
1360
|
this.scheduleSave();
|
|
1090
1361
|
this.notifyChange('nodes');
|
|
1091
1362
|
this.notifyChange('pins');
|
|
1363
|
+
this.notifyChange('ax');
|
|
1092
1364
|
if (cloned) {
|
|
1093
1365
|
this.recordMutation({
|
|
1094
1366
|
operationType: 'removeNode',
|
|
@@ -1097,6 +1369,9 @@ class CanvasStateManager {
|
|
|
1097
1369
|
inverse: this.suppressed(() => {
|
|
1098
1370
|
this.addNode(structuredClone(cloned));
|
|
1099
1371
|
for (const edge of connectedEdges) this.addEdge(structuredClone(edge));
|
|
1372
|
+
this.applyAxState(oldAxState);
|
|
1373
|
+
this.scheduleSave();
|
|
1374
|
+
this.notifyChange('ax');
|
|
1100
1375
|
}),
|
|
1101
1376
|
});
|
|
1102
1377
|
}
|
|
@@ -1206,6 +1481,7 @@ class CanvasStateManager {
|
|
|
1206
1481
|
getLayout(): CanvasLayout {
|
|
1207
1482
|
return {
|
|
1208
1483
|
viewport: structuredClone(this._viewport),
|
|
1484
|
+
theme: this._theme,
|
|
1209
1485
|
nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
|
|
1210
1486
|
edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
|
|
1211
1487
|
annotations: this.getAnnotations(),
|
|
@@ -1215,6 +1491,7 @@ class CanvasStateManager {
|
|
|
1215
1491
|
getLayoutForPersistence(): CanvasLayout {
|
|
1216
1492
|
return {
|
|
1217
1493
|
viewport: structuredClone(this._viewport),
|
|
1494
|
+
theme: this._theme,
|
|
1218
1495
|
nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.externalizeNodeDataBlobs(node))),
|
|
1219
1496
|
edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
|
|
1220
1497
|
annotations: this.getAnnotations(),
|
|
@@ -1334,12 +1611,70 @@ class CanvasStateManager {
|
|
|
1334
1611
|
});
|
|
1335
1612
|
}
|
|
1336
1613
|
|
|
1614
|
+
get theme(): CanvasTheme {
|
|
1615
|
+
return this._theme;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
setTheme(theme: CanvasTheme): CanvasTheme {
|
|
1619
|
+
const next = normalizeCanvasTheme(theme, this._theme);
|
|
1620
|
+
if (next === this._theme) return this._theme;
|
|
1621
|
+
this._theme = next;
|
|
1622
|
+
this.scheduleSave();
|
|
1623
|
+
this.notifyChange('nodes');
|
|
1624
|
+
return this._theme;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1337
1627
|
// ── Context pins ─────────────────────────────────────────────
|
|
1338
1628
|
|
|
1339
1629
|
get contextPinnedNodeIds(): Set<string> {
|
|
1340
1630
|
return new Set(this._contextPinnedNodeIds);
|
|
1341
1631
|
}
|
|
1342
1632
|
|
|
1633
|
+
getAxState(): PmxAxState {
|
|
1634
|
+
return structuredClone(this.normalizeAxForCurrentNodes(this._axState));
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
getAxFocus(): PmxAxFocusState {
|
|
1638
|
+
return this.getAxState().focus;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
setAxFocus(nodeIds: string[], options: { source?: PmxAxSource; recordHistory?: boolean } = {}): PmxAxFocusState {
|
|
1642
|
+
const oldAxState = this.getAxState();
|
|
1643
|
+
const nextAxState: PmxAxState = {
|
|
1644
|
+
...oldAxState,
|
|
1645
|
+
focus: {
|
|
1646
|
+
nodeIds,
|
|
1647
|
+
primaryNodeId: nodeIds[0] ?? null,
|
|
1648
|
+
updatedAt: new Date().toISOString(),
|
|
1649
|
+
source: options.source ?? 'api',
|
|
1650
|
+
},
|
|
1651
|
+
};
|
|
1652
|
+
this.applyAxState(nextAxState);
|
|
1653
|
+
const appliedAxState = this.getAxState();
|
|
1654
|
+
this.scheduleSave();
|
|
1655
|
+
this.notifyChange('ax');
|
|
1656
|
+
if (options.recordHistory === false) return appliedAxState.focus;
|
|
1657
|
+
this.recordMutation({
|
|
1658
|
+
operationType: 'setAxFocus',
|
|
1659
|
+
description: `Set AX focus (${appliedAxState.focus.nodeIds.length} nodes)`,
|
|
1660
|
+
forward: this.suppressed(() => {
|
|
1661
|
+
this.applyAxState(appliedAxState);
|
|
1662
|
+
this.scheduleSave();
|
|
1663
|
+
this.notifyChange('ax');
|
|
1664
|
+
}),
|
|
1665
|
+
inverse: this.suppressed(() => {
|
|
1666
|
+
this.applyAxState(oldAxState);
|
|
1667
|
+
this.scheduleSave();
|
|
1668
|
+
this.notifyChange('ax');
|
|
1669
|
+
}),
|
|
1670
|
+
});
|
|
1671
|
+
return appliedAxState.focus;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
clearAxFocus(): PmxAxFocusState {
|
|
1675
|
+
return this.setAxFocus([], { source: 'system' });
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1343
1678
|
setContextPins(nodeIds: string[]): void {
|
|
1344
1679
|
const oldPins = Array.from(this._contextPinnedNodeIds);
|
|
1345
1680
|
this._contextPinnedNodeIds.clear();
|
|
@@ -1465,15 +1800,18 @@ class CanvasStateManager {
|
|
|
1465
1800
|
const oldEdges = Array.from(this.edges.values()).map((e) => structuredClone(e));
|
|
1466
1801
|
const oldAnnotations = Array.from(this.annotations.values()).map((annotation) => structuredClone(annotation));
|
|
1467
1802
|
const oldPins = Array.from(this._contextPinnedNodeIds);
|
|
1803
|
+
const oldAxState = this.getAxState();
|
|
1468
1804
|
const oldViewport = { ...this._viewport };
|
|
1469
1805
|
this.nodes.clear();
|
|
1470
1806
|
this.edges.clear();
|
|
1471
1807
|
this.annotations.clear();
|
|
1472
1808
|
this._contextPinnedNodeIds.clear();
|
|
1809
|
+
this._axState = createEmptyAxState();
|
|
1473
1810
|
this._viewport = { x: 0, y: 0, scale: 1 };
|
|
1474
1811
|
this.scheduleSave();
|
|
1475
1812
|
this.notifyChange('nodes');
|
|
1476
1813
|
this.notifyChange('pins');
|
|
1814
|
+
this.notifyChange('ax');
|
|
1477
1815
|
this.recordMutation({
|
|
1478
1816
|
operationType: 'clear',
|
|
1479
1817
|
description: `Cleared canvas (was ${oldNodes.length} nodes, ${oldEdges.length} edges)`,
|
|
@@ -1483,7 +1821,9 @@ class CanvasStateManager {
|
|
|
1483
1821
|
for (const e of oldEdges) this.addEdge(structuredClone(e));
|
|
1484
1822
|
for (const annotation of oldAnnotations) this.addAnnotation(structuredClone(annotation));
|
|
1485
1823
|
this.setContextPins(oldPins);
|
|
1824
|
+
this.applyAxState(oldAxState);
|
|
1486
1825
|
this.setViewport(oldViewport);
|
|
1826
|
+
this.notifyChange('ax');
|
|
1487
1827
|
}),
|
|
1488
1828
|
});
|
|
1489
1829
|
}
|