@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,185 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Menu as MenuPrimitive } from "@base-ui/react/menu"
5
+
6
+ import { cn } from "@/lib/utils"
7
+ import { ChevronRightIcon } from "lucide-react"
8
+
9
+ function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
10
+ return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
11
+ }
12
+
13
+ function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
14
+ return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
15
+ }
16
+
17
+ function DropdownMenuContent({
18
+ align = "start",
19
+ alignOffset = 0,
20
+ side = "bottom",
21
+ sideOffset = 4,
22
+ className,
23
+ ...props
24
+ }: MenuPrimitive.Popup.Props &
25
+ Pick<
26
+ MenuPrimitive.Positioner.Props,
27
+ "align" | "alignOffset" | "side" | "sideOffset"
28
+ >) {
29
+ return (
30
+ <MenuPrimitive.Portal>
31
+ <MenuPrimitive.Positioner
32
+ className="isolate z-50 outline-none"
33
+ align={align}
34
+ alignOffset={alignOffset}
35
+ side={side}
36
+ sideOffset={sideOffset}
37
+ >
38
+ <MenuPrimitive.Popup
39
+ data-slot="dropdown-menu-content"
40
+ className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
41
+ {...props}
42
+ />
43
+ </MenuPrimitive.Positioner>
44
+ </MenuPrimitive.Portal>
45
+ )
46
+ }
47
+
48
+ function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
49
+ return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
50
+ }
51
+
52
+ function DropdownMenuLabel({
53
+ className,
54
+ inset,
55
+ ...props
56
+ }: MenuPrimitive.GroupLabel.Props & {
57
+ inset?: boolean
58
+ }) {
59
+ return (
60
+ <MenuPrimitive.GroupLabel
61
+ data-slot="dropdown-menu-label"
62
+ data-inset={inset}
63
+ className={cn(
64
+ "px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
65
+ className
66
+ )}
67
+ {...props}
68
+ />
69
+ )
70
+ }
71
+
72
+ function DropdownMenuItem({
73
+ className,
74
+ inset,
75
+ variant = "default",
76
+ ...props
77
+ }: MenuPrimitive.Item.Props & {
78
+ inset?: boolean
79
+ variant?: "default" | "destructive"
80
+ }) {
81
+ return (
82
+ <MenuPrimitive.Item
83
+ data-slot="dropdown-menu-item"
84
+ data-inset={inset}
85
+ data-variant={variant}
86
+ className={cn(
87
+ "group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
88
+ className
89
+ )}
90
+ {...props}
91
+ />
92
+ )
93
+ }
94
+
95
+ function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
96
+ return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
97
+ }
98
+
99
+ function DropdownMenuSubTrigger({
100
+ className,
101
+ inset,
102
+ children,
103
+ ...props
104
+ }: MenuPrimitive.SubmenuTrigger.Props & {
105
+ inset?: boolean
106
+ }) {
107
+ return (
108
+ <MenuPrimitive.SubmenuTrigger
109
+ data-slot="dropdown-menu-sub-trigger"
110
+ data-inset={inset}
111
+ className={cn(
112
+ "flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
113
+ className
114
+ )}
115
+ {...props}
116
+ >
117
+ {children}
118
+ <ChevronRightIcon className="ml-auto" />
119
+ </MenuPrimitive.SubmenuTrigger>
120
+ )
121
+ }
122
+
123
+ function DropdownMenuSubContent({
124
+ align = "start",
125
+ alignOffset = -3,
126
+ side = "right",
127
+ sideOffset = 0,
128
+ className,
129
+ ...props
130
+ }: React.ComponentProps<typeof DropdownMenuContent>) {
131
+ return (
132
+ <DropdownMenuContent
133
+ data-slot="dropdown-menu-sub-content"
134
+ className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
135
+ align={align}
136
+ alignOffset={alignOffset}
137
+ side={side}
138
+ sideOffset={sideOffset}
139
+ {...props}
140
+ />
141
+ )
142
+ }
143
+
144
+ function DropdownMenuSeparator({
145
+ className,
146
+ ...props
147
+ }: MenuPrimitive.Separator.Props) {
148
+ return (
149
+ <MenuPrimitive.Separator
150
+ data-slot="dropdown-menu-separator"
151
+ className={cn("-mx-1 my-1 h-px bg-border", className)}
152
+ {...props}
153
+ />
154
+ )
155
+ }
156
+
157
+ function DropdownMenuShortcut({
158
+ className,
159
+ ...props
160
+ }: React.ComponentProps<"span">) {
161
+ return (
162
+ <span
163
+ data-slot="dropdown-menu-shortcut"
164
+ className={cn(
165
+ "ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
166
+ className
167
+ )}
168
+ {...props}
169
+ />
170
+ )
171
+ }
172
+
173
+ export {
174
+ DropdownMenu,
175
+ DropdownMenuTrigger,
176
+ DropdownMenuContent,
177
+ DropdownMenuGroup,
178
+ DropdownMenuLabel,
179
+ DropdownMenuItem,
180
+ DropdownMenuSeparator,
181
+ DropdownMenuShortcut,
182
+ DropdownMenuSub,
183
+ DropdownMenuSubTrigger,
184
+ DropdownMenuSubContent,
185
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The Isothrum lab editor as a reusable React component, plus the ports a host wires in (accounts,
5
+ * the product mark). The pure lab-model logic (no React) lives in the `./model` entry.
6
+ */
7
+ export { LabCanvas, type LabCanvasProps } from "@/components/tool/lab-canvas";
8
+ export {
9
+ AuthClientProvider,
10
+ useAuthClient,
11
+ type AuthClient,
12
+ type AuthSession,
13
+ type AuthSessionUser,
14
+ } from "@/lib/auth";
15
+ export {
16
+ BrandMarkProvider,
17
+ DefaultBrandMark,
18
+ useBrandMark,
19
+ type BrandMark,
20
+ type BrandMarkProps,
21
+ } from "@/lib/brand";
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, type ReactNode } from "react";
4
+
5
+ /**
6
+ * The accounts surface the editor uses, injected by the host (the website wires Better Auth; the
7
+ * desktop app and the standalone editor leave it out and run with `accounts={false}`). Keeping it
8
+ * an injected port is what lets this package stay free of any auth implementation.
9
+ */
10
+ export interface AuthSessionUser {
11
+ name?: string | null;
12
+ email: string;
13
+ }
14
+
15
+ export interface AuthSession {
16
+ user: AuthSessionUser;
17
+ }
18
+
19
+ export interface AuthClient {
20
+ /** React hook: the current session (or `null`), and whether it is still loading. */
21
+ useSession: () => { data: AuthSession | null; isPending: boolean };
22
+ signOut: () => Promise<unknown> | void;
23
+ signIn: {
24
+ oauth2: (options: { providerId: string; callbackURL: string; errorCallbackURL: string }) => Promise<unknown> | void;
25
+ };
26
+ }
27
+
28
+ const AuthClientContext = createContext<AuthClient | null>(null);
29
+
30
+ /** Provides the host's auth client to the editor. Omit it (or pass `null`) to run without accounts. */
31
+ export function AuthClientProvider({ client, children }: { client: AuthClient | null; children: ReactNode }) {
32
+ return <AuthClientContext.Provider value={client}>{children}</AuthClientContext.Provider>;
33
+ }
34
+
35
+ /**
36
+ * The injected auth client. It is present only where accounts are enabled; the editor reads it
37
+ * solely inside `accounts`-gated UI, so a missing client there is a wiring error, not a normal path.
38
+ */
39
+ export function useAuthClient(): AuthClient {
40
+ const client = useContext(AuthClientContext);
41
+ if (!client) throw new Error("Accounts are enabled but no AuthClient was provided to the editor.");
42
+ return client;
43
+ }
@@ -0,0 +1,46 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, type ReactNode } from "react";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ /**
7
+ * The product mark the empty canvas shows. It is a port so a host can inject its own mark while the
8
+ * package stays brand-neutral; the default below is the Isothrum mark (the geometry is shared with
9
+ * the website's favicon and OG images), themed by the host's `--primary` tokens.
10
+ */
11
+ export interface BrandMarkProps {
12
+ className?: string;
13
+ }
14
+
15
+ export type BrandMark = (props: BrandMarkProps) => ReactNode;
16
+
17
+ const logoMark = {
18
+ viewBox: "0 0 32 32",
19
+ radius: 9,
20
+ fill: "M7.2 8.1 14.6 7.8 14.4 15.3 7 15.5Z M17.6 16.6 25.1 16.3 24.9 24 17.4 23.8Z",
21
+ stroke: "M14.6 11.6C16.8 11.3 19.4 11.9 21.2 11.7 21.5 13.2 21.1 14.8 21.3 16.4",
22
+ strokeWidth: 2.25,
23
+ } as const;
24
+
25
+ /** The default Isothrum mark, themed by `--primary`/`--primary-foreground`. */
26
+ export function DefaultBrandMark({ className }: BrandMarkProps) {
27
+ return (
28
+ <svg viewBox={logoMark.viewBox} aria-hidden className={cn("size-7 shrink-0", className)}>
29
+ <rect width="32" height="32" rx={logoMark.radius} className="fill-primary" />
30
+ <path d={logoMark.fill} strokeWidth={1} strokeLinejoin="round" className="fill-primary-foreground stroke-primary-foreground" />
31
+ <path d={logoMark.stroke} fill="none" strokeWidth={logoMark.strokeWidth} strokeLinecap="round" strokeLinejoin="round" className="stroke-primary-foreground" />
32
+ </svg>
33
+ );
34
+ }
35
+
36
+ const BrandMarkContext = createContext<BrandMark>(DefaultBrandMark);
37
+
38
+ /** Lets a host replace the editor's product mark. Omit it to keep the default Isothrum mark. */
39
+ export function BrandMarkProvider({ mark, children }: { mark: BrandMark; children: ReactNode }) {
40
+ return <BrandMarkContext.Provider value={mark}>{children}</BrandMarkContext.Provider>;
41
+ }
42
+
43
+ /** The product mark in use (the injected one, or the default). */
44
+ export function useBrandMark(): BrandMark {
45
+ return useContext(BrandMarkContext);
46
+ }
@@ -0,0 +1,53 @@
1
+ export const LOCALES = ["en", "fr", "es", "de"] as const;
2
+ export const DEFAULT_LOCALE = "en" as const;
3
+
4
+ export type Locale = (typeof LOCALES)[number];
5
+
6
+ /** Whether `value` names one of the site's locales. */
7
+ export function hasLocale(value: string): value is Locale {
8
+ return (LOCALES as readonly string[]).includes(value);
9
+ }
10
+
11
+ /** Every locale but `locale`. */
12
+ export function otherLocales(locale: string): Locale[] {
13
+ return LOCALES.filter((l) => l !== locale);
14
+ }
15
+
16
+ export const localeNames: Record<Locale, string> = {
17
+ en: "English",
18
+ fr: "Français",
19
+ es: "Español",
20
+ de: "Deutsch",
21
+ };
22
+
23
+ /**
24
+ * Replace (or insert) the locale segment in a pathname.
25
+ * "/en/blog/foo" + "fr" → "/fr/blog/foo"
26
+ * "/blog/foo" + "fr" → "/fr/blog/foo"
27
+ */
28
+ export function withLocale(pathname: string, locale: Locale): string {
29
+ const segments = pathname.split("/").filter(Boolean);
30
+ if (segments.length && hasLocale(segments[0])) {
31
+ segments[0] = locale;
32
+ } else {
33
+ segments.unshift(locale);
34
+ }
35
+ return "/" + segments.join("/");
36
+ }
37
+
38
+ /**
39
+ * Build the absolute-URL hreflang map for a path that exists in every locale.
40
+ * Useful for static pages (homepage, /blog index, etc).
41
+ */
42
+ export function hreflangFor(siteUrl: string, pathWithoutLocale: string) {
43
+ const base = siteUrl.replace(/\/$/, "");
44
+ const path = pathWithoutLocale.startsWith("/")
45
+ ? pathWithoutLocale
46
+ : "/" + pathWithoutLocale;
47
+ const map: Record<string, string> = {};
48
+ for (const l of LOCALES) {
49
+ map[l] = `${base}/${l}${path === "/" ? "" : path}`;
50
+ }
51
+ map["x-default"] = `${base}/${DEFAULT_LOCALE}${path === "/" ? "" : path}`;
52
+ return map;
53
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { LAN_TOOL, NOTE_TOOL, SWITCH_TOOL, allPresets, deviceBlocks, hintFor, toolLabel } from "./blocks";
3
+ import { PRESETS, type Preset } from "./presets";
4
+ import { en } from "./text/en";
5
+
6
+ const mine: Preset = { id: "my-1", group: "mine", label: "gateway", prefix: "gateway", data: { ...PRESETS[0].data } };
7
+ const blocks = deviceBlocks(allPresets([mine]), [mine], en);
8
+ const options = (id: string) => blocks.find((b) => b.id === id)?.options ?? [];
9
+
10
+ describe("deviceBlocks", () => {
11
+ test("has the switch, router, firewall, container and VM blocks, then the saved devices", () => {
12
+ expect(blocks.map((b) => b.id)).toEqual(["switch", "router", "firewall", "container", "vm", "mine"]);
13
+ expect(deviceBlocks(PRESETS, [], en).map((b) => b.id)).not.toContain("mine");
14
+ });
15
+
16
+ test("starts the switch block with the switch tool, on key S", () => {
17
+ expect(options("switch").map((o) => [o.tool, o.key])).toEqual([
18
+ [SWITCH_TOOL, "S"],
19
+ ["cisco-iol-l2", null],
20
+ ]);
21
+ });
22
+
23
+ test("names a built-in device in the editor's words, its hint what it runs when the text gives none", () => {
24
+ expect(options("router")[0]).toMatchObject({ tool: "router", label: "Router", hint: en.devices.router.hint, key: "1" });
25
+ expect(options("container")[0]).toMatchObject({ label: "Linux container", hint: "alpine:3.20" });
26
+ expect(options("vm")[0]).toMatchObject({ label: "Linux VM", hint: "debian@12" });
27
+ });
28
+
29
+ test("names a saved device by its name, its hint what it runs", () => {
30
+ expect(options("mine")).toEqual([{ tool: "my-1", label: "gateway", hint: "your device: automatic router", preset: mine, key: null }]);
31
+ });
32
+ });
33
+
34
+ describe("toolLabel", () => {
35
+ test("names the LAN, switch and note tools", () => {
36
+ expect([LAN_TOOL, SWITCH_TOOL, NOTE_TOOL].map((t) => toolLabel(t, PRESETS, en)?.label)).toEqual(["LAN", "Switch", "Note"]);
37
+ });
38
+
39
+ test("names a device by its preset, and nothing when no tool or an unknown one is picked", () => {
40
+ expect(toolLabel("web", PRESETS, en)?.label).toBe("Web server");
41
+ expect([toolLabel(null, PRESETS, en), toolLabel("nope", PRESETS, en)]).toEqual([undefined, undefined]);
42
+ });
43
+
44
+ test("names a device the text doesn't know by its id", () => {
45
+ const odd: Preset = { ...PRESETS[0], id: "odd" };
46
+ expect(toolLabel("odd", [odd], en)).toEqual({ label: "odd", hint: "" });
47
+ });
48
+ });
49
+
50
+ describe("hintFor", () => {
51
+ const state = { hand: false, nodes: 0, edges: 0 };
52
+
53
+ test("says how to place the tool picked", () => {
54
+ expect(hintFor(LAN_TOOL, "LAN", state, en)).toBe(en.hints.lan);
55
+ expect(hintFor(NOTE_TOOL, "Note", state, en)).toBe(en.hints.note);
56
+ expect(hintFor("web", "Web server", state, en)).toBe(en.hints.place("Web server"));
57
+ });
58
+
59
+ test("with no tool: how to move the canvas, then how to cable two machines, then how to select", () => {
60
+ expect(hintFor(null, undefined, { ...state, hand: true }, en)).toBe(en.hints.hand);
61
+ expect(hintFor(null, undefined, { ...state, nodes: 2 }, en)).toBe(en.hints.cable);
62
+ expect(hintFor(null, undefined, { ...state, nodes: 2, edges: 1 }, en)).toBe(en.hints.select);
63
+ });
64
+ });
@@ -0,0 +1,78 @@
1
+ import { PRESETS, shortcutOf, type Preset } from "./presets";
2
+ import type { EditorText } from "./text";
3
+
4
+ /** The toolbar's blocks: a switch, a router, a firewall, a container, a VM, and the saved devices. */
5
+
6
+ /** The switch tool's id in the toolbar (devices use their preset ids). */
7
+ export const SWITCH_TOOL = "switch";
8
+ /** The LAN tool's id in the toolbar. */
9
+ export const LAN_TOOL = "lan";
10
+ export const NOTE_TOOL = "note";
11
+
12
+ /** One kind a block places: a preset (a device), or the switch tool. */
13
+ export interface BlockOption {
14
+ tool: string;
15
+ label: string;
16
+ hint: string;
17
+ preset?: Preset;
18
+ key?: string | null;
19
+ }
20
+ export interface Block {
21
+ id: string;
22
+ label: string;
23
+ options: BlockOption[];
24
+ }
25
+
26
+ /** What a device runs: its OS (a VM), or its image. */
27
+ const runs = (p: Preset) => (p.data.kind === "vm" ? p.data.os : p.data.image);
28
+
29
+ /** A device's name and hint in the toolbar: the built-in ones by id (the hint, unless the text gives one, is what it runs), a saved one by its name. */
30
+ function presetText(p: Preset, t: EditorText): { label: string; hint: string } {
31
+ if (p.group !== "mine") {
32
+ const d = (t.devices as Partial<Record<string, { label: string; hint?: string }>>)[p.id];
33
+ return d ? { label: d.label, hint: d.hint ?? runs(p) } : { label: p.id, hint: "" };
34
+ }
35
+ const what = p.data.kind === "vm" ? p.data.os : p.data.kind === "router" ? t.myDeviceRouter : p.data.image;
36
+ return { label: p.label ?? p.prefix, hint: t.myDevice(what) };
37
+ }
38
+
39
+ /** The blocks of the toolbar, each listing the kinds it places (the switch block starts with the switch tool). */
40
+ export function deviceBlocks(presets: Preset[], mine: Preset[], t: EditorText): Block[] {
41
+ const kinds = (ids: string[]) =>
42
+ ids.flatMap((id): BlockOption[] => {
43
+ const p = presets.find((x) => x.id === id);
44
+ return p ? [{ tool: p.id, ...presetText(p, t), preset: p, key: shortcutOf(p.id) }] : [];
45
+ });
46
+ const switchTool: BlockOption = { tool: SWITCH_TOOL, label: t.toolbar.switch, hint: t.toolbar.switchTitle, key: "S" };
47
+ const blocks: Block[] = [
48
+ { id: "switch", label: t.toolbar.blocks.switch, options: [switchTool, ...kinds(["cisco-iol-l2"])] },
49
+ { id: "router", label: t.toolbar.blocks.router, options: kinds(["router", "router-frr", "cisco-iol"]) },
50
+ { id: "firewall", label: t.toolbar.blocks.firewall, options: kinds(["firewall"]) },
51
+ { id: "container", label: t.toolbar.blocks.container, options: kinds(["container", "web", "database", "desktop"]) },
52
+ { id: "vm", label: t.toolbar.blocks.vm, options: kinds(["linux-vm", "ubuntu-vm", "kali-vm", "windows-vm", "workstation"]) },
53
+ ];
54
+ if (mine.length) blocks.push({ id: "mine", label: t.toolbar.blocks.mine, options: kinds(mine.map((d) => d.id)) });
55
+ return blocks;
56
+ }
57
+
58
+ /** What the tool being placed is called, if any (a LAN, a switch, a note, or a device). */
59
+ export function toolLabel(tool: string | null, presets: Preset[], t: EditorText): { label: string } | undefined {
60
+ if (tool === LAN_TOOL) return { label: t.toolbar.lan };
61
+ if (tool === SWITCH_TOOL) return { label: t.toolbar.switch };
62
+ if (tool === NOTE_TOOL) return { label: t.toolbar.note };
63
+ const picked = presets.find((p) => p.id === tool);
64
+ return picked && presetText(picked, t);
65
+ }
66
+
67
+ /** Every device the toolbar can place: the built-in ones, then the saved ones. */
68
+ export const allPresets = (mine: Preset[]): Preset[] => [...PRESETS, ...mine];
69
+
70
+ /** The line under the toolbar: what a click does now. */
71
+ export function hintFor(tool: string | null, label: string | undefined, state: { hand: boolean; nodes: number; edges: number }, t: EditorText): string {
72
+ if (label !== undefined) {
73
+ if (tool === LAN_TOOL) return t.hints.lan;
74
+ return tool === NOTE_TOOL ? t.hints.note : t.hints.place(label);
75
+ }
76
+ if (state.hand) return t.hints.hand;
77
+ return state.nodes >= 2 && state.edges === 0 ? t.hints.cable : t.hints.select;
78
+ }
@@ -0,0 +1,74 @@
1
+ import type { Connection } from "@xyflow/react";
2
+ import { describe, expect, test } from "vitest";
3
+ import { type LabIndex, canConnect, newCable, onLanEdge, reconnected } from "./cables";
4
+ import { autoRouter, cable, lan, machine } from "@/test/lab";
5
+
6
+ const index: LabIndex = { lanIds: new Set(["office", "dmz"]), switchIds: new Set(["sw1", "sw2"]), routerIds: new Set(["frr"]) };
7
+ const link = (source: string, target: string, sourceHandle: string | null = null, targetHandle: string | null = null): Connection => ({ source, target, sourceHandle, targetHandle });
8
+ /** Every machine's first port is eth0. */
9
+ const fromZero = () => 0;
10
+
11
+ describe("canConnect", () => {
12
+ test("joins a machine to a machine, a LAN or a port, and two switches", () => {
13
+ expect([canConnect(link("a", "b"), index, []), canConnect(link("a", "office"), index, []), canConnect(link("sw1", "sw2"), index, [])]).toEqual([true, true, true]);
14
+ });
15
+
16
+ test("never joins a node to itself, a LAN to a LAN or a switch to a LAN", () => {
17
+ expect([canConnect(link("a", "a"), index, []), canConnect(link("office", "dmz"), index, []), canConnect(link("sw1", "office"), index, [])]).toEqual([false, false, false]);
18
+ });
19
+
20
+ test("a switch or router port takes one cable, unless that cable is the one moving", () => {
21
+ const edges = [cable("c1", "a", "sw1", { targetHandle: "p1" }), cable("c2", "frr", "b", { sourceHandle: "eth0" })];
22
+ expect(canConnect(link("x", "sw1", null, "p1"), index, edges)).toBe(false);
23
+ expect(canConnect(link("frr", "y", "eth0"), index, edges)).toBe(false);
24
+ expect(canConnect(link("x", "sw1", null, "p1"), index, edges, "c1")).toBe(true);
25
+ });
26
+ });
27
+
28
+ test("onLanEdge is true of an appliance on a LAN's border only", () => {
29
+ const office = lan("office");
30
+ const nodes = [office, autoRouter("r", { x: 340, y: 50 }), machine("web", {}, { x: 340, y: 50 })];
31
+ expect([onLanEdge("r", nodes, [office]), onLanEdge("web", nodes, [office]), onLanEdge("nope", nodes, [office])]).toEqual([true, false, false]);
32
+ });
33
+
34
+ describe("newCable", () => {
35
+ const edges = [cable("c1", "a", "b", { aPort: "eth0", bPort: "eth0" })];
36
+
37
+ test("labels a machine's end with its next free port", () => {
38
+ expect(newCable(link("a", "b"), index, edges, fromZero).data).toEqual({ aPort: "eth1", bPort: "eth1" });
39
+ });
40
+
41
+ test("labels a LAN's end with nothing, a switch's with the port, a router's with its port", () => {
42
+ expect(newCable(link("office", "sw1", null, "p3"), index, [], fromZero).data).toEqual({ aPort: "", bPort: "3" });
43
+ expect(newCable(link("frr", "a", "eth2"), index, edges, fromZero).data).toEqual({ aPort: "eth2", bPort: "eth1" });
44
+ });
45
+
46
+ test("gives a machine's first port from where it is told to start", () => {
47
+ expect(newCable(link("r", "a"), index, [], (n) => (n === "r" ? 1 : 0)).data).toEqual({ aPort: "eth1", bPort: "eth0" });
48
+ });
49
+
50
+ test("plugs into the handles of the connection, under a new id", () => {
51
+ const c = newCable(link("a", "sw1", "bottom", "p2"), index, [], fromZero);
52
+ expect(c).toMatchObject({ type: "cable", source: "a", sourceHandle: "bottom", target: "sw1", targetHandle: "p2" });
53
+ expect(c.id).toMatch(/^c-/);
54
+ });
55
+ });
56
+
57
+ describe("reconnected", () => {
58
+ const old = cable("c1", "a", "b", { aPort: "eth3", bPort: "eth0" });
59
+ const edges = [old, cable("c2", "d", "x", { aPort: "eth0", bPort: "eth0" })];
60
+
61
+ test("an end that stays on its machine keeps its port", () => {
62
+ const moved = reconnected(edges, old, link("a", "d", "bottom"), index, fromZero);
63
+ expect(moved[0]).toMatchObject({ source: "a", sourceHandle: "bottom", target: "d", data: { aPort: "eth3", bPort: "eth1" } });
64
+ });
65
+
66
+ test("an end moved onto a switch or a LAN takes its label there", () => {
67
+ const moved = reconnected(edges, old, link("a", "sw1", null, "p4"), index, fromZero);
68
+ expect(moved[0].data).toEqual({ aPort: "eth3", bPort: "4" });
69
+ });
70
+
71
+ test("leaves the other cables as they are", () => {
72
+ expect(reconnected(edges, old, link("a", "office"), index, fromZero)[1]).toBe(edges[1]);
73
+ });
74
+ });
@@ -0,0 +1,75 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { machineEnds, migrateEdges, networkOfCable, nextPort } from "./cables";
3
+ import { autoRouter, cable, lan, machine, realRouter, switchNode } from "@/test/lab";
4
+
5
+ describe("migrateEdges", () => {
6
+ const nodes = [machine("web"), autoRouter("r"), realRouter("frr"), lan("office"), switchNode("sw1")];
7
+
8
+ test("moves a machine's cable onto its top, or its bottom when it was there", () => {
9
+ const edges = migrateEdges([cable("c1", "web", "office", { sourceHandle: "left", targetHandle: "right" }), cable("c2", "office", "web", { targetHandle: "bottom" })], nodes);
10
+ expect(edges.map((e) => [e.sourceHandle, e.targetHandle])).toEqual([
11
+ ["top", "right"],
12
+ [null, "bottom"],
13
+ ]);
14
+ });
15
+
16
+ test("moves an appliance's cable onto its single port", () => {
17
+ expect(migrateEdges([cable("c", "r", "office", { sourceHandle: "right" })], nodes)[0].sourceHandle).toBe("port");
18
+ });
19
+
20
+ test("keeps a real router's port, and gives a cable on none the first free one", () => {
21
+ const edges = migrateEdges([cable("c1", "frr", "office", { sourceHandle: "eth0" }), cable("c2", "sw1", "frr", { sourceHandle: "p1", targetHandle: "top" })], nodes);
22
+ expect(edges.map((e) => [e.sourceHandle, e.targetHandle])).toEqual([
23
+ ["eth0", null],
24
+ ["p1", "eth1"],
25
+ ]);
26
+ });
27
+ });
28
+
29
+ describe("nextPort", () => {
30
+ const edges = [cable("c1", "web", "db", { aPort: "eth0", bPort: "eth0" }), cable("c2", "fw", "web", { aPort: "eth0", bPort: "eth1" })];
31
+
32
+ test("gives the first eth<n> no cable of the machine uses, at either end", () => {
33
+ expect(nextPort("web", edges)).toBe("eth2");
34
+ expect(nextPort("db", edges)).toBe("eth1");
35
+ expect(nextPort("new", edges)).toBe("eth0");
36
+ });
37
+
38
+ test("starts from the port it is given (eth0 taken on a LAN's edge)", () => {
39
+ expect(nextPort("new", edges, 1)).toBe("eth1");
40
+ });
41
+ });
42
+
43
+ describe("networkOfCable", () => {
44
+ const nodes = [lan("office"), switchNode("sw1"), machine("a"), machine("b"), machine("c"), machine("x", { name: "" })];
45
+ const edges = [
46
+ cable("toLan", "office", "a"),
47
+ cable("access", "a", "sw1", { targetHandle: "p3" }),
48
+ cable("trunk", "sw1", "b", { sourceHandle: "trunk" }),
49
+ cable("free", "c", "sw1", { targetHandle: "p6" }),
50
+ cable("ab", "a", "b"),
51
+ cable("toNameless", "a", "x"),
52
+ cable("bc", "b", "c"),
53
+ ];
54
+ const net = (id: string) => networkOfCable(id, nodes, edges);
55
+
56
+ test("a cable to a LAN is that LAN", () => {
57
+ expect(net("toLan")).toBe("office");
58
+ });
59
+
60
+ test("a cable on a switch's access port is its VLAN; on a trunk or a free port, no one network", () => {
61
+ expect([net("access"), net("trunk"), net("free")]).toEqual(["vlan20", null, null]);
62
+ });
63
+
64
+ test("cables between named machines are links numbered in order", () => {
65
+ expect([net("ab"), net("bc")]).toEqual(["link-0", "link-1"]);
66
+ });
67
+
68
+ test("a cable to a nameless machine, or an unknown cable, is no network", () => {
69
+ expect([net("toNameless"), net("nope")]).toEqual([null, null]);
70
+ });
71
+ });
72
+
73
+ test("machineEnds names the ends of a cable that are machines", () => {
74
+ expect(machineEnds(cable("c", "web", "office"), [machine("web"), lan("office")])).toEqual(["web"]);
75
+ });