@treenity/react 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (297) hide show
  1. package/dist/AclEditor.d.ts +11 -0
  2. package/dist/AclEditor.d.ts.map +1 -0
  3. package/dist/AclEditor.js +152 -0
  4. package/dist/AclEditor.js.map +1 -0
  5. package/dist/App.d.ts +2 -0
  6. package/dist/App.d.ts.map +1 -0
  7. package/dist/App.js +521 -0
  8. package/dist/App.js.map +1 -0
  9. package/dist/Inspector.d.ts +12 -0
  10. package/dist/Inspector.d.ts.map +1 -0
  11. package/dist/Inspector.js +360 -0
  12. package/dist/Inspector.js.map +1 -0
  13. package/dist/Tree.d.ts +16 -0
  14. package/dist/Tree.d.ts.map +1 -0
  15. package/dist/Tree.js +100 -0
  16. package/dist/Tree.js.map +1 -0
  17. package/dist/ViewPage.d.ts +5 -0
  18. package/dist/ViewPage.d.ts.map +1 -0
  19. package/dist/ViewPage.js +13 -0
  20. package/dist/ViewPage.js.map +1 -0
  21. package/dist/bind/computed.d.ts +9 -0
  22. package/dist/bind/computed.d.ts.map +1 -0
  23. package/dist/bind/computed.js +61 -0
  24. package/dist/bind/computed.js.map +1 -0
  25. package/dist/bind/engine.d.ts +3 -0
  26. package/dist/bind/engine.d.ts.map +1 -0
  27. package/dist/bind/engine.js +184 -0
  28. package/dist/bind/engine.js.map +1 -0
  29. package/dist/bind/eval.d.ts +13 -0
  30. package/dist/bind/eval.d.ts.map +1 -0
  31. package/dist/bind/eval.js +97 -0
  32. package/dist/bind/eval.js.map +1 -0
  33. package/dist/bind/hook.d.ts +8 -0
  34. package/dist/bind/hook.d.ts.map +1 -0
  35. package/dist/bind/hook.js +99 -0
  36. package/dist/bind/hook.js.map +1 -0
  37. package/dist/bind/parse.d.ts +19 -0
  38. package/dist/bind/parse.d.ts.map +1 -0
  39. package/dist/bind/parse.js +86 -0
  40. package/dist/bind/parse.js.map +1 -0
  41. package/dist/bind/pipes.d.ts +4 -0
  42. package/dist/bind/pipes.d.ts.map +1 -0
  43. package/dist/bind/pipes.js +43 -0
  44. package/dist/bind/pipes.js.map +1 -0
  45. package/dist/cache.d.ts +27 -0
  46. package/dist/cache.d.ts.map +1 -0
  47. package/dist/cache.js +236 -0
  48. package/dist/cache.js.map +1 -0
  49. package/dist/client-tree.d.ts +9 -0
  50. package/dist/client-tree.d.ts.map +1 -0
  51. package/dist/client-tree.js +14 -0
  52. package/dist/client-tree.js.map +1 -0
  53. package/dist/client.d.ts +2 -0
  54. package/dist/client.d.ts.map +1 -0
  55. package/dist/client.js +10 -0
  56. package/dist/client.js.map +1 -0
  57. package/dist/components/ui/accordion.d.ts +8 -0
  58. package/dist/components/ui/accordion.d.ts.map +1 -0
  59. package/dist/components/ui/accordion.js +18 -0
  60. package/dist/components/ui/accordion.js.map +1 -0
  61. package/dist/components/ui/badge.d.ts +10 -0
  62. package/dist/components/ui/badge.d.ts.map +1 -0
  63. package/dist/components/ui/badge.js +19 -0
  64. package/dist/components/ui/badge.js.map +1 -0
  65. package/dist/components/ui/button.d.ts +11 -0
  66. package/dist/components/ui/button.d.ts.map +1 -0
  67. package/dist/components/ui/button.js +31 -0
  68. package/dist/components/ui/button.js.map +1 -0
  69. package/dist/components/ui/checkbox.d.ts +4 -0
  70. package/dist/components/ui/checkbox.d.ts.map +1 -0
  71. package/dist/components/ui/checkbox.js +7 -0
  72. package/dist/components/ui/checkbox.js.map +1 -0
  73. package/dist/components/ui/dialog.d.ts +18 -0
  74. package/dist/components/ui/dialog.d.ts.map +1 -0
  75. package/dist/components/ui/dialog.js +37 -0
  76. package/dist/components/ui/dialog.js.map +1 -0
  77. package/dist/components/ui/drawer.d.ts +14 -0
  78. package/dist/components/ui/drawer.d.ts.map +1 -0
  79. package/dist/components/ui/drawer.js +35 -0
  80. package/dist/components/ui/drawer.js.map +1 -0
  81. package/dist/components/ui/input.d.ts +4 -0
  82. package/dist/components/ui/input.d.ts.map +1 -0
  83. package/dist/components/ui/input.js +7 -0
  84. package/dist/components/ui/input.js.map +1 -0
  85. package/dist/components/ui/label.d.ts +5 -0
  86. package/dist/components/ui/label.d.ts.map +1 -0
  87. package/dist/components/ui/label.js +8 -0
  88. package/dist/components/ui/label.js.map +1 -0
  89. package/dist/components/ui/popover.d.ts +11 -0
  90. package/dist/components/ui/popover.d.ts.map +1 -0
  91. package/dist/components/ui/popover.js +26 -0
  92. package/dist/components/ui/popover.js.map +1 -0
  93. package/dist/components/ui/progress.d.ts +5 -0
  94. package/dist/components/ui/progress.d.ts.map +1 -0
  95. package/dist/components/ui/progress.js +9 -0
  96. package/dist/components/ui/progress.js.map +1 -0
  97. package/dist/components/ui/select.d.ts +16 -0
  98. package/dist/components/ui/select.d.ts.map +1 -0
  99. package/dist/components/ui/select.js +39 -0
  100. package/dist/components/ui/select.js.map +1 -0
  101. package/dist/components/ui/slider.d.ts +5 -0
  102. package/dist/components/ui/slider.d.ts.map +1 -0
  103. package/dist/components/ui/slider.js +15 -0
  104. package/dist/components/ui/slider.js.map +1 -0
  105. package/dist/components/ui/sonner.d.ts +4 -0
  106. package/dist/components/ui/sonner.d.ts.map +1 -0
  107. package/dist/components/ui/sonner.js +21 -0
  108. package/dist/components/ui/sonner.js.map +1 -0
  109. package/dist/components/ui/switch.d.ts +7 -0
  110. package/dist/components/ui/switch.d.ts.map +1 -0
  111. package/dist/components/ui/switch.js +9 -0
  112. package/dist/components/ui/switch.js.map +1 -0
  113. package/dist/components/ui/textarea.d.ts +4 -0
  114. package/dist/components/ui/textarea.d.ts.map +1 -0
  115. package/dist/components/ui/textarea.js +7 -0
  116. package/dist/components/ui/textarea.js.map +1 -0
  117. package/dist/components/ui/tooltip.d.ts +8 -0
  118. package/dist/components/ui/tooltip.d.ts.map +1 -0
  119. package/dist/components/ui/tooltip.js +18 -0
  120. package/dist/components/ui/tooltip.js.map +1 -0
  121. package/dist/context/index.d.ts +31 -0
  122. package/dist/context/index.d.ts.map +1 -0
  123. package/dist/context/index.js +98 -0
  124. package/dist/context/index.js.map +1 -0
  125. package/dist/context.d.ts +2 -0
  126. package/dist/context.d.ts.map +1 -0
  127. package/dist/context.js +2 -0
  128. package/dist/context.js.map +1 -0
  129. package/dist/hooks.d.ts +21 -0
  130. package/dist/hooks.d.ts.map +1 -0
  131. package/dist/hooks.js +156 -0
  132. package/dist/hooks.js.map +1 -0
  133. package/dist/idb.d.ts +13 -0
  134. package/dist/idb.d.ts.map +1 -0
  135. package/dist/idb.js +67 -0
  136. package/dist/idb.js.map +1 -0
  137. package/dist/lib/minimd.d.ts +3 -0
  138. package/dist/lib/minimd.d.ts.map +1 -0
  139. package/dist/lib/minimd.js +97 -0
  140. package/dist/lib/minimd.js.map +1 -0
  141. package/dist/lib/utils.d.ts +3 -0
  142. package/dist/lib/utils.d.ts.map +1 -0
  143. package/dist/lib/utils.js +6 -0
  144. package/dist/lib/utils.js.map +1 -0
  145. package/dist/load-client.d.ts +2 -0
  146. package/dist/load-client.d.ts.map +1 -0
  147. package/dist/load-client.js +6 -0
  148. package/dist/load-client.js.map +1 -0
  149. package/dist/main.d.ts +4 -0
  150. package/dist/main.d.ts.map +1 -0
  151. package/dist/main.js +16 -0
  152. package/dist/main.js.map +1 -0
  153. package/dist/mods/editor-ui/client.d.ts +6 -0
  154. package/dist/mods/editor-ui/client.d.ts.map +1 -0
  155. package/dist/mods/editor-ui/client.js +8 -0
  156. package/dist/mods/editor-ui/client.js.map +1 -0
  157. package/dist/mods/editor-ui/default-view.d.ts +2 -0
  158. package/dist/mods/editor-ui/default-view.d.ts.map +1 -0
  159. package/dist/mods/editor-ui/default-view.js +71 -0
  160. package/dist/mods/editor-ui/default-view.js.map +1 -0
  161. package/dist/mods/editor-ui/dir-view.d.ts +2 -0
  162. package/dist/mods/editor-ui/dir-view.d.ts.map +1 -0
  163. package/dist/mods/editor-ui/dir-view.js +42 -0
  164. package/dist/mods/editor-ui/dir-view.js.map +1 -0
  165. package/dist/mods/editor-ui/form-fields.d.ts +6 -0
  166. package/dist/mods/editor-ui/form-fields.d.ts.map +1 -0
  167. package/dist/mods/editor-ui/form-fields.js +401 -0
  168. package/dist/mods/editor-ui/form-fields.js.map +1 -0
  169. package/dist/mods/editor-ui/layout-view.d.ts +2 -0
  170. package/dist/mods/editor-ui/layout-view.d.ts.map +1 -0
  171. package/dist/mods/editor-ui/layout-view.js +22 -0
  172. package/dist/mods/editor-ui/layout-view.js.map +1 -0
  173. package/dist/mods/editor-ui/list-items.d.ts +2 -0
  174. package/dist/mods/editor-ui/list-items.d.ts.map +1 -0
  175. package/dist/mods/editor-ui/list-items.js +38 -0
  176. package/dist/mods/editor-ui/list-items.js.map +1 -0
  177. package/dist/mods/editor-ui/node-utils.d.ts +10 -0
  178. package/dist/mods/editor-ui/node-utils.d.ts.map +1 -0
  179. package/dist/mods/editor-ui/node-utils.js +76 -0
  180. package/dist/mods/editor-ui/node-utils.js.map +1 -0
  181. package/dist/mods/editor-ui/user-view.d.ts +2 -0
  182. package/dist/mods/editor-ui/user-view.d.ts.map +1 -0
  183. package/dist/mods/editor-ui/user-view.js +47 -0
  184. package/dist/mods/editor-ui/user-view.js.map +1 -0
  185. package/dist/mods/treenity/client.d.ts +4 -0
  186. package/dist/mods/treenity/client.d.ts.map +1 -0
  187. package/dist/mods/treenity/client.js +6 -0
  188. package/dist/mods/treenity/client.js.map +1 -0
  189. package/dist/mods/treenity/groups/index.d.ts +2 -0
  190. package/dist/mods/treenity/groups/index.d.ts.map +1 -0
  191. package/dist/mods/treenity/groups/index.js +27 -0
  192. package/dist/mods/treenity/groups/index.js.map +1 -0
  193. package/dist/mods/treenity/preview.d.ts +6 -0
  194. package/dist/mods/treenity/preview.d.ts.map +1 -0
  195. package/dist/mods/treenity/preview.js +95 -0
  196. package/dist/mods/treenity/preview.js.map +1 -0
  197. package/dist/mods/treenity/ref-view.d.ts +2 -0
  198. package/dist/mods/treenity/ref-view.d.ts.map +1 -0
  199. package/dist/mods/treenity/ref-view.js +29 -0
  200. package/dist/mods/treenity/ref-view.js.map +1 -0
  201. package/dist/mods/treenity/schema-form.d.ts +2 -0
  202. package/dist/mods/treenity/schema-form.d.ts.map +1 -0
  203. package/dist/mods/treenity/schema-form.js +38 -0
  204. package/dist/mods/treenity/schema-form.js.map +1 -0
  205. package/dist/mods/treenity/seed.d.ts +2 -0
  206. package/dist/mods/treenity/seed.d.ts.map +1 -0
  207. package/dist/mods/treenity/seed.js +53 -0
  208. package/dist/mods/treenity/seed.js.map +1 -0
  209. package/dist/mods/treenity/server.d.ts +2 -0
  210. package/dist/mods/treenity/server.d.ts.map +1 -0
  211. package/dist/mods/treenity/server.js +2 -0
  212. package/dist/mods/treenity/server.js.map +1 -0
  213. package/dist/mods/treenity/type-view.d.ts +2 -0
  214. package/dist/mods/treenity/type-view.d.ts.map +1 -0
  215. package/dist/mods/treenity/type-view.js +36 -0
  216. package/dist/mods/treenity/type-view.js.map +1 -0
  217. package/dist/remote-tree.d.ts +6 -0
  218. package/dist/remote-tree.d.ts.map +1 -0
  219. package/dist/remote-tree.js +18 -0
  220. package/dist/remote-tree.js.map +1 -0
  221. package/dist/schema-loader.d.ts +19 -0
  222. package/dist/schema-loader.d.ts.map +1 -0
  223. package/dist/schema-loader.js +63 -0
  224. package/dist/schema-loader.js.map +1 -0
  225. package/dist/trpc.d.ts +187 -0
  226. package/dist/trpc.d.ts.map +1 -0
  227. package/dist/trpc.js +21 -0
  228. package/dist/trpc.js.map +1 -0
  229. package/package.json +88 -0
  230. package/src/AclEditor.tsx +330 -0
  231. package/src/App.tsx +775 -0
  232. package/src/CLAUDE.md +16 -0
  233. package/src/Inspector.tsx +857 -0
  234. package/src/Tree.tsx +237 -0
  235. package/src/ViewPage.tsx +45 -0
  236. package/src/bind/bind.test.ts +316 -0
  237. package/src/bind/computed.ts +64 -0
  238. package/src/bind/engine.ts +198 -0
  239. package/src/bind/eval.ts +108 -0
  240. package/src/bind/hook.ts +112 -0
  241. package/src/bind/parse.ts +104 -0
  242. package/src/bind/pipes.ts +71 -0
  243. package/src/cache.test.ts +139 -0
  244. package/src/cache.ts +244 -0
  245. package/src/client-tree.test.ts +116 -0
  246. package/src/client-tree.ts +24 -0
  247. package/src/client.ts +11 -0
  248. package/src/components/ui/accordion.tsx +63 -0
  249. package/src/components/ui/badge.tsx +27 -0
  250. package/src/components/ui/button.tsx +44 -0
  251. package/src/components/ui/checkbox.tsx +19 -0
  252. package/src/components/ui/dialog.tsx +156 -0
  253. package/src/components/ui/drawer.tsx +132 -0
  254. package/src/components/ui/input.tsx +19 -0
  255. package/src/components/ui/label.tsx +21 -0
  256. package/src/components/ui/popover.tsx +86 -0
  257. package/src/components/ui/progress.tsx +30 -0
  258. package/src/components/ui/select.tsx +189 -0
  259. package/src/components/ui/slider.tsx +62 -0
  260. package/src/components/ui/sonner.tsx +32 -0
  261. package/src/components/ui/switch.tsx +34 -0
  262. package/src/components/ui/textarea.tsx +17 -0
  263. package/src/components/ui/tooltip.tsx +56 -0
  264. package/src/context/index.tsx +131 -0
  265. package/src/context.ts +1 -0
  266. package/src/hooks.ts +208 -0
  267. package/src/idb.ts +80 -0
  268. package/src/index.html +14 -0
  269. package/src/lib/minimd.css +28 -0
  270. package/src/lib/minimd.ts +95 -0
  271. package/src/lib/utils.ts +6 -0
  272. package/src/load-client.ts +5 -0
  273. package/src/main.tsx +22 -0
  274. package/src/mods/editor-ui/CLAUDE.md +3 -0
  275. package/src/mods/editor-ui/client.ts +8 -0
  276. package/src/mods/editor-ui/default-view.tsx +148 -0
  277. package/src/mods/editor-ui/dir-view.tsx +91 -0
  278. package/src/mods/editor-ui/form-fields.tsx +861 -0
  279. package/src/mods/editor-ui/layout-view.tsx +62 -0
  280. package/src/mods/editor-ui/list-items.tsx +63 -0
  281. package/src/mods/editor-ui/node-utils.ts +84 -0
  282. package/src/mods/editor-ui/user-view.tsx +101 -0
  283. package/src/mods/treenity/CLAUDE.md +7 -0
  284. package/src/mods/treenity/client.ts +6 -0
  285. package/src/mods/treenity/groups/index.tsx +65 -0
  286. package/src/mods/treenity/preview.tsx +133 -0
  287. package/src/mods/treenity/ref-view.tsx +87 -0
  288. package/src/mods/treenity/schema-form.tsx +65 -0
  289. package/src/mods/treenity/seed.ts +56 -0
  290. package/src/mods/treenity/server.ts +1 -0
  291. package/src/mods/treenity/type-view.tsx +116 -0
  292. package/src/remote-tree.test.ts +142 -0
  293. package/src/remote-tree.ts +25 -0
  294. package/src/schema-loader.ts +84 -0
  295. package/src/style.css +1269 -0
  296. package/src/trpc.ts +27 -0
  297. package/src/vite-env.d.ts +3 -0
package/src/Tree.tsx ADDED
@@ -0,0 +1,237 @@
1
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
2
+ import * as cache from './cache';
3
+
4
+ type TreeProps = {
5
+ roots: string[];
6
+ expanded: Set<string>;
7
+ loaded: Set<string>;
8
+ selected: string | null;
9
+ filter: string;
10
+ showHidden: boolean;
11
+ onSelect: (path: string) => void;
12
+ onExpand: (path: string) => void;
13
+ onCreateChild: (parentPath: string) => void;
14
+ onDelete?: (path: string) => void;
15
+ onMove?: (fromPath: string, toPath: string) => void;
16
+ };
17
+
18
+ function nodeName(p: string) {
19
+ return p.slice(p.lastIndexOf('/') + 1) || '/';
20
+ }
21
+
22
+ function matchesFilter(name: string, type: string, filter: string): boolean {
23
+ if (!filter) return true;
24
+ const lf = filter.toLowerCase();
25
+ return name.toLowerCase().includes(lf) || type.toLowerCase().includes(lf);
26
+ }
27
+
28
+ function hasMatchingDescendant(path: string, filter: string): boolean {
29
+ const nodes = cache.raw();
30
+ const prefix = path === '/' ? '/' : path + '/';
31
+ for (const [k, v] of nodes) {
32
+ if (k.startsWith(prefix) && matchesFilter(nodeName(k), v.$type, filter)) return true;
33
+ }
34
+ return false;
35
+ }
36
+
37
+ function BadgeMenu({
38
+ path,
39
+ typeLabel,
40
+ fullType,
41
+ onCreateChild,
42
+ onDelete,
43
+ }: {
44
+ path: string;
45
+ typeLabel: string;
46
+ fullType: string;
47
+ onCreateChild: (path: string) => void;
48
+ onDelete?: (path: string) => void;
49
+ }) {
50
+ const [open, setOpen] = useState(false);
51
+ const ref = useRef<HTMLDivElement>(null);
52
+
53
+ useEffect(() => {
54
+ if (!open) return;
55
+ const close = (e: MouseEvent) => {
56
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
57
+ };
58
+ document.addEventListener('mousedown', close);
59
+ return () => document.removeEventListener('mousedown', close);
60
+ }, [open]);
61
+
62
+ return (
63
+ <div ref={ref} className="tree-badge-wrap">
64
+ <span
65
+ className="tree-badge"
66
+ title={fullType}
67
+ onClick={(e) => {
68
+ e.stopPropagation();
69
+ setOpen(!open);
70
+ }}
71
+ >
72
+ {typeLabel}
73
+ </span>
74
+ {open && (
75
+ <div className="tree-menu">
76
+ <button
77
+ className="tree-menu-item"
78
+ onClick={(e) => {
79
+ e.stopPropagation();
80
+ setOpen(false);
81
+ onCreateChild(path);
82
+ }}
83
+ >
84
+ + Add child
85
+ </button>
86
+ {onDelete && (
87
+ <button
88
+ className="tree-menu-item danger"
89
+ onClick={(e) => {
90
+ e.stopPropagation();
91
+ setOpen(false);
92
+ if (confirm(`Delete ${path}?`)) onDelete(path);
93
+ }}
94
+ >
95
+ × Delete
96
+ </button>
97
+ )}
98
+ </div>
99
+ )}
100
+ </div>
101
+ );
102
+ }
103
+
104
+ function TreeNode({
105
+ path,
106
+ expanded,
107
+ loaded = new Set<string>(),
108
+ selected,
109
+ filter,
110
+ showHidden,
111
+ depth,
112
+ onSelect,
113
+ onExpand,
114
+ onCreateChild,
115
+ onDelete,
116
+ onMove,
117
+ }: Omit<TreeProps, 'roots'> & { path: string; depth: number }) {
118
+ // Granular subscriptions — only re-render when THIS node or ITS children change
119
+ const node = useSyncExternalStore(
120
+ useCallback((cb: () => void) => cache.subscribePath(path, cb), [path]),
121
+ useCallback(() => cache.get(path), [path]),
122
+ );
123
+ const childrenNodes = useSyncExternalStore(
124
+ useCallback((cb: () => void) => cache.subscribeChildren(path, cb), [path]),
125
+ useCallback(() => cache.getChildren(path), [path]),
126
+ );
127
+
128
+ const rowRef = useRef<HTMLDivElement>(null);
129
+ const [dragOver, setDragOver] = useState<'above' | 'below' | null>(null);
130
+
131
+ if (!node) return null;
132
+
133
+ const name = nodeName(path);
134
+ const isExp = expanded.has(path);
135
+ const allChildren = childrenNodes.map(n => n.$path).filter(p => p !== path);
136
+ const knownChildren = showHidden
137
+ ? allChildren
138
+ : allChildren.filter(p => !nodeName(p).startsWith('_'));
139
+ const showChildren = filter ? knownChildren : isExp ? knownChildren : [];
140
+ const filteredChildren = filter
141
+ ? showChildren.filter((c) => {
142
+ const cn = cache.get(c);
143
+ if (!cn) return false;
144
+ return matchesFilter(nodeName(c), cn.$type, filter) || hasMatchingDescendant(c, filter);
145
+ })
146
+ : showChildren;
147
+
148
+ if (filter && !matchesFilter(name, node.$type, filter) && filteredChildren.length === 0) {
149
+ return null;
150
+ }
151
+
152
+ const indent = depth * 12 + 4;
153
+ const typeLabel = node.$type.includes('.') ? node.$type.slice(node.$type.lastIndexOf('.') + 1) : node.$type;
154
+
155
+ return (
156
+ <div className="tree-node">
157
+ <div
158
+ ref={rowRef}
159
+ className={`tree-row${selected === path ? ' selected' : ''}${dragOver === 'above' ? ' tree-drop-above' : ''}${dragOver === 'below' ? ' tree-drop-below' : ''}`}
160
+ style={{ paddingLeft: indent }}
161
+ onClick={() => onSelect(path)}
162
+ draggable
163
+ onDragStart={(e) => {
164
+ e.dataTransfer.setData('text/plain', path);
165
+ e.dataTransfer.setData('application/treenity-path', path);
166
+ e.dataTransfer.effectAllowed = 'all';
167
+ }}
168
+ onDragOver={(e) => {
169
+ e.preventDefault();
170
+ const rect = rowRef.current!.getBoundingClientRect();
171
+ const y = e.clientY - rect.top;
172
+ setDragOver(y < rect.height / 2 ? 'above' : 'below');
173
+ }}
174
+ onDragLeave={() => setDragOver(null)}
175
+ onDrop={(e) => {
176
+ e.preventDefault();
177
+ setDragOver(null);
178
+ const from = e.dataTransfer.getData('text/plain');
179
+ if (from && from !== path && onMove) onMove(from, path);
180
+ }}
181
+ >
182
+ {knownChildren.length > 0 || !loaded.has(path) ? (
183
+ <span
184
+ className="tree-toggle"
185
+ onClick={(e) => {
186
+ e.stopPropagation();
187
+ onExpand(path);
188
+ }}
189
+ >
190
+ {isExp ? '\u25BE' : '\u25B8'}
191
+ </span>
192
+ ) : (
193
+ <span className="tree-toggle" />
194
+ )}
195
+ <span className="tree-label">{name}</span>
196
+ <BadgeMenu
197
+ path={path}
198
+ typeLabel={typeLabel}
199
+ fullType={node.$type}
200
+ onCreateChild={onCreateChild}
201
+ onDelete={onDelete}
202
+ />
203
+ </div>
204
+ {(isExp || filter) && filteredChildren.length > 0 && (
205
+ <div className="tree-children">
206
+ {filteredChildren.map((c) => (
207
+ <TreeNode
208
+ key={c}
209
+ path={c}
210
+ depth={depth + 1}
211
+ expanded={expanded}
212
+ loaded={loaded}
213
+ selected={selected}
214
+ filter={filter}
215
+ showHidden={showHidden}
216
+ onSelect={onSelect}
217
+ onExpand={onExpand}
218
+ onCreateChild={onCreateChild}
219
+ onDelete={onDelete}
220
+ onMove={onMove}
221
+ />
222
+ ))}
223
+ </div>
224
+ )}
225
+ </div>
226
+ );
227
+ }
228
+
229
+ export function Tree(props: TreeProps) {
230
+ return (
231
+ <div>
232
+ {props.roots.map((r) => (
233
+ <TreeNode key={r} path={r} depth={0} {...props} />
234
+ ))}
235
+ </div>
236
+ );
237
+ }
@@ -0,0 +1,45 @@
1
+ // ViewPage — standalone read-only node view at /{path}
2
+
3
+ import { Render, RenderContext } from '#context';
4
+ import { usePath } from './hooks';
5
+
6
+ export function ViewPage({ path, editorLink }: { path: string; editorLink?: boolean }) {
7
+ const node = usePath(path);
8
+
9
+ const name = path === '/' ? '/' : path.slice(path.lastIndexOf('/') + 1);
10
+
11
+ if (!node) {
12
+ return (
13
+ <div className="flex flex-col items-center justify-center h-screen gap-4 text-[--text-3]">
14
+ <div className="text-4xl">404</div>
15
+ <p>Node not found: <span className="font-mono">{path}</span></p>
16
+ {editorLink && <a href={`/t${path}`} className="text-[--accent] hover:underline">Open in editor</a>}
17
+ </div>
18
+ );
19
+ }
20
+
21
+ return (
22
+ <div className="flex flex-col h-screen">
23
+ {editorLink && (
24
+ <div className="flex items-center gap-3 px-4 py-2 border-b border-[--border] bg-[--bg-2]">
25
+ <span className="font-semibold text-sm">{name}</span>
26
+ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-[--accent]/10 text-[--accent]">
27
+ {node.$type}
28
+ </span>
29
+ <span className="flex-1" />
30
+ <a
31
+ href={`/t${path}`}
32
+ className="text-xs text-[--text-3] hover:text-[--text-1] no-underline"
33
+ >
34
+ Open in editor
35
+ </a>
36
+ </div>
37
+ )}
38
+ <div className="flex-1 overflow-auto p-4 has-[.view-full]:p-0">
39
+ <RenderContext name="react">
40
+ <Render value={node} />
41
+ </RenderContext>
42
+ </div>
43
+ </div>
44
+ );
45
+ }
@@ -0,0 +1,316 @@
1
+ import type { NodeData } from '@treenity/core/core';
2
+ import assert from 'node:assert/strict';
3
+ import { describe, it } from 'node:test';
4
+ import { clearComputed, getComputed, setComputed, subscribeComputed } from './computed';
5
+ import { evaluateRef, extractArgPaths, hasOnce, isCollectionRef } from './eval';
6
+ import { isRefArg, parseMapExpr } from './parse';
7
+
8
+ // ── Parser ──
9
+
10
+ describe('parseMapExpr', () => {
11
+ it('parses pipe with field chain', () => {
12
+ const expr = parseMapExpr('last().value | div(5)');
13
+ assert.deepEqual(expr.steps, [
14
+ { type: 'pipe', name: 'last', args: [] },
15
+ { type: 'field', name: 'value' },
16
+ { type: 'pipe', name: 'div', args: [5] },
17
+ ]);
18
+ });
19
+
20
+ it('parses bare pipe (no parens)', () => {
21
+ const expr = parseMapExpr('round');
22
+ assert.deepEqual(expr.steps, [
23
+ { type: 'pipe', name: 'round', args: [] },
24
+ ]);
25
+ });
26
+
27
+ it('parses multiple args', () => {
28
+ const expr = parseMapExpr('clamp(0, 10)');
29
+ assert.deepEqual(expr.steps, [
30
+ { type: 'pipe', name: 'clamp', args: [0, 10] },
31
+ ]);
32
+ });
33
+
34
+ it('parses field-only chain', () => {
35
+ const expr = parseMapExpr('.status');
36
+ assert.deepEqual(expr.steps, [
37
+ { type: 'field', name: 'status' },
38
+ ]);
39
+ });
40
+
41
+ it('parses complex chain', () => {
42
+ const expr = parseMapExpr('last().value | sub(20) | abs | div(10)');
43
+ assert.deepEqual(expr.steps, [
44
+ { type: 'pipe', name: 'last', args: [] },
45
+ { type: 'field', name: 'value' },
46
+ { type: 'pipe', name: 'sub', args: [20] },
47
+ { type: 'pipe', name: 'abs', args: [] },
48
+ { type: 'pipe', name: 'div', args: [10] },
49
+ ]);
50
+ });
51
+
52
+ it('parses map pipe', () => {
53
+ const expr = parseMapExpr('map(value) | avg');
54
+ assert.deepEqual(expr.steps, [
55
+ { type: 'pipe', name: 'map', args: ['value'] },
56
+ { type: 'pipe', name: 'avg', args: [] },
57
+ ]);
58
+ });
59
+
60
+ it('parses #field as step (self lookup)', () => {
61
+ const expr = parseMapExpr('#width | mul(#height)');
62
+ assert.deepEqual(expr.steps, [
63
+ { type: 'field', name: 'width' },
64
+ { type: 'pipe', name: 'mul', args: [{ $ref: '.', fields: ['height'] }] },
65
+ ]);
66
+ });
67
+
68
+ it('parses #/path.field ref arg', () => {
69
+ const expr = parseMapExpr('#price | mul(#/config/tax.rate)');
70
+ const mulStep = expr.steps[1];
71
+ assert.equal(mulStep.type, 'pipe');
72
+ if (mulStep.type === 'pipe') {
73
+ assert.equal(isRefArg(mulStep.args[0]), true);
74
+ assert.deepEqual(mulStep.args[0], { $ref: '/config/tax', fields: ['rate'] });
75
+ }
76
+ });
77
+
78
+ it('parses #/path without field (whole node)', () => {
79
+ const expr = parseMapExpr('count(#/sensors)');
80
+ const step = expr.steps[0];
81
+ if (step.type === 'pipe') {
82
+ assert.deepEqual(step.args[0], { $ref: '/sensors', fields: [] });
83
+ }
84
+ });
85
+
86
+ it('parses #/path.deep.field', () => {
87
+ const expr = parseMapExpr('mul(#/config.tax.rate)');
88
+ const step = expr.steps[0];
89
+ if (step.type === 'pipe') {
90
+ assert.deepEqual(step.args[0], { $ref: '/config', fields: ['tax', 'rate'] });
91
+ }
92
+ });
93
+ });
94
+
95
+ // ── Evaluator ──
96
+
97
+ describe('evaluateRef', () => {
98
+ const config = { $path: '/config', $type: 'config', factor: 5, tax: { rate: 0.2 } } as NodeData;
99
+
100
+ const sensors = [
101
+ { $path: '/s/1', $type: 'reading', value: 10, seq: 0 },
102
+ { $path: '/s/2', $type: 'reading', value: 20, seq: 1 },
103
+ { $path: '/s/3', $type: 'reading', value: 30, seq: 2 },
104
+ ] as NodeData[];
105
+
106
+ const selfNode = { $path: '/obj', $type: 't3d.object', width: 4, height: 3, price: 100 } as NodeData;
107
+
108
+ const allNodes = [...sensors, config, selfNode];
109
+
110
+ const ctx = {
111
+ getNode: (p: string) => allNodes.find(s => s.$path === p),
112
+ getChildren: (p: string) => p === '/s' ? sensors : [],
113
+ };
114
+
115
+ it('resolves plain ref (no $map)', () => {
116
+ const result = evaluateRef({ $ref: '/s/2' }, ctx);
117
+ assert.equal((result as NodeData)?.value, 20);
118
+ });
119
+
120
+ it('resolves last().value | div(5)', () => {
121
+ const result = evaluateRef({ $ref: '/s', $map: 'last().value | div(5)' }, ctx);
122
+ assert.equal(result, 6); // 30 / 5
123
+ });
124
+
125
+ it('resolves first().value', () => {
126
+ const result = evaluateRef({ $ref: '/s', $map: 'first().value' }, ctx);
127
+ assert.equal(result, 10);
128
+ });
129
+
130
+ it('resolves count()', () => {
131
+ const result = evaluateRef({ $ref: '/s', $map: 'count()' }, ctx);
132
+ assert.equal(result, 3);
133
+ });
134
+
135
+ it('resolves map + avg', () => {
136
+ const result = evaluateRef({ $ref: '/s', $map: 'map(value) | avg' }, ctx);
137
+ assert.equal(result, 20); // (10+20+30)/3
138
+ });
139
+
140
+ it('resolves map + sum', () => {
141
+ const result = evaluateRef({ $ref: '/s', $map: 'map(value) | sum' }, ctx);
142
+ assert.equal(result, 60);
143
+ });
144
+
145
+ it('resolves scalar chain', () => {
146
+ const result = evaluateRef({ $ref: '/s', $map: 'last().value | sub(20) | abs | div(2)' }, ctx);
147
+ assert.equal(result, 5); // |30-20| / 2
148
+ });
149
+
150
+ it('resolves clamp', () => {
151
+ const result = evaluateRef({ $ref: '/s', $map: 'last().value | clamp(0, 25)' }, ctx);
152
+ assert.equal(result, 25); // 30 clamped to 25
153
+ });
154
+
155
+ it('resolves single node field access (no collection)', () => {
156
+ const result = evaluateRef({ $ref: '/s/1', $map: '.value | mul(3)' }, ctx);
157
+ assert.equal(result, 30); // 10 * 3
158
+ });
159
+
160
+ it('returns undefined for empty children', () => {
161
+ const result = evaluateRef({ $ref: '/empty', $map: 'last().value' }, ctx);
162
+ assert.equal(result, undefined);
163
+ });
164
+
165
+ // ── Self-ref + #-args ──
166
+
167
+ it('resolves #field self lookup', () => {
168
+ const result = evaluateRef({ $ref: '/obj', $map: '#width | mul(#height)' }, ctx);
169
+ assert.equal(result, 12); // 4 * 3
170
+ });
171
+
172
+ it('resolves cross-ref #/path.field arg', () => {
173
+ const result = evaluateRef({ $ref: '/obj', $map: '#price | mul(#/config.tax.rate)' }, ctx);
174
+ assert.equal(result, 20); // 100 * 0.2
175
+ });
176
+
177
+ it('resolves external source + self arg via #', () => {
178
+ const result = evaluateRef({ $ref: '/s', $map: 'last().value | div(#/config.factor)' }, ctx);
179
+ assert.equal(result, 6); // 30 / 5
180
+ });
181
+
182
+ it('returns NaN for missing #ref node (loud failure)', () => {
183
+ const result = evaluateRef({ $ref: '/obj', $map: '#width | mul(#/missing.value)' }, ctx);
184
+ assert.equal(Number.isNaN(result), true); // 4 * undefined = NaN
185
+ });
186
+ });
187
+
188
+ // ── isCollectionRef ──
189
+
190
+ describe('isCollectionRef', () => {
191
+ it('true for last()', () => {
192
+ assert.equal(isCollectionRef({ $ref: '/s', $map: 'last().value' }), true);
193
+ });
194
+
195
+ it('true for count()', () => {
196
+ assert.equal(isCollectionRef({ $ref: '/s', $map: 'count()' }), true);
197
+ });
198
+
199
+ it('false for field access', () => {
200
+ assert.equal(isCollectionRef({ $ref: '/s/1', $map: '.value' }), false);
201
+ });
202
+
203
+ it('false for #field self access', () => {
204
+ assert.equal(isCollectionRef({ $ref: '.', $map: '#width' }), false);
205
+ });
206
+
207
+ it('false for no $map', () => {
208
+ assert.equal(isCollectionRef({ $ref: '/s/1' }), false);
209
+ });
210
+ });
211
+
212
+ // ── extractArgPaths ──
213
+
214
+ describe('extractArgPaths', () => {
215
+ it('returns external paths from #/path args', () => {
216
+ const paths = extractArgPaths({ $ref: '/obj', $map: '#price | mul(#/config.rate) | add(#/bonus.value)' });
217
+ assert.deepEqual(paths, ['/config', '/bonus']);
218
+ });
219
+
220
+ it('skips # self refs', () => {
221
+ const paths = extractArgPaths({ $ref: '.', $map: '#width | mul(#height)' });
222
+ assert.deepEqual(paths, []);
223
+ });
224
+
225
+ it('returns empty for no $map', () => {
226
+ assert.deepEqual(extractArgPaths({ $ref: '/x' }), []);
227
+ });
228
+ });
229
+
230
+ // ── hasOnce ──
231
+
232
+ describe('hasOnce', () => {
233
+ it('true when once in pipe chain', () => {
234
+ assert.equal(hasOnce({ $ref: '/s', $map: 'last().value | div(5) | once' }), true);
235
+ });
236
+
237
+ it('true when once is only step', () => {
238
+ assert.equal(hasOnce({ $ref: '/s', $map: 'once' }), true);
239
+ });
240
+
241
+ it('false for normal pipes', () => {
242
+ assert.equal(hasOnce({ $ref: '/s', $map: 'last().value | div(5)' }), false);
243
+ });
244
+
245
+ it('false for no $map', () => {
246
+ assert.equal(hasOnce({ $ref: '/s' }), false);
247
+ });
248
+ });
249
+
250
+ // ── once pipe (identity) ──
251
+
252
+ describe('once pipe in evaluation', () => {
253
+ const nodes = [
254
+ { $path: '/s/1', $type: 'r', value: 10 },
255
+ { $path: '/s/2', $type: 'r', value: 20 },
256
+ ] as NodeData[];
257
+
258
+ const ctx = {
259
+ getNode: (p: string) => nodes.find(n => n.$path === p),
260
+ getChildren: (p: string) => p === '/s' ? nodes : [],
261
+ };
262
+
263
+ it('once does not alter the computed value', () => {
264
+ const withOnce = evaluateRef({ $ref: '/s', $map: 'last().value | div(5) | once' }, ctx);
265
+ const without = evaluateRef({ $ref: '/s', $map: 'last().value | div(5)' }, ctx);
266
+ assert.equal(withOnce, without);
267
+ assert.equal(withOnce, 4); // 20 / 5
268
+ });
269
+ });
270
+
271
+ // ── Computed store ──
272
+
273
+ describe('computed store', () => {
274
+ it('set + get', () => {
275
+ setComputed('/test', 'sy', 42);
276
+ const c = getComputed('/test');
277
+ assert.equal(c?.sy, 42);
278
+ clearComputed('/test');
279
+ });
280
+
281
+ it('fires subscriber on change', () => {
282
+ let fired = 0;
283
+ const unsub = subscribeComputed('/test2', () => { fired++; });
284
+ setComputed('/test2', 'px', 1);
285
+ assert.equal(fired, 1);
286
+ setComputed('/test2', 'px', 2);
287
+ assert.equal(fired, 2);
288
+ // No-op same value
289
+ setComputed('/test2', 'px', 2);
290
+ assert.equal(fired, 2);
291
+ unsub();
292
+ clearComputed('/test2');
293
+ });
294
+
295
+ it('returns new object reference on change (useSyncExternalStore compat)', () => {
296
+ setComputed('/ref-test', 'a', 1);
297
+ const snap1 = getComputed('/ref-test');
298
+ setComputed('/ref-test', 'a', 2);
299
+ const snap2 = getComputed('/ref-test');
300
+ // Must be different references — Object.is must see the change
301
+ assert.notEqual(snap1, snap2);
302
+ assert.equal(snap2?.a, 2);
303
+ // Old snapshot retains old value (immutable)
304
+ assert.equal(snap1?.a, 1);
305
+ clearComputed('/ref-test');
306
+ });
307
+
308
+ it('unsubscribe stops notifications', () => {
309
+ let fired = 0;
310
+ const unsub = subscribeComputed('/test3', () => { fired++; });
311
+ unsub();
312
+ setComputed('/test3', 'sy', 99);
313
+ assert.equal(fired, 0);
314
+ clearComputed('/test3');
315
+ });
316
+ });
@@ -0,0 +1,64 @@
1
+ // Computed store — separate reactive layer for $ref+$map resolved values
2
+ // Dual: valtio proxies for field-level React reactivity (useSnapshot),
3
+ // plus synchronous manual subs for bind engine and non-React consumers.
4
+ // Never persisted to IDB or server. No Meteor trap.
5
+
6
+ import { proxy, snapshot } from 'valtio/vanilla';
7
+
8
+ type Sub = () => void;
9
+
10
+ const proxies = new Map<string, Record<string, unknown>>();
11
+ const subs = new Map<string, Set<Sub>>();
12
+
13
+ function ensure(path: string): Record<string, unknown> {
14
+ let p = proxies.get(path);
15
+ if (!p) {
16
+ p = proxy<Record<string, unknown>>({});
17
+ proxies.set(path, p);
18
+ }
19
+ return p;
20
+ }
21
+
22
+ function fire(path: string): void {
23
+ const s = subs.get(path);
24
+ if (s) for (const cb of s) cb();
25
+ }
26
+
27
+ export function getComputed(path: string): Record<string, unknown> | undefined {
28
+ const p = proxies.get(path);
29
+ if (!p) return undefined;
30
+ return snapshot(p) as Record<string, unknown>;
31
+ }
32
+
33
+ /** Get the raw valtio proxy — for useSnapshot in hooks */
34
+ export function getComputedProxy(path: string): Record<string, unknown> | undefined {
35
+ return proxies.get(path);
36
+ }
37
+
38
+ export function setComputed(path: string, field: string, value: unknown): void {
39
+ const p = ensure(path);
40
+ if (Object.is(p[field], value)) return; // no-op if unchanged
41
+ p[field] = value;
42
+ fire(path); // synchronous for non-React consumers
43
+ }
44
+
45
+ export function clearComputed(path: string): void {
46
+ const p = proxies.get(path);
47
+ if (p) {
48
+ for (const k of Object.keys(p)) delete p[k];
49
+ proxies.delete(path);
50
+ }
51
+ fire(path);
52
+ }
53
+
54
+ export function subscribeComputed(path: string, cb: Sub): () => void {
55
+ if (!subs.has(path)) subs.set(path, new Set());
56
+ subs.get(path)!.add(cb);
57
+ return () => {
58
+ const s = subs.get(path);
59
+ if (s) {
60
+ s.delete(cb);
61
+ if (!s.size) subs.delete(path);
62
+ }
63
+ };
64
+ }