@isothrum/editor 0.0.0-stage → 0.1.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 (293) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/PUBLISHING.md +93 -0
  4. package/README.md +106 -2
  5. package/desktop-editor/editor.css +11 -0
  6. package/desktop-editor/index.html +14 -0
  7. package/desktop-editor/main.tsx +26 -0
  8. package/dist/desktop/editor.css +1 -0
  9. package/dist/desktop/editor.js +181 -0
  10. package/dist/desktop/fonts/geist-cyrillic-ext-wght-normal-RO72NYA2.woff2 +0 -0
  11. package/dist/desktop/fonts/geist-cyrillic-wght-normal-CK2XY27K.woff2 +0 -0
  12. package/dist/desktop/fonts/geist-latin-ext-wght-normal-XEQ56JUF.woff2 +0 -0
  13. package/dist/desktop/fonts/geist-latin-wght-normal-T72VVCUW.woff2 +0 -0
  14. package/dist/desktop/fonts/geist-mono-cyrillic-ext-wght-normal-QXKVIKFD.woff2 +0 -0
  15. package/dist/desktop/fonts/geist-mono-cyrillic-wght-normal-GOIAQAGU.woff2 +0 -0
  16. package/dist/desktop/fonts/geist-mono-latin-ext-wght-normal-HU3ZAWN6.woff2 +0 -0
  17. package/dist/desktop/fonts/geist-mono-latin-wght-normal-CJNQKXUG.woff2 +0 -0
  18. package/dist/desktop/fonts/geist-mono-symbols2-wght-normal-WZJJVLQO.woff2 +0 -0
  19. package/dist/desktop/fonts/geist-mono-vietnamese-wght-normal-KW5NM6YV.woff2 +0 -0
  20. package/dist/desktop/fonts/geist-vietnamese-wght-normal-Y2R7JZKV.woff2 +0 -0
  21. package/dist/desktop/fonts/shantell-sans-cyrillic-ext-wght-normal-ILPQWRJJ.woff2 +0 -0
  22. package/dist/desktop/fonts/shantell-sans-cyrillic-wght-normal-RKC6NDPM.woff2 +0 -0
  23. package/dist/desktop/fonts/shantell-sans-latin-ext-wght-normal-SNKHG3LM.woff2 +0 -0
  24. package/dist/desktop/fonts/shantell-sans-latin-wght-normal-7CHHGC3N.woff2 +0 -0
  25. package/dist/desktop/fonts/shantell-sans-vietnamese-wght-normal-IPFAQSBA.woff2 +0 -0
  26. package/dist/desktop/index.html +14 -0
  27. package/dist/desktop/styles.css +2 -0
  28. package/dist/desktop/wasm/isothrum_wasm.d.ts +60 -0
  29. package/dist/desktop/wasm/isothrum_wasm.js +312 -0
  30. package/dist/desktop/wasm/isothrum_wasm_bg.wasm +0 -0
  31. package/dist/index.js +7059 -0
  32. package/dist/index.js.map +7 -0
  33. package/dist/model.js +745 -0
  34. package/dist/model.js.map +7 -0
  35. package/dist/types/components/tool/canvas/cable-edge.d.ts +4 -0
  36. package/dist/types/components/tool/canvas/contexts.d.ts +12 -0
  37. package/dist/types/components/tool/canvas/device-icon.d.ts +17 -0
  38. package/dist/types/components/tool/canvas/inline-text.d.ts +15 -0
  39. package/dist/types/components/tool/canvas/lan-node.d.ts +8 -0
  40. package/dist/types/components/tool/canvas/machine-addresses.d.ts +14 -0
  41. package/dist/types/components/tool/canvas/machine-node.d.ts +4 -0
  42. package/dist/types/components/tool/canvas/note-node.d.ts +7 -0
  43. package/dist/types/components/tool/canvas/port-led.d.ts +10 -0
  44. package/dist/types/components/tool/canvas/router-node.d.ts +8 -0
  45. package/dist/types/components/tool/canvas/sketch.d.ts +50 -0
  46. package/dist/types/components/tool/canvas/status-dot.d.ts +5 -0
  47. package/dist/types/components/tool/canvas/switch-node.d.ts +8 -0
  48. package/dist/types/components/tool/canvas/use-measured-ports.d.ts +7 -0
  49. package/dist/types/components/tool/context-menus.d.ts +5 -0
  50. package/dist/types/components/tool/device-block.d.ts +12 -0
  51. package/dist/types/components/tool/editor-menu.d.ts +28 -0
  52. package/dist/types/components/tool/export/copy-button.d.ts +9 -0
  53. package/dist/types/components/tool/export/export-panel.d.ts +17 -0
  54. package/dist/types/components/tool/export/target-list.d.ts +15 -0
  55. package/dist/types/components/tool/floating-window.d.ts +13 -0
  56. package/dist/types/components/tool/flow-canvas.d.ts +6 -0
  57. package/dist/types/components/tool/hooks/use-cabling.d.ts +23 -0
  58. package/dist/types/components/tool/hooks/use-captures.d.ts +9 -0
  59. package/dist/types/components/tool/hooks/use-clipboard.d.ts +12 -0
  60. package/dist/types/components/tool/hooks/use-context-menus.d.ts +25 -0
  61. package/dist/types/components/tool/hooks/use-design.d.ts +10 -0
  62. package/dist/types/components/tool/hooks/use-desktop.d.ts +50 -0
  63. package/dist/types/components/tool/hooks/use-devices.d.ts +17 -0
  64. package/dist/types/components/tool/hooks/use-downloads.d.ts +9 -0
  65. package/dist/types/components/tool/hooks/use-editor.d.ts +609 -0
  66. package/dist/types/components/tool/hooks/use-engine.d.ts +9 -0
  67. package/dist/types/components/tool/hooks/use-history.d.ts +9 -0
  68. package/dist/types/components/tool/hooks/use-lab-actions.d.ts +25 -0
  69. package/dist/types/components/tool/hooks/use-lab-run.d.ts +25 -0
  70. package/dist/types/components/tool/hooks/use-lab.d.ts +18 -0
  71. package/dist/types/components/tool/hooks/use-lan-carry.d.ts +8 -0
  72. package/dist/types/components/tool/hooks/use-machine-actions.d.ts +26 -0
  73. package/dist/types/components/tool/hooks/use-placement.d.ts +27 -0
  74. package/dist/types/components/tool/hooks/use-project.d.ts +25 -0
  75. package/dist/types/components/tool/hooks/use-projects.d.ts +34 -0
  76. package/dist/types/components/tool/hooks/use-shared-link.d.ts +23 -0
  77. package/dist/types/components/tool/hooks/use-toast.d.ts +2 -0
  78. package/dist/types/components/tool/hooks/use-tools.d.ts +17 -0
  79. package/dist/types/components/tool/lab-canvas.d.ts +18 -0
  80. package/dist/types/components/tool/overlays.d.ts +5 -0
  81. package/dist/types/components/tool/projects-list.d.ts +9 -0
  82. package/dist/types/components/tool/text.d.ts +418 -0
  83. package/dist/types/components/tool/toolbar.d.ts +5 -0
  84. package/dist/types/components/tool/top-bars.d.ts +10 -0
  85. package/dist/types/components/tool/windows/conditions-fields.d.ts +6 -0
  86. package/dist/types/components/tool/windows/editor-windows.d.ts +5 -0
  87. package/dist/types/components/tool/windows/fields.d.ts +4 -0
  88. package/dist/types/components/tool/windows/lab-status.d.ts +6 -0
  89. package/dist/types/components/tool/windows/lab-windows.d.ts +13 -0
  90. package/dist/types/components/tool/windows/link-controls.d.ts +21 -0
  91. package/dist/types/components/tool/windows/machine-controls.d.ts +16 -0
  92. package/dist/types/components/tool/windows/machine-inspector.d.ts +11 -0
  93. package/dist/types/components/tool/windows/runs-fields.d.ts +18 -0
  94. package/dist/types/components/tool/windows/selected-windows.d.ts +21 -0
  95. package/dist/types/components/ui/button.d.ts +8 -0
  96. package/dist/types/components/ui/dropdown-menu.d.ts +21 -0
  97. package/dist/types/index.d.ts +7 -0
  98. package/dist/types/lib/auth.d.ts +38 -0
  99. package/dist/types/lib/brand.d.ts +19 -0
  100. package/dist/types/lib/i18n.d.ts +19 -0
  101. package/dist/types/lib/tool/blocks.d.ts +35 -0
  102. package/dist/types/lib/tool/cables.d.ts +48 -0
  103. package/dist/types/lib/tool/clone.d.ts +12 -0
  104. package/dist/types/lib/tool/conditions.d.ts +31 -0
  105. package/dist/types/lib/tool/create.d.ts +22 -0
  106. package/dist/types/lib/tool/desktop.d.ts +63 -0
  107. package/dist/types/lib/tool/draft.d.ts +20 -0
  108. package/dist/types/lib/tool/engine.d.ts +33 -0
  109. package/dist/types/lib/tool/examples.d.ts +11 -0
  110. package/dist/types/lib/tool/export-targets.d.ts +21 -0
  111. package/dist/types/lib/tool/facing.d.ts +9 -0
  112. package/dist/types/lib/tool/geometry.d.ts +36 -0
  113. package/dist/types/lib/tool/gns3-cables.d.ts +17 -0
  114. package/dist/types/lib/tool/gns3-import.d.ts +15 -0
  115. package/dist/types/lib/tool/gns3-parts.d.ts +25 -0
  116. package/dist/types/lib/tool/issues.d.ts +12 -0
  117. package/dist/types/lib/tool/layout.d.ts +52 -0
  118. package/dist/types/lib/tool/live.d.ts +21 -0
  119. package/dist/types/lib/tool/logos.d.ts +7 -0
  120. package/dist/types/lib/tool/model.d.ts +173 -0
  121. package/dist/types/lib/tool/os.d.ts +20 -0
  122. package/dist/types/lib/tool/pins.d.ts +5 -0
  123. package/dist/types/lib/tool/placement.d.ts +21 -0
  124. package/dist/types/lib/tool/ports.d.ts +21 -0
  125. package/dist/types/lib/tool/presets.d.ts +21 -0
  126. package/dist/types/lib/tool/projects-api.d.ts +37 -0
  127. package/dist/types/lib/tool/router-ports.d.ts +22 -0
  128. package/dist/types/lib/tool/selection.d.ts +19 -0
  129. package/dist/types/lib/tool/storage.d.ts +25 -0
  130. package/dist/types/lib/tool/switch-edit.d.ts +17 -0
  131. package/dist/types/lib/tool/text/de.d.ts +2 -0
  132. package/dist/types/lib/tool/text/en.d.ts +429 -0
  133. package/dist/types/lib/tool/text/es.d.ts +2 -0
  134. package/dist/types/lib/tool/text/fr.d.ts +2 -0
  135. package/dist/types/lib/tool/text/index.d.ts +419 -0
  136. package/dist/types/lib/tool/topology.d.ts +2 -0
  137. package/dist/types/lib/tool/vlans.d.ts +24 -0
  138. package/dist/types/lib/utils.d.ts +2 -0
  139. package/dist/types/model.d.ts +13 -0
  140. package/package.json +88 -4
  141. package/scripts/build-desktop-editor.mjs +43 -0
  142. package/scripts/build-lib.mjs +38 -0
  143. package/src/components/tool/canvas/cable-edge.tsx +112 -0
  144. package/src/components/tool/canvas/contexts.ts +18 -0
  145. package/src/components/tool/canvas/device-icon.tsx +105 -0
  146. package/src/components/tool/canvas/inline-text.tsx +69 -0
  147. package/src/components/tool/canvas/lan-node.tsx +148 -0
  148. package/src/components/tool/canvas/machine-addresses.tsx +117 -0
  149. package/src/components/tool/canvas/machine-node.tsx +114 -0
  150. package/src/components/tool/canvas/note-node.tsx +109 -0
  151. package/src/components/tool/canvas/port-led.tsx +21 -0
  152. package/src/components/tool/canvas/router-node.tsx +114 -0
  153. package/src/components/tool/canvas/sketch.tsx +130 -0
  154. package/src/components/tool/canvas/status-dot.tsx +29 -0
  155. package/src/components/tool/canvas/switch-node.tsx +143 -0
  156. package/src/components/tool/canvas/use-measured-ports.ts +25 -0
  157. package/src/components/tool/context-menus.tsx +142 -0
  158. package/src/components/tool/device-block.tsx +107 -0
  159. package/src/components/tool/editor-menu.tsx +191 -0
  160. package/src/components/tool/export/copy-button.tsx +45 -0
  161. package/src/components/tool/export/export-panel.tsx +127 -0
  162. package/src/components/tool/export/target-list.tsx +87 -0
  163. package/src/components/tool/floating-window.tsx +69 -0
  164. package/src/components/tool/flow-canvas.tsx +138 -0
  165. package/src/components/tool/hooks/use-cabling.ts +66 -0
  166. package/src/components/tool/hooks/use-captures.ts +24 -0
  167. package/src/components/tool/hooks/use-clipboard.ts +54 -0
  168. package/src/components/tool/hooks/use-context-menus.ts +37 -0
  169. package/src/components/tool/hooks/use-design.ts +24 -0
  170. package/src/components/tool/hooks/use-desktop.ts +100 -0
  171. package/src/components/tool/hooks/use-devices.ts +58 -0
  172. package/src/components/tool/hooks/use-downloads.ts +42 -0
  173. package/src/components/tool/hooks/use-editor.ts +114 -0
  174. package/src/components/tool/hooks/use-engine.ts +29 -0
  175. package/src/components/tool/hooks/use-history.ts +107 -0
  176. package/src/components/tool/hooks/use-lab-actions.ts +57 -0
  177. package/src/components/tool/hooks/use-lab-run.ts +61 -0
  178. package/src/components/tool/hooks/use-lab.ts +32 -0
  179. package/src/components/tool/hooks/use-lan-carry.ts +43 -0
  180. package/src/components/tool/hooks/use-machine-actions.ts +57 -0
  181. package/src/components/tool/hooks/use-placement.ts +80 -0
  182. package/src/components/tool/hooks/use-project.ts +112 -0
  183. package/src/components/tool/hooks/use-projects.ts +85 -0
  184. package/src/components/tool/hooks/use-shared-link.ts +64 -0
  185. package/src/components/tool/hooks/use-toast.ts +16 -0
  186. package/src/components/tool/hooks/use-tools.ts +60 -0
  187. package/src/components/tool/lab-canvas.tsx +96 -0
  188. package/src/components/tool/overlays.tsx +104 -0
  189. package/src/components/tool/projects-list.tsx +96 -0
  190. package/src/components/tool/text.ts +9 -0
  191. package/src/components/tool/toolbar.tsx +106 -0
  192. package/src/components/tool/top-bars.tsx +90 -0
  193. package/src/components/tool/windows/conditions-fields.tsx +43 -0
  194. package/src/components/tool/windows/editor-windows.tsx +73 -0
  195. package/src/components/tool/windows/fields.ts +6 -0
  196. package/src/components/tool/windows/lab-status.tsx +68 -0
  197. package/src/components/tool/windows/lab-windows.tsx +130 -0
  198. package/src/components/tool/windows/link-controls.tsx +69 -0
  199. package/src/components/tool/windows/machine-controls.tsx +116 -0
  200. package/src/components/tool/windows/machine-inspector.tsx +83 -0
  201. package/src/components/tool/windows/runs-fields.tsx +127 -0
  202. package/src/components/tool/windows/selected-windows.tsx +147 -0
  203. package/src/components/ui/button.tsx +59 -0
  204. package/src/components/ui/dropdown-menu.tsx +185 -0
  205. package/src/index.ts +21 -0
  206. package/src/lib/auth.tsx +43 -0
  207. package/src/lib/brand.tsx +46 -0
  208. package/src/lib/i18n.ts +53 -0
  209. package/src/lib/tool/blocks.test.ts +64 -0
  210. package/src/lib/tool/blocks.ts +78 -0
  211. package/src/lib/tool/cable-wiring.test.ts +74 -0
  212. package/src/lib/tool/cables.test.ts +75 -0
  213. package/src/lib/tool/cables.ts +215 -0
  214. package/src/lib/tool/clone.test.ts +54 -0
  215. package/src/lib/tool/clone.ts +48 -0
  216. package/src/lib/tool/conditions.test.ts +170 -0
  217. package/src/lib/tool/conditions.ts +132 -0
  218. package/src/lib/tool/create.test.ts +67 -0
  219. package/src/lib/tool/create.ts +83 -0
  220. package/src/lib/tool/desktop.test.ts +59 -0
  221. package/src/lib/tool/desktop.ts +99 -0
  222. package/src/lib/tool/draft.test.ts +150 -0
  223. package/src/lib/tool/draft.ts +109 -0
  224. package/src/lib/tool/engine.test.ts +39 -0
  225. package/src/lib/tool/engine.ts +58 -0
  226. package/src/lib/tool/examples.test.ts +35 -0
  227. package/src/lib/tool/examples.ts +71 -0
  228. package/src/lib/tool/export-targets.test.ts +47 -0
  229. package/src/lib/tool/export-targets.ts +84 -0
  230. package/src/lib/tool/facing.test.ts +72 -0
  231. package/src/lib/tool/facing.ts +100 -0
  232. package/src/lib/tool/geometry.test.ts +80 -0
  233. package/src/lib/tool/geometry.ts +109 -0
  234. package/src/lib/tool/gns3-cables.test.ts +47 -0
  235. package/src/lib/tool/gns3-cables.ts +81 -0
  236. package/src/lib/tool/gns3-import.test.ts +102 -0
  237. package/src/lib/tool/gns3-import.ts +198 -0
  238. package/src/lib/tool/gns3-layout.test.ts +62 -0
  239. package/src/lib/tool/gns3-parts.test.ts +47 -0
  240. package/src/lib/tool/gns3-parts.ts +68 -0
  241. package/src/lib/tool/issues.test.ts +35 -0
  242. package/src/lib/tool/issues.ts +60 -0
  243. package/src/lib/tool/layout.test.ts +128 -0
  244. package/src/lib/tool/layout.ts +229 -0
  245. package/src/lib/tool/live.test.ts +46 -0
  246. package/src/lib/tool/live.ts +49 -0
  247. package/src/lib/tool/logos.test.ts +30 -0
  248. package/src/lib/tool/logos.ts +99 -0
  249. package/src/lib/tool/model.test.ts +128 -0
  250. package/src/lib/tool/model.ts +238 -0
  251. package/src/lib/tool/os.test.ts +45 -0
  252. package/src/lib/tool/os.ts +57 -0
  253. package/src/lib/tool/parity.test.ts +145 -0
  254. package/src/lib/tool/pins.test.ts +24 -0
  255. package/src/lib/tool/pins.ts +11 -0
  256. package/src/lib/tool/placement.test.ts +79 -0
  257. package/src/lib/tool/placement.ts +60 -0
  258. package/src/lib/tool/ports.test.ts +39 -0
  259. package/src/lib/tool/ports.ts +48 -0
  260. package/src/lib/tool/presets.test.ts +50 -0
  261. package/src/lib/tool/presets.ts +77 -0
  262. package/src/lib/tool/projects-api.test.ts +59 -0
  263. package/src/lib/tool/projects-api.ts +47 -0
  264. package/src/lib/tool/router-ports.test.ts +63 -0
  265. package/src/lib/tool/router-ports.ts +88 -0
  266. package/src/lib/tool/selection.test.ts +44 -0
  267. package/src/lib/tool/selection.ts +34 -0
  268. package/src/lib/tool/storage.test.ts +123 -0
  269. package/src/lib/tool/storage.ts +127 -0
  270. package/src/lib/tool/switch-edit.test.ts +49 -0
  271. package/src/lib/tool/switch-edit.ts +38 -0
  272. package/src/lib/tool/text/de.ts +358 -0
  273. package/src/lib/tool/text/en.ts +371 -0
  274. package/src/lib/tool/text/es.ts +358 -0
  275. package/src/lib/tool/text/fr.ts +358 -0
  276. package/src/lib/tool/text/index.ts +21 -0
  277. package/src/lib/tool/text/text.test.ts +33 -0
  278. package/src/lib/tool/topology.test.ts +108 -0
  279. package/src/lib/tool/topology.ts +116 -0
  280. package/src/lib/tool/vlans.test.ts +64 -0
  281. package/src/lib/tool/vlans.ts +79 -0
  282. package/src/lib/utils.ts +6 -0
  283. package/src/model.ts +13 -0
  284. package/src/test/arbitrary.ts +41 -0
  285. package/src/test/engine.ts +48 -0
  286. package/src/test/lab.ts +101 -0
  287. package/styles/globals.css +258 -0
  288. package/styles/theme.css +192 -0
  289. package/tsconfig.build.json +12 -0
  290. package/tsconfig.json +22 -0
  291. package/wasm/isothrum_wasm.d.ts +60 -0
  292. package/wasm/isothrum_wasm.js +312 -0
  293. package/wasm/isothrum_wasm_bg.wasm +0 -0
@@ -0,0 +1,87 @@
1
+ "use client";
2
+
3
+ import { ChevronRight, Download } from "lucide-react";
4
+ import type { Target } from "@/lib/tool/export-targets";
5
+ import { cn } from "@/lib/utils";
6
+ import { useText } from "../text";
7
+ import { Command } from "./copy-button";
8
+
9
+ /** Which target and which file are open: kept by the Export window, so they stay open while it is. */
10
+ export interface Opened {
11
+ target: string | null;
12
+ setTarget: (dir: string | null) => void;
13
+ file: string | null;
14
+ setFile: (path: string | null) => void;
15
+ }
16
+
17
+ /** A target's commands and files; a click on a file shows it. */
18
+ function TargetDetails({ target, opened }: { target: Target; opened: Opened }) {
19
+ const t = useText().exportPanel;
20
+ const { file: openFile, setFile: setOpenFile } = opened;
21
+ return (
22
+ <div className="flex flex-col gap-3 px-4 pb-4">
23
+ {target.isoloom && <Command label={t.withIsoloom} text={target.isoloom} />}
24
+ {target.run && <Command label={target.isoloom ? t.withTool : t.run} text={target.run} />}
25
+ <ul className="font-mono text-xs">
26
+ {target.files.map((f) => (
27
+ <li key={f.path}>
28
+ <button
29
+ type="button"
30
+ onClick={() => { setOpenFile(openFile === f.path ? null : f.path); }}
31
+ className={cn("w-full truncate rounded px-2 py-1 text-left text-muted-foreground hover:bg-muted hover:text-foreground", openFile === f.path && "bg-muted text-foreground")}
32
+ >
33
+ {f.path}
34
+ </button>
35
+ {openFile === f.path && (
36
+ <pre className="mt-1 max-h-80 overflow-auto rounded-md border bg-surface p-3">
37
+ <code>{f.contents}</code>
38
+ </pre>
39
+ )}
40
+ </li>
41
+ ))}
42
+ </ul>
43
+ </div>
44
+ );
45
+ }
46
+
47
+ /** One target: its name and file count (a click opens it), and its own zip. */
48
+ function TargetRow({ target, opened, onDownload, ready }: { target: Target; opened: Opened; onDownload: () => void; ready: boolean }) {
49
+ const t = useText().exportPanel;
50
+ const expanded = opened.target === target.dir;
51
+ const onToggle = () => {
52
+ opened.setTarget(expanded ? null : target.dir);
53
+ };
54
+ return (
55
+ <li>
56
+ <div className="flex items-center hover:bg-muted/50">
57
+ <button type="button" aria-expanded={expanded} onClick={onToggle} className="flex min-w-0 flex-1 items-center gap-2 py-3 pl-4 text-left text-sm">
58
+ <ChevronRight className={cn("size-4 text-muted-foreground transition-transform", expanded && "rotate-90")} aria-hidden />
59
+ <span className="flex-1 font-medium">{target.name}</span>
60
+ <span className="text-xs text-muted-foreground">{t.files(target.files.length)}</span>
61
+ </button>
62
+ <button
63
+ type="button"
64
+ onClick={onDownload}
65
+ disabled={!ready}
66
+ title={t.downloadOnly(target.name)}
67
+ aria-label={t.downloadTarget(target.name)}
68
+ className="mr-2 grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40"
69
+ >
70
+ <Download className="size-4" />
71
+ </button>
72
+ </div>
73
+ {expanded && <TargetDetails target={target} opened={opened} />}
74
+ </li>
75
+ );
76
+ }
77
+
78
+ /** Every target the zip holds, one open at a time. */
79
+ export function TargetList({ targets, ready, opened, onDownloadTarget }: { targets: Target[]; ready: boolean; opened: Opened; onDownloadTarget: (dir: string) => void }) {
80
+ return (
81
+ <ul className="divide-y rounded-lg border">
82
+ {targets.map((g) => (
83
+ <TargetRow key={g.dir} target={g} opened={opened} onDownload={() => { onDownloadTarget(g.dir); }} ready={ready} />
84
+ ))}
85
+ </ul>
86
+ );
87
+ }
@@ -0,0 +1,69 @@
1
+ "use client";
2
+
3
+ import { useRef, useState, type PointerEvent, type ReactNode } from "react";
4
+ import { GripHorizontal, X } from "lucide-react";
5
+ import { cn } from "@/lib/utils";
6
+ import { useText } from "./text";
7
+
8
+ const MARGIN = 12;
9
+ /** Room left under the top bar (menu, toolbar, export). */
10
+ const TOP = 72;
11
+
12
+ /** Dragging a window by its title bar, kept inside the screen. */
13
+ function useWindowDrag(width: number, side: "left" | "right") {
14
+ const ref = useRef<HTMLDivElement>(null);
15
+ // The editor only renders in the browser, so the screen size is known from the start.
16
+ const [pos, setPos] = useState(() => {
17
+ const w = Math.min(width, window.innerWidth - 2 * MARGIN);
18
+ return { x: side === "left" ? MARGIN : window.innerWidth - w - MARGIN, y: TOP };
19
+ });
20
+ const drag = useRef<{ dx: number; dy: number } | null>(null);
21
+ const clamp = (x: number, y: number) => {
22
+ const w = ref.current?.offsetWidth ?? width;
23
+ return { x: Math.min(Math.max(MARGIN, x), window.innerWidth - w - MARGIN), y: Math.min(Math.max(MARGIN, y), window.innerHeight - 48) };
24
+ };
25
+ const handlers = {
26
+ onPointerDown: (e: PointerEvent<HTMLDivElement>) => {
27
+ if (e.target instanceof Element && e.target.closest("button")) return;
28
+ drag.current = { dx: e.clientX - pos.x, dy: e.clientY - pos.y };
29
+ e.currentTarget.setPointerCapture(e.pointerId);
30
+ },
31
+ onPointerMove: (e: PointerEvent<HTMLDivElement>) => {
32
+ if (drag.current) setPos(clamp(e.clientX - drag.current.dx, e.clientY - drag.current.dy));
33
+ },
34
+ onPointerUp: () => {
35
+ drag.current = null;
36
+ },
37
+ };
38
+ return { ref, pos, handlers };
39
+ }
40
+
41
+ /**
42
+ * A window floating over the canvas: drag it by its title bar, close it with the cross. It opens
43
+ * against the left or right edge and stays inside the screen while dragged.
44
+ */
45
+ export function FloatingWindow({ title, side, width, onClose, children, className }: { title: ReactNode; side: "left" | "right"; width: number; onClose: () => void; children: ReactNode; className?: string }) {
46
+ const t = useText();
47
+ const { ref, pos, handlers } = useWindowDrag(width, side);
48
+ return (
49
+ <div
50
+ ref={ref}
51
+ role="dialog"
52
+ aria-label={typeof title === "string" ? title : undefined}
53
+ style={{ left: pos.x, top: pos.y, width: `min(${String(width)}px, calc(100vw - ${String(2 * MARGIN)}px))` }}
54
+ className={cn(
55
+ "absolute z-20 flex max-h-[calc(100dvh-96px)] flex-col overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-[0_8px_30px_-8px_rgb(0_0_0/0.18)]",
56
+ className,
57
+ )}
58
+ >
59
+ <div {...handlers} className="flex cursor-grab touch-none items-center gap-2 border-b px-3 py-2 select-none active:cursor-grabbing">
60
+ <GripHorizontal className="size-4 text-muted-foreground/60" aria-hidden />
61
+ <span className="flex-1 truncate font-heading text-sm font-semibold">{title}</span>
62
+ <button type="button" onClick={onClose} aria-label={t.windows.close} className="grid size-6 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground">
63
+ <X className="size-4" />
64
+ </button>
65
+ </div>
66
+ <div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,138 @@
1
+ "use client";
2
+
3
+ import type { MouseEvent, ReactNode } from "react";
4
+ import { Background, BackgroundVariant, ConnectionMode, Controls, Panel, ReactFlow, SelectionMode, type EdgeTypes, type NodeTypes } from "@xyflow/react";
5
+ import "@xyflow/react/dist/style.css";
6
+ import { Redo2, Undo2 } from "lucide-react";
7
+ import { machineEnds, networkOfCable } from "@/lib/tool/cables";
8
+ import { isLan, isMachine, type CableEdge, type LabNode } from "@/lib/tool/model";
9
+ import { nameById } from "@/lib/tool/selection";
10
+ import { CableEdgeView } from "./canvas/cable-edge";
11
+ import { AddressesContext, LiveContext, PortsContext, SidesContext } from "./canvas/contexts";
12
+ import { LanNodeView } from "./canvas/lan-node";
13
+ import { MachineNodeView } from "./canvas/machine-node";
14
+ import { NoteNodeView } from "./canvas/note-node";
15
+ import { DesignContext } from "./canvas/sketch";
16
+ import { SwitchNodeView } from "./canvas/switch-node";
17
+ import type { EditorModel } from "./hooks/use-editor";
18
+
19
+ const nodeTypes: NodeTypes = { machine: MachineNodeView, lan: LanNodeView, switch: SwitchNodeView, note: NoteNodeView };
20
+ const edgeTypes: EdgeTypes = { cable: CableEdgeView };
21
+
22
+ /** What the canvas's nodes and cables read: the look, addresses, port sides and the running lab. */
23
+ function CanvasContexts({ e, children }: { e: EditorModel; children: ReactNode }) {
24
+ return (
25
+ <DesignContext.Provider value={e.design.design}>
26
+ <AddressesContext.Provider value={e.addresses}>
27
+ <SidesContext.Provider value={e.views.sides}>
28
+ <PortsContext.Provider value={e.views.routerPorts}>
29
+ <LiveContext.Provider value={e.liveView}>{children}</LiveContext.Provider>
30
+ </PortsContext.Provider>
31
+ </SidesContext.Provider>
32
+ </AddressesContext.Provider>
33
+ </DesignContext.Provider>
34
+ );
35
+ }
36
+
37
+ /** As in GNS3: double-click a running machine for its console, right-click for the rest (desktop app only). */
38
+ function runningLabMenus(e: EditorModel) {
39
+ const { invoke, desk, lab } = e;
40
+ const running = desk.state.run === "running";
41
+ return {
42
+ onNodeDoubleClick: (_: MouseEvent, node: LabNode) => {
43
+ if (invoke && running && isMachine(node)) void desk.actions.machineConsole(node.data.name);
44
+ },
45
+ onNodeContextMenu: (ev: MouseEvent, node: LabNode) => {
46
+ if (!invoke) return;
47
+ if (isMachine(node)) {
48
+ ev.preventDefault();
49
+ desk.menus.setMachineMenu({ x: ev.clientX, y: ev.clientY, machine: node.data.name });
50
+ } else if (isLan(node)) {
51
+ ev.preventDefault();
52
+ desk.menus.setLinkMenu({ x: ev.clientX, y: ev.clientY, network: node.data.name, title: node.data.name });
53
+ }
54
+ },
55
+ onEdgeContextMenu: (ev: MouseEvent, edge: CableEdge) => {
56
+ const network = invoke ? networkOfCable(edge.id, lab.nodes, lab.edges) : null;
57
+ if (!network) return;
58
+ ev.preventDefault();
59
+ const title = `${nameById(lab.nodes, edge.source)} ↔ ${nameById(lab.nodes, edge.target)}`;
60
+ desk.menus.setLinkMenu({ x: ev.clientX, y: ev.clientY, network, title, machines: machineEnds(edge, lab.nodes) });
61
+ },
62
+ };
63
+ }
64
+
65
+ /** Undo and redo, beside the zoom controls. */
66
+ function HistoryButtons({ e }: { e: EditorModel }) {
67
+ const { step, canUndo, canRedo } = e.history;
68
+ const t = e.t.toolbar;
69
+ return (
70
+ <Panel position="bottom-left" className="!mb-4 !ml-16 flex overflow-hidden rounded-lg border bg-popover shadow-sm">
71
+ <button type="button" onClick={() => { step(true); }} disabled={!canUndo} title={t.undoTitle} aria-label={t.undo} className="grid size-8 place-items-center text-foreground hover:bg-muted disabled:opacity-35">
72
+ <Undo2 className="size-4" />
73
+ </button>
74
+ <button type="button" onClick={() => { step(false); }} disabled={!canRedo} title={t.redoTitle} aria-label={t.redo} className="grid size-8 place-items-center border-l text-foreground hover:bg-muted disabled:opacity-35">
75
+ <Redo2 className="size-4" />
76
+ </button>
77
+ </Panel>
78
+ );
79
+ }
80
+
81
+ /** The canvas: React Flow with the lab's nodes and cables, in Excalidraw's two modes. */
82
+ export function FlowCanvas({ e }: { e: EditorModel }) {
83
+ const { lab, tools, cabling, carry, placement } = e;
84
+ const select = tools.mode === "select";
85
+ const menus = runningLabMenus(e);
86
+ return (
87
+ <CanvasContexts e={e}>
88
+ <ReactFlow<LabNode, CableEdge>
89
+ nodes={lab.nodes}
90
+ edges={e.views.shownEdges}
91
+ nodeTypes={nodeTypes}
92
+ edgeTypes={edgeTypes}
93
+ onNodesChange={cabling.onNodesChange}
94
+ onNodeDragStart={carry.onNodeDragStart}
95
+ onNodeDrag={carry.onNodeDrag}
96
+ onNodeDragStop={carry.onNodeDragStop}
97
+ // A selected LAN must stay under its machines.
98
+ elevateNodesOnSelect={false}
99
+ onEdgesChange={lab.onEdgesChange}
100
+ onConnect={cabling.onConnect}
101
+ onPaneClick={placement.onPaneClick}
102
+ onNodeClick={placement.onNodeClick}
103
+ onNodeDoubleClick={menus.onNodeDoubleClick}
104
+ onNodeContextMenu={menus.onNodeContextMenu}
105
+ onEdgeContextMenu={menus.onEdgeContextMenu}
106
+ onMoveStart={e.desk.menus.closeMenus}
107
+ connectionMode={ConnectionMode.Loose}
108
+ // Select: dragging the canvas draws a selection box; the middle button or Space pans.
109
+ // Hand: dragging pans, and nothing on the canvas can be changed.
110
+ panOnDrag={select ? [1] : true}
111
+ selectionOnDrag={select}
112
+ // Only what is wholly inside the box, so selecting a LAN's machines leaves the LAN alone.
113
+ selectionMode={SelectionMode.Full}
114
+ multiSelectionKeyCode={["Shift", "Meta", "Control"]}
115
+ panActivationKeyCode="Space"
116
+ nodesDraggable={select}
117
+ nodesConnectable={select}
118
+ elementsSelectable={select}
119
+ panOnScroll
120
+ zoomActivationKeyCode={["Meta", "Control"]}
121
+ isValidConnection={cabling.isValid}
122
+ edgesReconnectable
123
+ onReconnectStart={cabling.onReconnectStart}
124
+ onReconnect={cabling.onReconnect}
125
+ onReconnectEnd={cabling.onReconnectEnd}
126
+ colorMode={e.resolvedTheme === "dark" ? "dark" : "light"}
127
+ deleteKeyCode={["Backspace", "Delete"]}
128
+ fitView
129
+ fitViewOptions={{ padding: 0.4, maxZoom: 1.1 }}
130
+ proOptions={{ hideAttribution: true }}
131
+ >
132
+ {e.design.design === "business" && <Background variant={BackgroundVariant.Dots} gap={20} size={1.2} color="var(--border)" />}
133
+ <Controls position="bottom-left" showInteractive={false} className="!m-4 overflow-hidden !rounded-lg !border !shadow-sm" />
134
+ <HistoryButtons e={e} />
135
+ </ReactFlow>
136
+ </CanvasContexts>
137
+ );
138
+ }
@@ -0,0 +1,66 @@
1
+ import { useCallback, useMemo, useRef } from "react";
2
+ import { addEdge, type Connection, type NodeChange } from "@xyflow/react";
3
+ import { canConnect, newCable, onLanEdge, reconnected, type LabIndex } from "@/lib/tool/cables";
4
+ import { isLan, isRealRouter, isSwitch, type CableEdge, type LabNode, type LanNode } from "@/lib/tool/model";
5
+ import { snapMoves } from "@/lib/tool/placement";
6
+ import type { Lab } from "./use-lab";
7
+
8
+ /** The ids of the LANs, switches and real routers, and the LANs themselves. */
9
+ export function useLabIndex(nodes: LabNode[]): LabIndex & { lans: LanNode[] } {
10
+ const lanIds = useMemo(() => new Set(nodes.filter(isLan).map((n) => n.id)), [nodes]);
11
+ const lans = useMemo(() => nodes.filter(isLan), [nodes]);
12
+ const switchIds = useMemo(() => new Set(nodes.filter(isSwitch).map((n) => n.id)), [nodes]);
13
+ const routerIds = useMemo(() => new Set(nodes.filter(isRealRouter).map((n) => n.id)), [nodes]);
14
+ return useMemo(() => ({ lanIds, lans, switchIds, routerIds }), [lanIds, lans, switchIds, routerIds]);
15
+ }
16
+
17
+ /**
18
+ * Drawing and moving cables. Machine to machine, LAN or port; never LAN to LAN or switch to LAN;
19
+ * a switch or router port takes one cable. Cables can be moved: drag either end onto another
20
+ * machine, LAN or port, or onto the canvas to unplug it.
21
+ */
22
+ export function useCabling(lab: Lab, index: LabIndex & { lans: LanNode[] }) {
23
+ const { nodes, edges, setEdges, onNodesChange } = lab;
24
+ // Routers, firewalls and switches are magnetic: dragged near a LAN's border, they land on it.
25
+ const onSnappingChange = useCallback(
26
+ (changes: NodeChange<LabNode>[]) => {
27
+ onNodesChange(snapMoves(changes, nodes, index.lans));
28
+ },
29
+ [onNodesChange, nodes, index.lans],
30
+ );
31
+ // The cable being moved, if any: its own ends don't count as taken while it moves.
32
+ const moving = useRef<{ id: string; done: boolean } | null>(null);
33
+ // An appliance on a LAN's edge already uses eth0 there: its cables start at eth1.
34
+ const first = useCallback((id: string) => (onLanEdge(id, nodes, index.lans) ? 1 : 0), [nodes, index.lans]);
35
+ const isValid = useCallback((c: Parameters<typeof canConnect>[0]) => canConnect(c, index, edges, moving.current?.id), [index, edges]);
36
+ const onReconnect = useCallback(
37
+ (old: CableEdge, c: Connection) => {
38
+ if (!c.source || !c.target || !canConnect(c, index, edges, moving.current?.id)) return;
39
+ if (moving.current) moving.current.done = true;
40
+ setEdges((es) => reconnected(es, old, c, index, first));
41
+ },
42
+ [index, edges, setEdges, first],
43
+ );
44
+ const onConnect = useCallback(
45
+ (c: Connection) => {
46
+ if (!c.source || !c.target || c.source === c.target) return;
47
+ setEdges((es) => addEdge<CableEdge>(newCable(c, index, es, first), es));
48
+ },
49
+ [setEdges, index, first],
50
+ );
51
+ return {
52
+ onNodesChange: onSnappingChange,
53
+ isValid,
54
+ onConnect,
55
+ onReconnect,
56
+ onReconnectStart: (_: unknown, edge: CableEdge) => {
57
+ moving.current = { id: edge.id, done: false };
58
+ },
59
+ // Dropped nowhere: the cable is unplugged.
60
+ onReconnectEnd: () => {
61
+ const m = moving.current;
62
+ if (m && !m.done) setEdges((es) => es.filter((e) => e.id !== m.id));
63
+ moving.current = null;
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,24 @@
1
+ import { useState } from "react";
2
+ import type { Invoke } from "@/lib/tool/desktop";
3
+
4
+ /** Captures in progress (desktop): network -> the .pcap file, or null when Wireshark shows it live. */
5
+ export function useCaptures(invoke: Invoke | null, labName: string, onError: (error: string) => void) {
6
+ const [captures, setCaptures] = useState<Record<string, string | null>>({});
7
+ async function capture(network: string) {
8
+ if (!invoke) return;
9
+ try {
10
+ const res = await invoke<{ file: string | null }>("capture_start", { name: labName, network });
11
+ setCaptures((c) => ({ ...c, [network]: res.file }));
12
+ } catch (e) {
13
+ onError(String(e));
14
+ }
15
+ }
16
+ async function stopCapture(network: string) {
17
+ if (!invoke) return;
18
+ await invoke("capture_stop", { name: labName, network }).catch(() => undefined);
19
+ setCaptures((c) => Object.fromEntries(Object.entries(c).filter(([n]) => n !== network)));
20
+ }
21
+ return { captures, capture, stopCapture, clearCaptures: () => { setCaptures({}); } };
22
+ }
23
+
24
+ export type Captures = ReturnType<typeof useCaptures>;
@@ -0,0 +1,54 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import type { XYPosition } from "@xyflow/react";
3
+ import { cloneLab } from "@/lib/tool/clone";
4
+ import { isLan, unselected, type CableEdge, type LabNode } from "@/lib/tool/model";
5
+ import { inTextField } from "./use-history";
6
+ import type { Lab } from "./use-lab";
7
+
8
+ type DeleteNodes = (elements: { nodes: { id: string }[] }) => unknown;
9
+
10
+ /** The cables between `picked` nodes: what goes with them when they are copied. */
11
+ function cablesWithin(picked: LabNode[], edges: CableEdge[]): CableEdge[] {
12
+ const ids = new Set(picked.map((n) => n.id));
13
+ return edges.filter((c) => ids.has(c.source) && ids.has(c.target));
14
+ }
15
+
16
+ /**
17
+ * Copy, cut, paste and duplicate (Ctrl/⌘ C, X, V, D). Copies get new names and ids, a LAN its own
18
+ * block; LANs go first in the list so they stay under the machines.
19
+ */
20
+ export function useClipboard(lab: Lab, deleteElements: DeleteNodes): void {
21
+ const { nodes, edges, setNodes, setEdges } = lab;
22
+ const clipboard = useRef<{ nodes: LabNode[]; edges: CableEdge[]; pastes: number } | null>(null);
23
+ const addCopies = useCallback(
24
+ (picked: LabNode[], from: CableEdge[], offset: XYPosition) => {
25
+ const copies = cloneLab(picked, from, nodes, offset);
26
+ setNodes([...copies.nodes.filter(isLan), ...unselected(nodes), ...copies.nodes.filter((n) => !isLan(n))]);
27
+ setEdges((es) => [...unselected(es), ...copies.edges]);
28
+ },
29
+ [nodes, setNodes, setEdges],
30
+ );
31
+ useEffect(() => {
32
+ function onKey(e: KeyboardEvent) {
33
+ if (inTextField(e)) return;
34
+ if (!(e.metaKey || e.ctrlKey) || e.shiftKey) return;
35
+ const key = e.key.toLowerCase();
36
+ const picked = nodes.filter((n) => n.selected);
37
+ const pasted = clipboard.current;
38
+ if ((key === "c" || key === "x") && picked.length) {
39
+ clipboard.current = { nodes: picked, edges: cablesWithin(picked, edges), pastes: 0 };
40
+ if (key === "x") void deleteElements({ nodes: picked.map((n) => ({ id: n.id })) });
41
+ } else if (key === "v" && pasted) {
42
+ pasted.pastes += 1;
43
+ addCopies(pasted.nodes, pasted.edges, { x: 40 * pasted.pastes, y: 40 * pasted.pastes });
44
+ } else if (key === "d" && picked.length) {
45
+ addCopies(picked, cablesWithin(picked, edges), { x: 40, y: 40 });
46
+ } else return;
47
+ e.preventDefault();
48
+ }
49
+ window.addEventListener("keydown", onKey);
50
+ return () => {
51
+ window.removeEventListener("keydown", onKey);
52
+ };
53
+ }, [nodes, edges, addCopies, deleteElements]);
54
+ }
@@ -0,0 +1,37 @@
1
+ import { useState } from "react";
2
+
3
+ /** A right-click on a machine (desktop): its console, its screen, starting or stopping it. */
4
+ export interface MachineMenuAt {
5
+ x: number;
6
+ y: number;
7
+ machine: string;
8
+ }
9
+
10
+ /**
11
+ * A right-click on a cable or a LAN (desktop): capture its traffic, suspend or resume it.
12
+ * `machines`: a cable's ends that are machines (their ports); none for a whole LAN.
13
+ */
14
+ export interface LinkMenuAt {
15
+ x: number;
16
+ y: number;
17
+ network: string;
18
+ title: string;
19
+ machines?: string[];
20
+ }
21
+
22
+ /** The right-click menus open on the canvas (desktop app only). */
23
+ export function useContextMenus() {
24
+ const [machineMenu, setMachineMenu] = useState<MachineMenuAt | null>(null);
25
+ const [linkMenu, setLinkMenu] = useState<LinkMenuAt | null>(null);
26
+ return {
27
+ machineMenu,
28
+ setMachineMenu,
29
+ linkMenu,
30
+ setLinkMenu,
31
+ closeMenus: () => {
32
+ setMachineMenu(null);
33
+ setLinkMenu(null);
34
+ },
35
+ };
36
+ }
37
+
@@ -0,0 +1,24 @@
1
+ import { useEffect, useState } from "react";
2
+ import { desktop } from "@/lib/tool/desktop";
3
+ import type { Design } from "@/lib/tool/model";
4
+ import { loadDesign, saveDesign } from "@/lib/tool/storage";
5
+
6
+ /**
7
+ * The canvas's look: a preference of this browser (a share link brings its own). The desktop
8
+ * app opens in business, the site in the sketch.
9
+ */
10
+ export function useDesign() {
11
+ const [design, setDesign] = useState<Design>(() => (desktop() ? "business" : "sketch"));
12
+ useEffect(() => {
13
+ const saved = loadDesign();
14
+ if (saved)
15
+ queueMicrotask(() => {
16
+ setDesign(saved);
17
+ });
18
+ }, []);
19
+ const chooseDesign = (d: Design) => {
20
+ setDesign(d);
21
+ saveDesign(d);
22
+ };
23
+ return { design, setDesign, chooseDesign };
24
+ }
@@ -0,0 +1,100 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { desktop, runningServices, type DesktopInfo, type Invoke, type LabLive, type Snapshot, type SuspendedLink } from "@/lib/tool/desktop";
3
+ import { autoStarts } from "@/lib/tool/storage";
4
+
5
+ /** The desktop app's side of the editor: running the lab on this machine (none in a browser). */
6
+
7
+ export type RunState = "stopped" | "starting" | "running" | "stopping";
8
+
9
+ /** The app's commands (null in a browser), what the app says about itself, and the lab's run state. */
10
+ export function useDesktopState() {
11
+ const [invoke] = useState(desktop);
12
+ const [info, setInfo] = useState<DesktopInfo | null>(null);
13
+ const [run, setRun] = useState<RunState>("stopped");
14
+ const [update, setUpdate] = useState<string | null>(null);
15
+ const [updating, setUpdating] = useState(false);
16
+ return { invoke, info, setInfo, run, setRun, update, setUpdate, updating, setUpdating };
17
+ }
18
+
19
+ export type DesktopState = ReturnType<typeof useDesktopState>;
20
+
21
+ /**
22
+ * The running lab now (desktop): its machines' states and their ports, read from them, its
23
+ * machines' snapshots, and the links cut. Read again every 3 seconds while it runs.
24
+ */
25
+ export function useLiveLab(invoke: Invoke | null, labName: string, run: RunState) {
26
+ const [machineStates, setMachineStates] = useState<Record<string, string>>({});
27
+ const [snapshots, setSnapshots] = useState<Snapshot[]>([]);
28
+ const [links, setLinks] = useState<SuspendedLink[]>([]);
29
+ const [live, setLive] = useState<LabLive | null>(null);
30
+ const refreshStates = useCallback(async () => {
31
+ if (!invoke) return;
32
+ try {
33
+ const now = await invoke<LabLive | null>("lab_live", { name: labName });
34
+ setLive(now);
35
+ setMachineStates(Object.fromEntries(Object.entries(now?.machines ?? {}).map(([m, l]) => [m, l.state])));
36
+ setSnapshots(await invoke<Snapshot[]>("snapshot_list", { name: labName }));
37
+ setLinks(await invoke<SuspendedLink[]>("link_list", { name: labName }));
38
+ } catch {
39
+ setMachineStates({});
40
+ setLive(null);
41
+ }
42
+ }, [invoke, labName]);
43
+ useEffect(() => {
44
+ if (run !== "running") return;
45
+ queueMicrotask(() => void refreshStates());
46
+ const timer = setInterval(() => void refreshStates(), 3000);
47
+ return () => {
48
+ clearInterval(timer);
49
+ };
50
+ }, [run, refreshStates]);
51
+ return { machineStates, snapshots, links, setLinks, live, setLive, refreshStates };
52
+ }
53
+
54
+ export type LiveLab = ReturnType<typeof useLiveLab>;
55
+
56
+ /** The Run window's state and its log. */
57
+ export function useRunPanel() {
58
+ const [runOpen, setRunOpen] = useState(false);
59
+ const [runLog, setRunLog] = useState("");
60
+ return { runOpen, setRunOpen, runLog, setRunLog };
61
+ }
62
+
63
+ /** Auto-start (desktop): this lab starts when the app opens on it, once the engine has its spec. */
64
+ export function useAutoStart(invoke: Invoke | null, labName: string) {
65
+ const [autoStart, setAutoStart] = useState(false);
66
+ const [autoStartPending, setAutoStartPending] = useState(false);
67
+ useEffect(() => {
68
+ if (invoke)
69
+ queueMicrotask(() => {
70
+ setAutoStart(autoStarts(labName));
71
+ });
72
+ }, [invoke, labName]);
73
+ return { autoStart, setAutoStart, autoStartPending, setAutoStartPending };
74
+ }
75
+
76
+ /**
77
+ * On opening: what the app says about itself, whether a newer release is out (signed; from
78
+ * GitHub's latest release), and whether the lab still runs from before the app was reopened. If
79
+ * not, and it is set to, it starts.
80
+ */
81
+ export function useDesktopStartup(state: DesktopState, initialName: string, setAutoStartPending: (pending: boolean) => void): void {
82
+ const { invoke, setInfo, setUpdate, setRun } = state;
83
+ useEffect(() => {
84
+ if (!invoke) return;
85
+ invoke<DesktopInfo>("desktop_info").then(setInfo, () => undefined);
86
+ invoke<{ version: string } | null>("update_check").then(
87
+ (u) => {
88
+ if (u) setUpdate(u.version);
89
+ },
90
+ () => undefined,
91
+ );
92
+ invoke<string>("lab_status", { name: initialName }).then(
93
+ (ps) => {
94
+ if (runningServices(ps) > 0) setRun("running");
95
+ else if (autoStarts(initialName)) setAutoStartPending(true);
96
+ },
97
+ () => undefined,
98
+ );
99
+ }, [invoke, initialName, setInfo, setUpdate, setRun, setAutoStartPending]);
100
+ }
@@ -0,0 +1,58 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { allPresets, deviceBlocks } from "@/lib/tool/blocks";
3
+ import type { MachineData } from "@/lib/tool/model";
4
+ import { deviceFrom, type Preset } from "@/lib/tool/presets";
5
+ import { loadBlockChoices, loadDevices, saveBlockChoices, saveDevices } from "@/lib/tool/storage";
6
+ import type { EditorText } from "@/lib/tool/text";
7
+ import type { Tools } from "./use-tools";
8
+
9
+ /**
10
+ * Devices saved from the canvas, in this browser (loaded after mount: no localStorage on the
11
+ * server), and the toolbar's blocks, each placing the kind picked last.
12
+ */
13
+ export function useDevices(tools: Tools, setToast: (message: string) => void, t: EditorText) {
14
+ const [myDevices, setMyDevices] = useState<Preset[]>([]);
15
+ useEffect(() => {
16
+ queueMicrotask(() => {
17
+ setMyDevices(loadDevices());
18
+ });
19
+ }, []);
20
+ const presets = useMemo(() => allPresets(myDevices), [myDevices]);
21
+ const blocks = useMemo(() => deviceBlocks(presets, myDevices, t), [presets, myDevices, t]);
22
+ const [blockChoice, setBlockChoice] = useState<Record<string, string>>({});
23
+ useEffect(() => {
24
+ const saved = loadBlockChoices();
25
+ if (saved)
26
+ queueMicrotask(() => {
27
+ setBlockChoice(saved);
28
+ });
29
+ }, []);
30
+ const keep = (list: Preset[]) => {
31
+ setMyDevices(list);
32
+ saveDevices(list);
33
+ };
34
+ return {
35
+ presets,
36
+ blocks,
37
+ blockChoice,
38
+ pickFromBlock: (block: string, tool: string) => {
39
+ tools.pick(tool);
40
+ setBlockChoice((c) => {
41
+ const next = { ...c, [block]: tool };
42
+ saveBlockChoices(next);
43
+ return next;
44
+ });
45
+ },
46
+ /** Saving a machine again under the same name replaces that device. */
47
+ saveDevice: (m: MachineData) => {
48
+ const device = deviceFrom(m);
49
+ const name = device.label ?? "";
50
+ keep([...myDevices.filter((d) => d.label !== name), device]);
51
+ setToast(t.toasts.savedDevice(name));
52
+ },
53
+ forgetDevice: (id: string) => {
54
+ keep(myDevices.filter((d) => d.id !== id));
55
+ if (tools.activeTool === id) tools.setActiveTool(null);
56
+ },
57
+ };
58
+ }