@kiberon-labs/behave-graph-flow 2.0.0 → 3.0.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.
Files changed (251) hide show
  1. package/.storybook/manager.ts +6 -0
  2. package/.storybook/preview.ts +49 -1
  3. package/.storybook/styles.css +9 -3
  4. package/.turbo/turbo-build.log +1 -1
  5. package/CHANGELOG.md +368 -0
  6. package/dist/AnyControlImpl-Ds-CShIB.js +20 -0
  7. package/dist/AnyControlImpl-Ds-CShIB.js.map +1 -0
  8. package/dist/DocumentationBrowserPanelImpl-deZNzFX8.js +166 -0
  9. package/dist/DocumentationBrowserPanelImpl-deZNzFX8.js.map +1 -0
  10. package/dist/index.css +36 -33
  11. package/dist/index.css.map +1 -1
  12. package/dist/index.d.ts +1865 -550
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +14357 -11221
  15. package/dist/index.js.map +1 -1
  16. package/dist/noteImpl-KkrrWgJd.js +242 -0
  17. package/dist/noteImpl-KkrrWgJd.js.map +1 -0
  18. package/dist/styles.module-CvmpDkZj.css +3 -0
  19. package/dist/styles.module-CvmpDkZj.css.map +1 -0
  20. package/dist/styles.module-DZxg8aW9.js +271 -0
  21. package/dist/styles.module-DZxg8aW9.js.map +1 -0
  22. package/dist/useChangeNodeData-ChQGK7AI.js +23 -0
  23. package/dist/useChangeNodeData-ChQGK7AI.js.map +1 -0
  24. package/docs/protocol.md +43 -20
  25. package/package.json +5 -9
  26. package/src/components/FloatingToolbar/index.module.css +5 -13
  27. package/src/components/FloatingToolbar/index.tsx +9 -9
  28. package/src/components/Flow.tsx +34 -23
  29. package/src/components/contextMenus/DynamicContextMenu.tsx +85 -0
  30. package/src/components/contextMenus/NodePicker.module.css +13 -13
  31. package/src/components/contextMenus/edge.tsx +9 -95
  32. package/src/components/contextMenus/node.tsx +9 -149
  33. package/src/components/contextMenus/selection.tsx +5 -71
  34. package/src/components/controls/any/AnyControlImpl.tsx +14 -0
  35. package/src/components/controls/any/index.tsx +13 -2
  36. package/src/components/edges/index.tsx +75 -69
  37. package/src/components/layoutController/index.module.css +3 -0
  38. package/src/components/layoutController/index.tsx +24 -1
  39. package/src/components/layoutController/utils.ts +46 -3
  40. package/src/components/menubar/defaults.tsx +55 -19
  41. package/src/components/menubar/menuItem.module.css +18 -3
  42. package/src/components/menubar/menuItem.tsx +34 -1
  43. package/src/components/nodes/behave/NodeContainer.module.css +26 -25
  44. package/src/components/nodes/group/index.tsx +3 -3
  45. package/src/components/nodes/wrapper/styles.module.css +6 -32
  46. package/src/components/panels/alignment/index.module.css +0 -10
  47. package/src/components/panels/alignment/index.tsx +4 -4
  48. package/src/components/panels/base/styles.module.css +2 -2
  49. package/src/components/panels/common/PanelHeader.module.css +24 -0
  50. package/src/components/panels/common/PanelHeader.tsx +22 -0
  51. package/src/components/panels/common/SectionTitle.module.css +13 -0
  52. package/src/components/panels/common/SectionTitle.tsx +10 -0
  53. package/src/components/panels/events/EditEventPanel.tsx +14 -5
  54. package/src/components/panels/events/ManageEventsPanel.tsx +11 -8
  55. package/src/components/panels/events/styles.module.css +6 -64
  56. package/src/components/panels/graphProperties/index.tsx +125 -0
  57. package/src/components/panels/history/index.tsx +2 -2
  58. package/src/components/panels/history/styles.module.css +0 -9
  59. package/src/components/panels/keymaps/index.module.css +3 -13
  60. package/src/components/panels/keymaps/index.tsx +1 -2
  61. package/src/components/panels/layers/index.tsx +20 -15
  62. package/src/components/panels/layers/styles.module.css +9 -12
  63. package/src/components/panels/legend/index.tsx +1 -1
  64. package/src/components/panels/logs/index.module.css +25 -19
  65. package/src/components/panels/logs/index.tsx +7 -7
  66. package/src/components/panels/nodeInputs/InputsGroup.tsx +1 -0
  67. package/src/components/panels/nodeInputs/NodeSettings.tsx +2 -2
  68. package/src/components/panels/nodeInputs/NodeTitleEditor.tsx +1 -1
  69. package/src/components/panels/nodeInputs/OutputsGroup.tsx +2 -12
  70. package/src/components/panels/nodeInputs/index.module.css +99 -75
  71. package/src/components/panels/nodeInputs/index.tsx +21 -11
  72. package/src/components/panels/nodeInputs/useNodeHandlers.ts +2 -2
  73. package/src/components/panels/nodeInputs/useNodeInputsData.ts +23 -43
  74. package/src/components/panels/nodePicker/index.tsx +8 -8
  75. package/src/components/panels/panel/index.module.css +7 -7
  76. package/src/components/panels/search/index.module.css +0 -50
  77. package/src/components/panels/search/index.tsx +2 -2
  78. package/src/components/panels/systemSettings/ConversionsSettings.tsx +203 -0
  79. package/src/components/panels/systemSettings/index.tsx +221 -176
  80. package/src/components/panels/systemSettings/styles.module.css +135 -8
  81. package/src/components/panels/traces/GridLines.tsx +1 -1
  82. package/src/components/panels/traces/TimeGrid.tsx +3 -3
  83. package/src/components/panels/traces/TraceLane.tsx +1 -1
  84. package/src/components/panels/traces/index.module.css +1 -8
  85. package/src/components/panels/traces/index.tsx +8 -4
  86. package/src/components/panels/traces/useDerivedSpans.ts +241 -146
  87. package/src/components/panels/traces/utils.ts +8 -0
  88. package/src/components/panels/variables/CreateVariableScreen.tsx +3 -3
  89. package/src/components/panels/variables/ManageVariablesScreen.tsx +12 -9
  90. package/src/components/panels/variables/index.tsx +2 -2
  91. package/src/components/panels/variables/styles.module.css +4 -91
  92. package/src/components/primitives/icon.module.css +4 -4
  93. package/src/components/sockets/input/index.tsx +9 -2
  94. package/src/components/sockets/input/styles.module.css +2 -3
  95. package/src/components/sockets/output/index.tsx +10 -3
  96. package/src/components/sockets/output/styles.module.css +1 -6
  97. package/src/css/notes.css +135 -0
  98. package/src/css/prosemirror.css +3 -3
  99. package/src/css/rc-dock.css +143 -43
  100. package/src/css/rc-menu.css +56 -55
  101. package/src/css/themes/kiberon.css +127 -0
  102. package/src/css/vars.css +197 -13
  103. package/src/css/vscode-elements.css +124 -0
  104. package/src/generators/CallSubgraphGenerator.tsx +136 -0
  105. package/src/generators/CustomEventOnTriggeredGenerator.tsx +2 -2
  106. package/src/generators/GraphBoundaryGenerator.module.css +32 -0
  107. package/src/generators/GraphBoundaryGenerator.tsx +193 -0
  108. package/src/generators/SequenceGenerator.tsx +2 -2
  109. package/src/generators/SwitchOnIntegerGenerator.tsx +2 -2
  110. package/src/generators/SwitchOnStringGenerator.tsx +2 -2
  111. package/src/generators/callSubgraphSync.ts +126 -0
  112. package/src/generators/registerDefaultGenerators.ts +21 -0
  113. package/src/generators/registerDefaults.ts +26 -0
  114. package/src/hooks/useBehaveGraphFlow.ts +2 -2
  115. package/src/hooks/useFlowHandlers.ts +47 -9
  116. package/src/hooks/useWasdPan.ts +26 -4
  117. package/src/index.css +4 -16
  118. package/src/index.ts +17 -0
  119. package/src/manifest/contributionRegistry.ts +93 -0
  120. package/src/manifest/index.ts +4 -0
  121. package/src/manifest/loadManifest.ts +82 -0
  122. package/src/manifest/manifestPlugin.ts +29 -0
  123. package/src/manifest/passthroughValueType.ts +40 -0
  124. package/src/plugin/alignment/index.ts +22 -12
  125. package/src/plugin/autosave/controller.ts +366 -0
  126. package/src/plugin/autosave/index.tsx +114 -0
  127. package/src/plugin/autosave/panel/BackupPanel.tsx +141 -0
  128. package/src/plugin/autosave/panel/index.tsx +1 -0
  129. package/src/plugin/autosave/panel/styles.module.css +56 -0
  130. package/src/plugin/autosave/settings.ts +65 -0
  131. package/src/plugin/autosave/storage.ts +147 -0
  132. package/src/plugin/docs/index.tsx +2 -4
  133. package/src/plugin/docs/panel/DocumentationBrowserPanelImpl.tsx +200 -0
  134. package/src/plugin/docs/panel/index.tsx +15 -194
  135. package/src/plugin/docs/panel/styles.module.css +8 -8
  136. package/src/plugin/graphrunner/actions.ts +258 -185
  137. package/src/plugin/graphrunner/buttons.tsx +34 -26
  138. package/src/plugin/graphrunner/client.ts +4 -1
  139. package/src/plugin/graphrunner/index.tsx +29 -100
  140. package/src/plugin/graphrunner/panel.tsx +2 -2
  141. package/src/plugin/graphrunner/runController.ts +283 -0
  142. package/src/plugin/graphrunner/runner.ts +21 -192
  143. package/src/plugin/graphrunner/store.ts +14 -24
  144. package/src/plugin/graphrunner/styles.module.css +17 -57
  145. package/src/plugin/graphrunner/transport.ts +26 -0
  146. package/src/plugin/graphrunner/types.ts +21 -0
  147. package/src/plugin/graphrunner-local/execution-utils.ts +260 -80
  148. package/src/plugin/graphrunner-local/index.tsx +8 -2
  149. package/src/plugin/graphrunner-local/panel.tsx +131 -175
  150. package/src/plugin/graphrunner-local/styles.module.css +57 -76
  151. package/src/plugin/graphrunner-local/transport.ts +151 -184
  152. package/src/plugin/graphrunner-webworker/graph-executor.worker.ts +2 -0
  153. package/src/plugin/graphrunner-webworker/index.tsx +4 -10
  154. package/src/plugin/graphrunner-webworker/store.ts +9 -0
  155. package/src/plugin/kitchen-sink/index.ts +38 -0
  156. package/src/{layout/dagre.tsx → plugin/layout/dagre.ts} +17 -5
  157. package/src/{layout → plugin/layout}/elk.ts +22 -6
  158. package/src/plugin/layout/index.ts +80 -0
  159. package/src/plugin/notes/FormatToolbar.tsx +200 -0
  160. package/src/plugin/notes/index.tsx +191 -0
  161. package/src/plugin/notes/nodeActions.ts +100 -0
  162. package/src/plugin/notes/note.tsx +20 -0
  163. package/src/plugin/notes/noteImpl.tsx +89 -0
  164. package/src/plugin/realtime/realtimeRunner.ts +58 -4
  165. package/src/specifics/CustomEventOnTriggeredSpecific.tsx +2 -2
  166. package/src/specifics/CustomEventTriggerSpecific.tsx +2 -2
  167. package/src/specifics/VariableGetSpecific.tsx +2 -2
  168. package/src/specifics/VariableSetSpecific.tsx +2 -2
  169. package/src/store/actions.tsx +5 -5
  170. package/src/store/commands.ts +278 -0
  171. package/src/store/contextMenu.ts +192 -0
  172. package/src/store/conversions.ts +47 -0
  173. package/src/store/flow.tsx +23 -38
  174. package/src/store/graphMeta.ts +39 -0
  175. package/src/store/hotKeys.tsx +301 -260
  176. package/src/store/layers.ts +3 -3
  177. package/src/store/registry.ts +12 -4
  178. package/src/store/selection.ts +3 -3
  179. package/src/store/settings.ts +82 -82
  180. package/src/store/settingsSchema.ts +210 -0
  181. package/src/store/tabs.ts +5 -1
  182. package/src/store/traces.ts +3 -3
  183. package/src/system/graph.ts +11 -14
  184. package/src/system/graphSession.ts +172 -0
  185. package/src/system/index.ts +3 -0
  186. package/src/system/notifications.ts +13 -0
  187. package/src/system/persistence.ts +82 -0
  188. package/src/system/plugin.ts +28 -0
  189. package/src/system/provider.tsx +64 -0
  190. package/src/system/system.ts +518 -88
  191. package/src/system/tabLoader.tsx +70 -32
  192. package/src/system/undoRedo.ts +1 -1
  193. package/src/transformers/Uigraph.ts +5 -4
  194. package/src/transformers/contract.ts +87 -0
  195. package/src/transformers/flowToBehave.ts +13 -5
  196. package/src/types/nodes.ts +8 -3
  197. package/src/types.ts +2 -0
  198. package/src/util/autoConvert.ts +200 -0
  199. package/src/util/isValidConnection.ts +23 -2
  200. package/stories/defaults/defaultStoryProvider.tsx +17 -14
  201. package/stories/defaults/systemGenerator.ts +6 -1
  202. package/stories/{components/nodes/comment.stories.tsx → plugins/notes.stories.tsx} +24 -30
  203. package/tests/autoConvert.test.ts +329 -0
  204. package/tests/autosavePlugin.test.ts +204 -0
  205. package/tests/callSubgraphSync.test.ts +148 -0
  206. package/tests/commandRegistry.test.ts +137 -0
  207. package/tests/contract.test.ts +51 -0
  208. package/tests/contractSerialize.test.ts +62 -0
  209. package/tests/deriveSpans.test.ts +71 -0
  210. package/tests/flowToBehave.test.ts +2 -1
  211. package/tests/hotkeys.test.ts +79 -0
  212. package/tests/keepAliveLifecycle.test.ts +167 -0
  213. package/tests/loadManifest.test.ts +113 -0
  214. package/tests/noteMarkdown.test.ts +65 -0
  215. package/tests/notesPlugin.test.ts +162 -0
  216. package/tests/persistence.test.ts +51 -0
  217. package/tests/saveLoad.test.ts +7 -6
  218. package/tests/settings.test.ts +178 -0
  219. package/tests/traceStore.test.ts +46 -0
  220. package/tests/visual/README.md +2 -2
  221. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-conversation-chromium-win32.png +0 -0
  222. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-events-chromium-win32.png +0 -0
  223. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-history-chromium-win32.png +0 -0
  224. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-keymaps-chromium-win32.png +0 -0
  225. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-layers-chromium-win32.png +0 -0
  226. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-legend-chromium-win32.png +0 -0
  227. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-localGraphRunner-chromium-win32.png +0 -0
  228. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-logs-chromium-win32.png +0 -0
  229. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-nodeInputs-chromium-win32.png +0 -0
  230. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-nodePicker-chromium-win32.png +0 -0
  231. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-panel-chromium-win32.png +0 -0
  232. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-search-chromium-win32.png +0 -0
  233. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-systemSettings-chromium-win32.png +0 -0
  234. package/tests/visual/__screenshots__/panels.visual.test.tsx/panel-variables-chromium-win32.png +0 -0
  235. package/tests/visual/panels.visual.test.tsx +3 -3
  236. package/tests/wasdPan.test.ts +71 -0
  237. package/vitest.config.ts +1 -1
  238. package/vitest.visual.config.ts +7 -0
  239. package/.storybook/vscode.css +0 -814
  240. package/src/components/nodes/comment/FormatToolbar.tsx +0 -118
  241. package/src/components/nodes/comment/comment.tsx +0 -103
  242. package/src/components/nodes/comment/styles.module.css +0 -150
  243. package/src/components/panels/conversation/index.module.css +0 -151
  244. package/src/components/panels/conversation/index.tsx +0 -162
  245. package/src/components/panels/events/CustomEventsEditor.tsx +0 -384
  246. package/src/css/vscode.css +0 -13
  247. package/src/hooks/useDetachNodes.ts +0 -39
  248. package/src/plugin/graphrunner-webworker/types.ts +0 -17
  249. package/src/specifics/registerDefaultSpecifics.ts +0 -5
  250. package/src/store/chat.ts +0 -73
  251. package/src/store/graphRunnerClient.ts +0 -110
@@ -0,0 +1,366 @@
1
+ import { createStore, type StoreApi } from 'zustand/vanilla';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+ import type { System, SettingsStorage } from '@/system/system';
4
+ import type { GraphSession } from '@/system/graphSession';
5
+ import { buildUIGraphJSON } from '@/transformers/Uigraph';
6
+ import type { UIGraphJSON } from '@/types/graph';
7
+ import { BackupStorage, type BackupSnapshot } from './storage';
8
+ import {
9
+ AUTOSAVE_ENABLED,
10
+ AUTOSAVE_INTERVAL_SECONDS,
11
+ AUTOSAVE_MAX_COPIES,
12
+ AUTOSAVE_DEFAULTS,
13
+ MIN_INTERVAL_SECONDS
14
+ } from './settings';
15
+
16
+ /**
17
+ * Wait for a graph to be untouched for this long before snapshotting it. A drag
18
+ * or a multi-store edit produces a burst of store updates; requiring a quiet
19
+ * window means we capture the settled result rather than a half-applied frame.
20
+ */
21
+ const QUIESCE_MS = 800;
22
+
23
+ /** Reactive slice the backup panel subscribes to. */
24
+ export type BackupControllerStore = {
25
+ /** All snapshots across every graph, newest first. */
26
+ snapshots: BackupSnapshot[];
27
+ /** Whether the timer is currently running. */
28
+ running: boolean;
29
+ /** Epoch ms of the last successful capture, or null. */
30
+ lastBackupAt: number | null;
31
+ };
32
+
33
+ /** Per-open-session bookkeeping used to decide when a snapshot is worthwhile. */
34
+ type SessionWatch = {
35
+ /** Changed since the last successful capture. */
36
+ dirty: boolean;
37
+ /** Epoch ms of the most recent store change (for the quiescence window). */
38
+ lastChange: number;
39
+ /** Serialized form of the last captured graph, to skip exact duplicates. */
40
+ lastCapturedJson: string;
41
+ /** Removes the node/edge store subscriptions. */
42
+ unsubscribe: () => void;
43
+ };
44
+
45
+ /**
46
+ * Every edge must point at nodes that exist. A snapshot taken mid-way through a
47
+ * load or a bulk mutation can have edges referencing not-yet-added (or
48
+ * already-removed) nodes; refusing to persist such a document is the concrete
49
+ * meaning of "do not take a copy during an inconsistent state".
50
+ */
51
+ const isConsistent = (graph: UIGraphJSON): boolean => {
52
+ if (!Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) return false;
53
+ const ids = new Set<string>();
54
+ for (const node of graph.nodes) {
55
+ if (!node || typeof node.id !== 'string') return false;
56
+ ids.add(node.id);
57
+ }
58
+ for (const edge of graph.edges) {
59
+ if (!edge || !edge.source || !edge.target) return false;
60
+ if (!ids.has(edge.source) || !ids.has(edge.target)) return false;
61
+ }
62
+ return true;
63
+ };
64
+
65
+ /**
66
+ * Drives periodic, consistency-checked backups of every open graph into local
67
+ * storage, and restores from them. Owned by the autosave plugin and exposed on
68
+ * the editor as `system.backups` so panels and other plugins can drive it.
69
+ *
70
+ * Snapshots are only written when a graph has actually changed, has settled
71
+ * (see {@link QUIESCE_MS}), is internally consistent, and is not empty , and
72
+ * never while a caller has the controller suspended. Timer cadence, copy count
73
+ * and the on/off switch are read live from the editor settings.
74
+ */
75
+ export class BackupController {
76
+ public readonly store: StoreApi<BackupControllerStore>;
77
+ private readonly system: System;
78
+ private readonly backups: BackupStorage;
79
+
80
+ private timer: ReturnType<typeof setInterval> | undefined;
81
+ /** Nesting counter: > 0 means "unsafe to snapshot right now". */
82
+ private suspendDepth = 0;
83
+ private readonly watches = new Map<string, SessionWatch>();
84
+
85
+ private readonly disposers: Array<() => void> = [];
86
+
87
+ constructor(system: System, storage?: SettingsStorage) {
88
+ this.system = system;
89
+ this.backups = new BackupStorage(storage);
90
+ this.store = createStore<BackupControllerStore>(() => ({
91
+ snapshots: this.backups.listAll(),
92
+ running: false,
93
+ lastBackupAt: null
94
+ }));
95
+
96
+ // Track sessions as tabs open and close so each open graph is watched.
97
+ this.syncSessions();
98
+ this.disposers.push(
99
+ this.system.activeGraph.subscribe(() => this.syncSessions())
100
+ );
101
+
102
+ // React to enable/interval changes without a restart.
103
+ this.disposers.push(
104
+ this.system.systemSettings.subscribe(() => this.applySettings())
105
+ );
106
+
107
+ this.applySettings();
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Suspension , the "no copy during inconsistent state" API.
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /** Mark the start of a region where a snapshot would be unsafe. */
115
+ suspend(): void {
116
+ this.suspendDepth += 1;
117
+ }
118
+
119
+ /** End a region opened by {@link suspend}. */
120
+ resume(): void {
121
+ if (this.suspendDepth > 0) this.suspendDepth -= 1;
122
+ }
123
+
124
+ /** Whether snapshots are currently suspended. */
125
+ get suspended(): boolean {
126
+ return this.suspendDepth > 0;
127
+ }
128
+
129
+ /**
130
+ * Run `fn` with snapshots suspended, resuming even if it throws. Use this to
131
+ * bracket bulk mutations (loads, imports, programmatic rewrites) so an
132
+ * autosave tick can never capture the graph mid-transition.
133
+ */
134
+ runExclusive<T>(fn: () => T): T {
135
+ this.suspend();
136
+ try {
137
+ return fn();
138
+ } finally {
139
+ this.resume();
140
+ }
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Scheduling.
145
+ // ---------------------------------------------------------------------------
146
+
147
+ private intervalMs(): number {
148
+ const raw = Number(
149
+ this.system.getSetting(AUTOSAVE_INTERVAL_SECONDS) ??
150
+ AUTOSAVE_DEFAULTS.intervalSeconds
151
+ );
152
+ const seconds = Number.isFinite(raw)
153
+ ? Math.max(MIN_INTERVAL_SECONDS, raw)
154
+ : AUTOSAVE_DEFAULTS.intervalSeconds;
155
+ return seconds * 1000;
156
+ }
157
+
158
+ private maxCopies(): number {
159
+ const raw = Number(
160
+ this.system.getSetting(AUTOSAVE_MAX_COPIES) ?? AUTOSAVE_DEFAULTS.maxCopies
161
+ );
162
+ return Number.isFinite(raw) && raw >= 1
163
+ ? Math.floor(raw)
164
+ : AUTOSAVE_DEFAULTS.maxCopies;
165
+ }
166
+
167
+ /** (Re)start or stop the timer to match the current settings. */
168
+ private applySettings(): void {
169
+ const enabled =
170
+ this.system.getSetting<boolean>(AUTOSAVE_ENABLED) === true &&
171
+ this.backups.available;
172
+ if (enabled) this.start();
173
+ else this.stop();
174
+ }
175
+
176
+ private start(): void {
177
+ this.stop();
178
+ this.timer = setInterval(() => this.tick(), this.intervalMs());
179
+ this.store.setState({ running: true });
180
+ }
181
+
182
+ private stop(): void {
183
+ if (this.timer) {
184
+ clearInterval(this.timer);
185
+ this.timer = undefined;
186
+ }
187
+ this.store.setState({ running: false });
188
+ }
189
+
190
+ /** One scheduler pass: try to capture each open, dirty, settled graph. */
191
+ private tick(): void {
192
+ if (this.suspended) return;
193
+ for (const session of Object.values(
194
+ this.system.activeGraph.getState().sessions
195
+ )) {
196
+ this.captureSession(session);
197
+ }
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Capture.
202
+ // ---------------------------------------------------------------------------
203
+
204
+ /**
205
+ * Snapshot one graph now if it is worth it: changed, settled, non-empty,
206
+ * consistent and not a duplicate of the last capture. Returns the snapshot on
207
+ * success, or null when it was skipped.
208
+ */
209
+ captureSession(session: GraphSession, force = false): BackupSnapshot | null {
210
+ if (this.suspended) return null;
211
+ const watch = this.watches.get(session.id);
212
+ const now = Date.now();
213
+
214
+ if (!force) {
215
+ if (!watch?.dirty) return null;
216
+ if (now - watch.lastChange < QUIESCE_MS) return null;
217
+ }
218
+
219
+ let graph: UIGraphJSON;
220
+ try {
221
+ graph = buildUIGraphJSON(session);
222
+ } catch {
223
+ // A graph that can't even be serialized is by definition inconsistent.
224
+ return null;
225
+ }
226
+
227
+ // Skip empty graphs: an accidental "Clear" should not evict good backups.
228
+ if (graph.nodes.length === 0) {
229
+ if (watch) watch.dirty = false;
230
+ return null;
231
+ }
232
+
233
+ if (!isConsistent(graph)) return null;
234
+
235
+ const json = JSON.stringify(graph);
236
+ if (watch && json === watch.lastCapturedJson) {
237
+ watch.dirty = false;
238
+ return null;
239
+ }
240
+
241
+ const snapshot: BackupSnapshot = {
242
+ id: uuidv4(),
243
+ graphId: session.id,
244
+ name: session.name,
245
+ timestamp: now,
246
+ nodeCount: graph.nodes.length,
247
+ graph
248
+ };
249
+
250
+ this.backups.append(snapshot, this.maxCopies());
251
+ if (watch) {
252
+ watch.dirty = false;
253
+ watch.lastCapturedJson = json;
254
+ }
255
+ this.store.setState({
256
+ snapshots: this.backups.listAll(),
257
+ lastBackupAt: now
258
+ });
259
+ return snapshot;
260
+ }
261
+
262
+ /** Force an immediate backup of the focused graph (the "Back up now" action). */
263
+ backupNow(): BackupSnapshot | null {
264
+ const session = this.system.session;
265
+ if (!session) return null;
266
+ return this.captureSession(session, true);
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // Restore + management.
271
+ // ---------------------------------------------------------------------------
272
+
273
+ /**
274
+ * Restore a snapshot into a brand-new graph tab, leaving all currently open
275
+ * graphs untouched , the safe choice when recovering from a bad state.
276
+ * Returns the new session, or undefined if the snapshot is gone.
277
+ */
278
+ restore(snapshotId: string): GraphSession | undefined {
279
+ const snapshot = this.backups.find(snapshotId);
280
+ if (!snapshot) return undefined;
281
+
282
+ const session = this.system.newGraph(`${snapshot.name} (restored)`);
283
+ this.runExclusive(() => {
284
+ session.graph.deseralize(snapshot.graph);
285
+ session.flowStore
286
+ .getState()
287
+ .setGraph(snapshot.graph.flow, { skipLayout: true });
288
+ });
289
+ this.system.notifications.success(
290
+ `Restored "${snapshot.name}" into a new tab`
291
+ );
292
+ return session;
293
+ }
294
+
295
+ /** Delete one snapshot. */
296
+ deleteSnapshot(id: string): void {
297
+ this.backups.remove(id);
298
+ this.refresh();
299
+ }
300
+
301
+ /** Delete every snapshot. */
302
+ clearAll(): void {
303
+ this.backups.clear();
304
+ this.refresh();
305
+ }
306
+
307
+ /** Re-read storage into the reactive store (after an external change). */
308
+ refresh(): void {
309
+ this.store.setState({ snapshots: this.backups.listAll() });
310
+ }
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // Session watching.
314
+ // ---------------------------------------------------------------------------
315
+
316
+ /** Add watches for newly opened sessions, drop them for closed ones. */
317
+ private syncSessions(): void {
318
+ const sessions = this.system.activeGraph.getState().sessions;
319
+ // Add watches for new sessions.
320
+ for (const session of Object.values(sessions)) {
321
+ if (!this.watches.has(session.id)) this.watchSession(session);
322
+ }
323
+ // Remove watches for sessions that have gone away.
324
+ for (const [id, watch] of this.watches) {
325
+ if (!(id in sessions)) {
326
+ watch.unsubscribe();
327
+ this.watches.delete(id);
328
+ }
329
+ }
330
+ }
331
+
332
+ private watchSession(session: GraphSession): void {
333
+ const markDirty = () => {
334
+ const watch = this.watches.get(session.id);
335
+ if (!watch) return;
336
+ watch.dirty = true;
337
+ watch.lastChange = Date.now();
338
+ };
339
+ const unsubNodes = session.nodeStore.subscribe(markDirty);
340
+ const unsubEdges = session.edgeStore.subscribe(markDirty);
341
+ this.watches.set(session.id, {
342
+ dirty: false,
343
+ lastChange: 0,
344
+ lastCapturedJson: '',
345
+ unsubscribe: () => {
346
+ unsubNodes();
347
+ unsubEdges();
348
+ }
349
+ });
350
+ }
351
+
352
+ /** Tear down timers and subscriptions. */
353
+ dispose(): void {
354
+ this.stop();
355
+ for (const watch of this.watches.values()) watch.unsubscribe();
356
+ this.watches.clear();
357
+ for (const dispose of this.disposers) {
358
+ try {
359
+ dispose();
360
+ } catch {
361
+ // ignore disposer errors
362
+ }
363
+ }
364
+ this.disposers.length = 0;
365
+ }
366
+ }
@@ -0,0 +1,114 @@
1
+ import { ErrorBoundary } from 'react-error-boundary';
2
+ import { plugin } from '@/system/plugin';
3
+ import type { System, SettingsStorage } from '@/system/system';
4
+ import { MenuItemElement } from '@/components/menubar/menuItem';
5
+ import { BackupController } from './controller';
6
+ import { BackupPanel } from './panel';
7
+ import { AUTOSAVE_SETTINGS } from './settings';
8
+
9
+ export * from './settings';
10
+ export * from './storage';
11
+ export * from './controller';
12
+
13
+ declare module '@/system/system' {
14
+ interface System {
15
+ /** Local backup controller, present when the autosave plugin is loaded. */
16
+ backups?: BackupController;
17
+ }
18
+ }
19
+
20
+ /** Tab id for the backup browser panel. */
21
+ export const BACKUP_PANEL_TAB_ID = 'autosaveBackups';
22
+
23
+ /** Options for {@link autosavePlugin}. */
24
+ export interface AutosavePluginOptions {
25
+ /**
26
+ * Storage adapter for the backups. Defaults to `localStorage`. Provide your
27
+ * own (e.g. backed by VS Code workspace state) to persist elsewhere; pass a
28
+ * throwaway map in tests.
29
+ */
30
+ storage?: SettingsStorage;
31
+ /** Add the "Local Backups" item to the Window menu. Default: true. */
32
+ addMenuItem?: boolean;
33
+ }
34
+
35
+ /**
36
+ * Client-side automatic graph backups.
37
+ *
38
+ * Periodically snapshots every open graph into local storage so a crash or a
39
+ * bad edit during development is recoverable, and adds a panel to browse and
40
+ * restore those copies. Everything stays in the browser , no backend.
41
+ *
42
+ * The plugin:
43
+ * - contributes the `Autosave` settings (on/off, frequency, copies-to-keep);
44
+ * - installs a {@link BackupController} on the editor as `system.backups`,
45
+ * which runs the consistency-checked capture loop and honours the settings;
46
+ * - registers the `autosaveBackups` panel plus `autosave.openBackups` /
47
+ * `autosave.backupNow` commands and a Window-menu entry.
48
+ *
49
+ * Snapshots are only taken when a graph changed, settled, is consistent and is
50
+ * not empty; callers can bracket bulk mutations with
51
+ * `system.backups.runExclusive(...)` to guarantee no copy is taken mid-change.
52
+ */
53
+ export const autosavePlugin = plugin(
54
+ (system: System, options: AutosavePluginOptions = {}) => {
55
+ system.registerSettings(AUTOSAVE_SETTINGS);
56
+
57
+ const controller = new BackupController(system, options.storage);
58
+ system.decorate('backups', controller);
59
+
60
+ const commands = system.commandStore.getState();
61
+ commands.register({
62
+ id: 'autosave.openBackups',
63
+ title: 'Open Local Backups',
64
+ run: (ctx) => ctx.editor.tabStore.getState().openTab(BACKUP_PANEL_TAB_ID)
65
+ });
66
+ commands.register({
67
+ id: 'autosave.backupNow',
68
+ title: 'Back Up Graph Now',
69
+ run: (ctx) => {
70
+ ctx.editor.backups?.backupNow();
71
+ }
72
+ });
73
+
74
+ system.tabLoader.register(BACKUP_PANEL_TAB_ID, () => ({
75
+ id: BACKUP_PANEL_TAB_ID,
76
+ closable: true,
77
+ title: 'Local Backups',
78
+ group: 'default',
79
+ content: () => (
80
+ <ErrorBoundary fallback={'Error loading Local Backups panel'}>
81
+ <BackupPanel />
82
+ </ErrorBoundary>
83
+ )
84
+ }));
85
+
86
+ if (options.addMenuItem !== false) {
87
+ const menuStore = system.menubarStore;
88
+ const windowMenu = menuStore
89
+ .getState()
90
+ .items.find((menu) => menu.name === 'window');
91
+ if (windowMenu) {
92
+ menuStore.getState().setSubMenuItems('window', [
93
+ ...windowMenu.items,
94
+ {
95
+ name: 'autosaveBackups',
96
+ render: function BackupsMenuItem() {
97
+ return (
98
+ <MenuItemElement
99
+ key="autosaveBackups"
100
+ onClick={() =>
101
+ system.tabStore.getState().openTab(BACKUP_PANEL_TAB_ID)
102
+ }
103
+ >
104
+ Local Backups
105
+ </MenuItemElement>
106
+ );
107
+ }
108
+ }
109
+ ]);
110
+ }
111
+ }
112
+ },
113
+ { name: 'autosave' }
114
+ );
@@ -0,0 +1,141 @@
1
+ import { useStore } from 'zustand';
2
+ import {
3
+ VscodeButton,
4
+ VscodeLabel,
5
+ VscodeTable,
6
+ VscodeTableBody,
7
+ VscodeTableCell,
8
+ VscodeTableHeader,
9
+ VscodeTableHeaderCell,
10
+ VscodeTableRow
11
+ } from '@vscode-elements/react-elements';
12
+ import { useSystem } from '@/system/provider';
13
+ import { BasePanel } from '@/components/panels/base';
14
+ import type { BackupController } from '../controller';
15
+ import type { BackupSnapshot } from '../storage';
16
+ import styles from './styles.module.css';
17
+
18
+ const formatTime = (ms: number): string => {
19
+ try {
20
+ return new Date(ms).toLocaleString();
21
+ } catch {
22
+ return String(ms);
23
+ }
24
+ };
25
+
26
+ /**
27
+ * Browse and restore local graph backups. Restoring opens the snapshot in a new
28
+ * tab so the current work is never overwritten. Rendered from the autosave
29
+ * plugin's registered `autosaveBackups` tab.
30
+ */
31
+ export const BackupPanel = () => {
32
+ const system = useSystem();
33
+ const controller = system.backups as BackupController | undefined;
34
+
35
+ if (!controller) {
36
+ return (
37
+ <BasePanel>
38
+ <div className={styles.empty}>Backups are not available.</div>
39
+ </BasePanel>
40
+ );
41
+ }
42
+
43
+ return <BackupPanelBody controller={controller} />;
44
+ };
45
+
46
+ const BackupPanelBody = ({ controller }: { controller: BackupController }) => {
47
+ const snapshots = useStore(controller.store, (s) => s.snapshots);
48
+ const running = useStore(controller.store, (s) => s.running);
49
+ const lastBackupAt = useStore(controller.store, (s) => s.lastBackupAt);
50
+
51
+ const onRestore = (snapshot: BackupSnapshot) => {
52
+ controller.restore(snapshot.id);
53
+ };
54
+
55
+ const onDelete = (snapshot: BackupSnapshot) => {
56
+ controller.deleteSnapshot(snapshot.id);
57
+ };
58
+
59
+ const onClearAll = () => {
60
+ if (
61
+ typeof confirm === 'function' &&
62
+ !confirm('Delete all local backups? This cannot be undone.')
63
+ ) {
64
+ return;
65
+ }
66
+ controller.clearAll();
67
+ };
68
+
69
+ return (
70
+ <BasePanel>
71
+ <div className={styles.content}>
72
+ <div className={styles.header}>
73
+ <VscodeLabel>Local Backups</VscodeLabel>
74
+ <span className={styles.helpText}>
75
+ {running
76
+ ? 'Autosave is on. Snapshots are kept in this browser only.'
77
+ : 'Autosave is off. Enable it in Settings, or back up on demand.'}
78
+ {lastBackupAt ? ` Last backup: ${formatTime(lastBackupAt)}.` : ''}
79
+ </span>
80
+ </div>
81
+
82
+ <div className={styles.toolbar}>
83
+ <VscodeButton onClick={() => controller.backupNow()}>
84
+ Back up now
85
+ </VscodeButton>
86
+ <VscodeButton
87
+ secondary
88
+ disabled={snapshots.length === 0}
89
+ onClick={onClearAll}
90
+ >
91
+ Clear all
92
+ </VscodeButton>
93
+ </div>
94
+
95
+ {snapshots.length === 0 ? (
96
+ <div className={styles.empty}>
97
+ No backups yet. A copy is saved automatically when a graph changes
98
+ (with autosave on), or press &ldquo;Back up now&rdquo;.
99
+ </div>
100
+ ) : (
101
+ <VscodeTable className={styles.table} zebra>
102
+ <VscodeTableHeader slot="header">
103
+ <VscodeTableHeaderCell>Graph</VscodeTableHeaderCell>
104
+ <VscodeTableHeaderCell>When</VscodeTableHeaderCell>
105
+ <VscodeTableHeaderCell>Nodes</VscodeTableHeaderCell>
106
+ <VscodeTableHeaderCell>Actions</VscodeTableHeaderCell>
107
+ </VscodeTableHeader>
108
+ <VscodeTableBody slot="body">
109
+ {snapshots.map((snapshot) => (
110
+ <VscodeTableRow key={snapshot.id}>
111
+ <VscodeTableCell className={styles.nameCell}>
112
+ <span className={styles.name} title={snapshot.name}>
113
+ {snapshot.name || 'Untitled'}
114
+ </span>
115
+ </VscodeTableCell>
116
+ <VscodeTableCell>
117
+ {formatTime(snapshot.timestamp)}
118
+ </VscodeTableCell>
119
+ <VscodeTableCell>{snapshot.nodeCount}</VscodeTableCell>
120
+ <VscodeTableCell className={styles.actionsCell}>
121
+ <div className={styles.buttonGroup}>
122
+ <VscodeButton onClick={() => onRestore(snapshot)}>
123
+ Restore
124
+ </VscodeButton>
125
+ <VscodeButton
126
+ secondary
127
+ onClick={() => onDelete(snapshot)}
128
+ >
129
+ Delete
130
+ </VscodeButton>
131
+ </div>
132
+ </VscodeTableCell>
133
+ </VscodeTableRow>
134
+ ))}
135
+ </VscodeTableBody>
136
+ </VscodeTable>
137
+ )}
138
+ </div>
139
+ </BasePanel>
140
+ );
141
+ };
@@ -0,0 +1 @@
1
+ export { BackupPanel } from './BackupPanel';
@@ -0,0 +1,56 @@
1
+ .content {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 1rem;
5
+ }
6
+
7
+ .header {
8
+ display: flex;
9
+ flex-direction: column;
10
+ gap: 0.25rem;
11
+ padding-bottom: 0.25rem;
12
+ }
13
+
14
+ .helpText {
15
+ font-size: 0.75rem;
16
+ opacity: 0.7;
17
+ }
18
+
19
+ .toolbar {
20
+ display: flex;
21
+ gap: 0.5rem;
22
+ }
23
+
24
+ .table {
25
+ width: 100%;
26
+ }
27
+
28
+ .nameCell {
29
+ width: 40%;
30
+ }
31
+
32
+ .name {
33
+ font-weight: 500;
34
+ overflow: hidden;
35
+ text-overflow: ellipsis;
36
+ white-space: nowrap;
37
+ display: inline-block;
38
+ max-width: 100%;
39
+ }
40
+
41
+ .actionsCell {
42
+ text-align: right;
43
+ }
44
+
45
+ .buttonGroup {
46
+ display: flex;
47
+ gap: 0.25rem;
48
+ justify-content: flex-end;
49
+ flex-wrap: wrap;
50
+ }
51
+
52
+ .empty {
53
+ font-size: 0.8125rem;
54
+ opacity: 0.7;
55
+ padding: 1rem 0;
56
+ }