@anchrd/intel 0.59.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 (364) hide show
  1. package/README.md +496 -0
  2. package/bin/intel.mjs +134 -0
  3. package/dist/adapters/cloudflare/cloudflare-flow-workflow.d.ts +42 -0
  4. package/dist/adapters/cloudflare/cloudflare-flow-workflow.js +89 -0
  5. package/dist/adapters/cloudflare/cloudflare.d.ts +29 -0
  6. package/dist/adapters/cloudflare/cloudflare.js +416 -0
  7. package/dist/adapters/cloudflare/cloudflare.types.d.ts +31 -0
  8. package/dist/adapters/cloudflare/cloudflare.types.js +1 -0
  9. package/dist/adapters/content/content.d.ts +3 -0
  10. package/dist/adapters/content/content.js +128 -0
  11. package/dist/adapters/content/content.types.d.ts +38 -0
  12. package/dist/adapters/content/content.types.js +1 -0
  13. package/dist/adapters/db/db-audit.d.ts +62 -0
  14. package/dist/adapters/db/db-audit.js +150 -0
  15. package/dist/adapters/db/db-boards.d.ts +6 -0
  16. package/dist/adapters/db/db-boards.js +324 -0
  17. package/dist/adapters/db/db-feed.d.ts +30 -0
  18. package/dist/adapters/db/db-feed.js +276 -0
  19. package/dist/adapters/db/db-flows.d.ts +42 -0
  20. package/dist/adapters/db/db-flows.js +1311 -0
  21. package/dist/adapters/db/db-grants.d.ts +133 -0
  22. package/dist/adapters/db/db-grants.js +269 -0
  23. package/dist/adapters/db/db-indexing.d.ts +3 -0
  24. package/dist/adapters/db/db-indexing.js +200 -0
  25. package/dist/adapters/db/db-oauth.d.ts +3 -0
  26. package/dist/adapters/db/db-oauth.js +26 -0
  27. package/dist/adapters/db/db-prompts.d.ts +46 -0
  28. package/dist/adapters/db/db-prompts.js +144 -0
  29. package/dist/adapters/db/db.d.ts +6 -0
  30. package/dist/adapters/db/db.js +1687 -0
  31. package/dist/adapters/db/db.types.d.ts +16 -0
  32. package/dist/adapters/db/db.types.js +1 -0
  33. package/dist/adapters/document-converter/document-converter.d.ts +2 -0
  34. package/dist/adapters/document-converter/document-converter.js +26 -0
  35. package/dist/adapters/document-converter/document-converter.types.d.ts +17 -0
  36. package/dist/adapters/document-converter/document-converter.types.js +1 -0
  37. package/dist/adapters/flow-runtime/flow-runtime.d.ts +3 -0
  38. package/dist/adapters/flow-runtime/flow-runtime.js +22 -0
  39. package/dist/adapters/flow-runtime/flow-runtime.types.d.ts +19 -0
  40. package/dist/adapters/flow-runtime/flow-runtime.types.js +1 -0
  41. package/dist/adapters/index-queue/index-queue.d.ts +10 -0
  42. package/dist/adapters/index-queue/index-queue.js +12 -0
  43. package/dist/adapters/index-queue/index-queue.types.d.ts +11 -0
  44. package/dist/adapters/index-queue/index-queue.types.js +1 -0
  45. package/dist/adapters/json-schema/json-schema.d.ts +2 -0
  46. package/dist/adapters/json-schema/json-schema.js +77 -0
  47. package/dist/adapters/openid/openid.d.ts +5 -0
  48. package/dist/adapters/openid/openid.js +301 -0
  49. package/dist/adapters/portal-tokens/portal-tokens.d.ts +8 -0
  50. package/dist/adapters/portal-tokens/portal-tokens.js +79 -0
  51. package/dist/adapters/remote-tools/remote-tools.d.ts +5 -0
  52. package/dist/adapters/remote-tools/remote-tools.js +129 -0
  53. package/dist/adapters/semantic-index/semantic-index.d.ts +2 -0
  54. package/dist/adapters/semantic-index/semantic-index.js +139 -0
  55. package/dist/adapters/semantic-index/semantic-index.types.d.ts +56 -0
  56. package/dist/adapters/semantic-index/semantic-index.types.js +1 -0
  57. package/dist/adapters/session-cookie/session-cookie.d.ts +5 -0
  58. package/dist/adapters/session-cookie/session-cookie.js +87 -0
  59. package/dist/adapters/tool-source-policy/tool-source-policy.d.ts +1 -0
  60. package/dist/adapters/tool-source-policy/tool-source-policy.js +22 -0
  61. package/dist/audit/audit.d.ts +2 -0
  62. package/dist/audit/audit.js +87 -0
  63. package/dist/audit/audit.types.d.ts +23 -0
  64. package/dist/audit/audit.types.js +1 -0
  65. package/dist/auth/auth.d.ts +2 -0
  66. package/dist/auth/auth.js +284 -0
  67. package/dist/auth/auth.types.d.ts +108 -0
  68. package/dist/auth/auth.types.js +1 -0
  69. package/dist/boards/boards.d.ts +44 -0
  70. package/dist/boards/boards.js +447 -0
  71. package/dist/boards/boards.types.d.ts +117 -0
  72. package/dist/boards/boards.types.js +1 -0
  73. package/dist/build/build.d.ts +2 -0
  74. package/dist/build/build.js +192 -0
  75. package/dist/build/build.types.d.ts +17 -0
  76. package/dist/build/build.types.js +1 -0
  77. package/dist/bundle/bundle.d.ts +4 -0
  78. package/dist/bundle/bundle.js +1194 -0
  79. package/dist/bundle/bundle.types.d.ts +33 -0
  80. package/dist/bundle/bundle.types.js +1 -0
  81. package/dist/cli/cli.d.ts +4 -0
  82. package/dist/cli/cli.js +262 -0
  83. package/dist/cli/cli.types.d.ts +14 -0
  84. package/dist/cli/cli.types.js +1 -0
  85. package/dist/contract/audit.d.ts +50 -0
  86. package/dist/contract/audit.js +72 -0
  87. package/dist/contract/board.d.ts +150 -0
  88. package/dist/contract/board.js +262 -0
  89. package/dist/contract/bundle.d.ts +147 -0
  90. package/dist/contract/bundle.js +139 -0
  91. package/dist/contract/contract.d.ts +112 -0
  92. package/dist/contract/contract.js +143 -0
  93. package/dist/contract/feed.d.ts +126 -0
  94. package/dist/contract/feed.js +149 -0
  95. package/dist/contract/flow-run.d.ts +346 -0
  96. package/dist/contract/flow-run.js +194 -0
  97. package/dist/contract/flow.d.ts +1057 -0
  98. package/dist/contract/flow.js +552 -0
  99. package/dist/contract/node.d.ts +551 -0
  100. package/dist/contract/node.js +456 -0
  101. package/dist/contract/share.d.ts +216 -0
  102. package/dist/contract/share.js +134 -0
  103. package/dist/contract/table.d.ts +171 -0
  104. package/dist/contract/table.js +117 -0
  105. package/dist/contract/tool.d.ts +122 -0
  106. package/dist/contract/tool.js +172 -0
  107. package/dist/feed/feed.d.ts +2 -0
  108. package/dist/feed/feed.js +77 -0
  109. package/dist/feed/feed.types.d.ts +23 -0
  110. package/dist/feed/feed.types.js +1 -0
  111. package/dist/flows/flows.d.ts +41 -0
  112. package/dist/flows/flows.js +2216 -0
  113. package/dist/flows/flows.types.d.ts +339 -0
  114. package/dist/flows/flows.types.js +1 -0
  115. package/dist/http/http.d.ts +9 -0
  116. package/dist/http/http.js +874 -0
  117. package/dist/http/http.types.d.ts +22 -0
  118. package/dist/http/http.types.js +1 -0
  119. package/dist/indexing/indexing.d.ts +6 -0
  120. package/dist/indexing/indexing.js +239 -0
  121. package/dist/indexing/indexing.types.d.ts +11 -0
  122. package/dist/indexing/indexing.types.js +1 -0
  123. package/dist/intel/intel.d.ts +3 -0
  124. package/dist/intel/intel.js +194 -0
  125. package/dist/intel/intel.types.d.ts +24 -0
  126. package/dist/intel/intel.types.js +1 -0
  127. package/dist/mcp/mcp.d.ts +2 -0
  128. package/dist/mcp/mcp.js +1292 -0
  129. package/dist/mcp/mcp.types.d.ts +25 -0
  130. package/dist/mcp/mcp.types.js +1 -0
  131. package/dist/nodes/document-links/document-links.d.ts +20 -0
  132. package/dist/nodes/document-links/document-links.js +57 -0
  133. package/dist/nodes/nodes.d.ts +2 -0
  134. package/dist/nodes/nodes.js +1667 -0
  135. package/dist/nodes/nodes.types.d.ts +511 -0
  136. package/dist/nodes/nodes.types.js +1 -0
  137. package/dist/prepare/prepare.d.ts +4 -0
  138. package/dist/prepare/prepare.js +16 -0
  139. package/dist/prepare/prepare.types.d.ts +9 -0
  140. package/dist/prepare/prepare.types.js +1 -0
  141. package/dist/prompts/prompts.d.ts +2 -0
  142. package/dist/prompts/prompts.js +65 -0
  143. package/dist/prompts/prompts.types.d.ts +71 -0
  144. package/dist/prompts/prompts.types.js +1 -0
  145. package/dist/shared/callers/callers.d.ts +28 -0
  146. package/dist/shared/callers/callers.js +30 -0
  147. package/dist/shared/csv/csv.d.ts +13 -0
  148. package/dist/shared/csv/csv.js +85 -0
  149. package/dist/shared/document-text/document-text.d.ts +21 -0
  150. package/dist/shared/document-text/document-text.js +31 -0
  151. package/dist/shared/gate-authorization/gate-authorization.d.ts +5 -0
  152. package/dist/shared/gate-authorization/gate-authorization.js +26 -0
  153. package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
  154. package/dist/shared/grant-expiry/grant-expiry.js +26 -0
  155. package/dist/shared/intel-error/intel-error.d.ts +22 -0
  156. package/dist/shared/intel-error/intel-error.js +27 -0
  157. package/dist/shared/plain-title/plain-title.d.ts +16 -0
  158. package/dist/shared/plain-title/plain-title.js +45 -0
  159. package/dist/shared/problem-details/problem-details.d.ts +30 -0
  160. package/dist/shared/problem-details/problem-details.js +18 -0
  161. package/dist/shared/report-unexpected-error/report-unexpected-error.d.ts +1 -0
  162. package/dist/shared/report-unexpected-error/report-unexpected-error.js +37 -0
  163. package/dist/shared/safe-return-path/safe-return-path.d.ts +2 -0
  164. package/dist/shared/safe-return-path/safe-return-path.js +11 -0
  165. package/dist/shared/sha256/sha256.d.ts +2 -0
  166. package/dist/shared/sha256/sha256.js +14 -0
  167. package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
  168. package/dist/tools/tool-servers/tool-servers.js +177 -0
  169. package/dist/tools/tools.d.ts +2 -0
  170. package/dist/tools/tools.js +207 -0
  171. package/dist/tools/tools.types.d.ts +56 -0
  172. package/dist/tools/tools.types.js +1 -0
  173. package/examples/branding/favicon.svg +5 -0
  174. package/examples/branding/logo.svg +5 -0
  175. package/examples/branding/theme.css +16 -0
  176. package/examples/dev.vars.example +7 -0
  177. package/examples/intel.json +9 -0
  178. package/migrations/0000_intel_foundation.sql +203 -0
  179. package/migrations/0001_portal_native_tools.sql +21 -0
  180. package/migrations/0002_flows_in_the_knowledge_tree.sql +34 -0
  181. package/migrations/0003_folder_permissions.sql +211 -0
  182. package/migrations/0004_subflow_runs.sql +14 -0
  183. package/migrations/0005_flow_node_cleanup.sql +28 -0
  184. package/migrations/0005_tables_in_the_knowledge_tree.sql +68 -0
  185. package/migrations/0006_links_are_written_in_the_text.sql +20 -0
  186. package/migrations/0007_no_node_waits.sql +69 -0
  187. package/migrations/0008_three_layers.sql +130 -0
  188. package/migrations/0009_no_context_policy.sql +48 -0
  189. package/migrations/0010_no_run_waits_a_year.sql +27 -0
  190. package/migrations/0011_one_name_for_the_tree.sql +53 -0
  191. package/migrations/0012_table_snapshots.sql +29 -0
  192. package/migrations/0017_a_vector_per_card.sql +38 -0
  193. package/migrations/0018_no_context_policy_at_last.sql +97 -0
  194. package/migrations/0019_one_name_for_the_grants.sql +52 -0
  195. package/migrations/0020_cascade_purge_replay.sql +11 -0
  196. package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -0
  197. package/migrations/0022_a_cursor_over_the_journal.sql +14 -0
  198. package/migrations/0023_a_board_and_its_tasks.sql +116 -0
  199. package/migrations/0024_the_archive_is_a_column.sql +33 -0
  200. package/migrations/0025_archiving_takes_the_subtree.sql +29 -0
  201. package/migrations/0026_one_feed_over_two_kinds.sql +23 -0
  202. package/migrations/0027_the_runs_of_every_flow.sql +27 -0
  203. package/migrations/0028_a_prompt_name_over_two_kinds.sql +70 -0
  204. package/package.json +118 -0
  205. package/src/contract/CLAUDE.md +152 -0
  206. package/src/contract/README.md +102 -0
  207. package/src/contract/audit.ts +85 -0
  208. package/src/contract/board.ts +306 -0
  209. package/src/contract/bundle.ts +157 -0
  210. package/src/contract/contract.ts +161 -0
  211. package/src/contract/feed.ts +165 -0
  212. package/src/contract/flow-run.ts +241 -0
  213. package/src/contract/flow.ts +675 -0
  214. package/src/contract/node.ts +583 -0
  215. package/src/contract/share.ts +196 -0
  216. package/src/contract/table.ts +178 -0
  217. package/src/contract/tool.ts +202 -0
  218. package/ui/README.md +110 -0
  219. package/ui/components.json +21 -0
  220. package/ui/index.html +13 -0
  221. package/ui/package.json +11 -0
  222. package/ui/scripts/lint-tokens.mjs +187 -0
  223. package/ui/src/access-summary/access-summary.tsx +385 -0
  224. package/ui/src/app/action-slot/action-slot.tsx +27 -0
  225. package/ui/src/app/app-sidebar/app-sidebar.tsx +71 -0
  226. package/ui/src/app/app-tree/app-tree.tsx +1175 -0
  227. package/ui/src/app/app.tsx +87 -0
  228. package/ui/src/app/header-search/header-search.tsx +294 -0
  229. package/ui/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
  230. package/ui/src/app/settings-dialog/settings-dialog.tsx +135 -0
  231. package/ui/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
  232. package/ui/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
  233. package/ui/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
  234. package/ui/src/app/tree-expansion/tree-expansion.ts +107 -0
  235. package/ui/src/app/tree-expansion/tree-expansion.types.ts +31 -0
  236. package/ui/src/app/tree-move/tree-move.tsx +427 -0
  237. package/ui/src/app/tree-reveal/tree-reveal.ts +64 -0
  238. package/ui/src/app/tree-reveal/tree-reveal.types.ts +16 -0
  239. package/ui/src/app/user-footer/user-footer.tsx +188 -0
  240. package/ui/src/app/view-menu/view-menu.tsx +115 -0
  241. package/ui/src/app-root/app-root.tsx +109 -0
  242. package/ui/src/app-root/app-root.types.ts +27 -0
  243. package/ui/src/archive/archive.tsx +417 -0
  244. package/ui/src/attachment-detail/attachment-detail.ts +52 -0
  245. package/ui/src/attachment-viewer/attachment-viewer.tsx +166 -0
  246. package/ui/src/blocknote-view/blocknote-view.tsx +30 -0
  247. package/ui/src/board/board-assignee/board-assignee-picker.tsx +132 -0
  248. package/ui/src/board/board-assignee/board-assignee.ts +48 -0
  249. package/ui/src/board/board-assignee/board-assignee.tsx +75 -0
  250. package/ui/src/board/board-chip/board-chip.tsx +46 -0
  251. package/ui/src/board/board-crumbs/board-crumbs.ts +56 -0
  252. package/ui/src/board/board-crumbs/board-crumbs.tsx +111 -0
  253. package/ui/src/board/board-data/board-data.ts +195 -0
  254. package/ui/src/board/board-data/board-data.types.ts +38 -0
  255. package/ui/src/board/board-dates/board-dates.ts +40 -0
  256. package/ui/src/board/board-dates/board-dates.tsx +105 -0
  257. package/ui/src/board/board-kanban/board-kanban.ts +162 -0
  258. package/ui/src/board/board-kanban/board-kanban.tsx +680 -0
  259. package/ui/src/board/board-open-task/board-open-task.ts +32 -0
  260. package/ui/src/board/board-panel/board-panel.tsx +259 -0
  261. package/ui/src/board/board-settings/board-settings.tsx +226 -0
  262. package/ui/src/board/board-status/board-status.tsx +60 -0
  263. package/ui/src/board/board-stripes/board-stripes.ts +128 -0
  264. package/ui/src/board/board-table/board-table.ts +53 -0
  265. package/ui/src/board/board-table/board-table.tsx +610 -0
  266. package/ui/src/board/board-task/board-task.ts +108 -0
  267. package/ui/src/board/board-task/board-task.tsx +335 -0
  268. package/ui/src/board/board-title-row/board-title-row.tsx +66 -0
  269. package/ui/src/branding/branding.tsx +17 -0
  270. package/ui/src/branding/custom-logo.ts +1 -0
  271. package/ui/src/branding/favicon.default.svg +5 -0
  272. package/ui/src/branding/favicon.svg +5 -0
  273. package/ui/src/components/ui/avatar.tsx +39 -0
  274. package/ui/src/components/ui/button.tsx +64 -0
  275. package/ui/src/components/ui/collapsible.tsx +20 -0
  276. package/ui/src/components/ui/command.tsx +160 -0
  277. package/ui/src/components/ui/dialog.tsx +143 -0
  278. package/ui/src/components/ui/dropdown-menu.tsx +187 -0
  279. package/ui/src/components/ui/input.tsx +21 -0
  280. package/ui/src/components/ui/popover.tsx +41 -0
  281. package/ui/src/components/ui/select.tsx +163 -0
  282. package/ui/src/components/ui/separator.tsx +26 -0
  283. package/ui/src/components/ui/sheet.tsx +136 -0
  284. package/ui/src/components/ui/sidebar.tsx +693 -0
  285. package/ui/src/components/ui/skeleton.tsx +13 -0
  286. package/ui/src/components/ui/switch.tsx +25 -0
  287. package/ui/src/components/ui/table.tsx +82 -0
  288. package/ui/src/components/ui/tabs.tsx +52 -0
  289. package/ui/src/components/ui/tooltip.tsx +51 -0
  290. package/ui/src/data/intel-data-provider/intel-data-provider.ts +685 -0
  291. package/ui/src/data/intel-data-provider/intel-data-provider.types.ts +266 -0
  292. package/ui/src/data/query-client.ts +18 -0
  293. package/ui/src/data/request-refusal/refusal-notice.tsx +36 -0
  294. package/ui/src/data/request-refusal/request-refusal.ts +51 -0
  295. package/ui/src/data/sign-in/sign-in.ts +97 -0
  296. package/ui/src/data/sign-in/sign-in.types.ts +15 -0
  297. package/ui/src/document-link/document-link.tsx +114 -0
  298. package/ui/src/document-markdown/document-markdown.ts +195 -0
  299. package/ui/src/editor-schema/editor-schema.ts +23 -0
  300. package/ui/src/entry-picker/entry-picker.tsx +238 -0
  301. package/ui/src/feed/feed.tsx +344 -0
  302. package/ui/src/file-preview/file-preview-view.tsx +496 -0
  303. package/ui/src/file-preview/file-preview.tsx +28 -0
  304. package/ui/src/file-preview/pdf-file-preview.tsx +5 -0
  305. package/ui/src/file-preview/presentation-file-preview.tsx +21 -0
  306. package/ui/src/file-preview/spreadsheet-file-preview.tsx +5 -0
  307. package/ui/src/file-preview/word-file-preview.tsx +5 -0
  308. package/ui/src/flow-runs/flow-runs.tsx +238 -0
  309. package/ui/src/flows/flows.tsx +1646 -0
  310. package/ui/src/flows/node-icon/node-icon.ts +34 -0
  311. package/ui/src/flows/node-palette/node-palette.tsx +214 -0
  312. package/ui/src/flows/node-palette/node-palette.types.ts +15 -0
  313. package/ui/src/folder-contents/folder-contents.tsx +321 -0
  314. package/ui/src/folder-index/folder-index.ts +55 -0
  315. package/ui/src/frontmatter/frontmatter.tsx +193 -0
  316. package/ui/src/graph-pane/graph-pane.tsx +53 -0
  317. package/ui/src/hooks/use-mobile.ts +19 -0
  318. package/ui/src/i18n/custom.json +4 -0
  319. package/ui/src/i18n/de.json +609 -0
  320. package/ui/src/i18n/en.json +609 -0
  321. package/ui/src/i18n/es.json +609 -0
  322. package/ui/src/i18n/i18n-context.tsx +65 -0
  323. package/ui/src/i18n/i18n-languages/i18n-languages.ts +83 -0
  324. package/ui/src/i18n/i18n.ts +32 -0
  325. package/ui/src/i18n/i18n.types.ts +49 -0
  326. package/ui/src/kind-icon.ts +97 -0
  327. package/ui/src/lib/utils.ts +6 -0
  328. package/ui/src/main.tsx +68 -0
  329. package/ui/src/modal/modal.tsx +53 -0
  330. package/ui/src/node-details/node-details.tsx +545 -0
  331. package/ui/src/node-editor/node-editor.tsx +304 -0
  332. package/ui/src/node-graph/graph-notice.tsx +21 -0
  333. package/ui/src/node-graph/node-graph.ts +214 -0
  334. package/ui/src/node-graph/node-graph.tsx +295 -0
  335. package/ui/src/node-import/node-import.tsx +418 -0
  336. package/ui/src/node-save-error.ts +46 -0
  337. package/ui/src/node-source/node-source.tsx +128 -0
  338. package/ui/src/node-table/node-table.tsx +107 -0
  339. package/ui/src/nodes/nodes.tsx +240 -0
  340. package/ui/src/rename-resource/rename-resource.ts +80 -0
  341. package/ui/src/resolved-names/resolved-names.tsx +67 -0
  342. package/ui/src/resource-error.ts +33 -0
  343. package/ui/src/resource-menu/resource-menu.tsx +1156 -0
  344. package/ui/src/router/router-context.ts +6 -0
  345. package/ui/src/router/router.tsx +67 -0
  346. package/ui/src/router/router.types.ts +8 -0
  347. package/ui/src/router/selection-search.ts +88 -0
  348. package/ui/src/save-button/save-button.tsx +94 -0
  349. package/ui/src/section-hint/section-hint.tsx +40 -0
  350. package/ui/src/sign-in-refused/sign-in-refused.tsx +35 -0
  351. package/ui/src/styles.css +189 -0
  352. package/ui/src/table-columns/table-columns.tsx +379 -0
  353. package/ui/src/theme/custom.css +1 -0
  354. package/ui/src/theme/theme-context.tsx +89 -0
  355. package/ui/src/theme/theme.ts +70 -0
  356. package/ui/src/time/relative-time.tsx +41 -0
  357. package/ui/src/time/time-context.tsx +78 -0
  358. package/ui/src/time/time.ts +165 -0
  359. package/ui/src/title-row/title-row.tsx +185 -0
  360. package/ui/src/tools/tools.tsx +546 -0
  361. package/ui/src/user-name/user-name.ts +61 -0
  362. package/ui/src/verbatim/verbatim.tsx +68 -0
  363. package/ui/tsconfig.json +17 -0
  364. package/ui/vite.config.ts +129 -0
@@ -0,0 +1,15 @@
1
+ export interface SidebarPreferences {
2
+ open: boolean;
3
+ width: number;
4
+ }
5
+
6
+ export interface SidebarPreferenceStore {
7
+ read(): SidebarPreferences;
8
+ write(preferences: SidebarPreferences): void;
9
+ }
10
+
11
+ export interface SidebarPreferenceDeps {
12
+ // A `null` storage disables persistence. `undefined` means "use the browser's", which is not the
13
+ // same thing: a test passes null, a browser without localStorage produces null on its own.
14
+ storage?: Pick<Storage, "getItem" | "setItem"> | null;
15
+ }
@@ -0,0 +1,86 @@
1
+ import { useRef } from "react";
2
+ import {
3
+ clampSidebarWidth,
4
+ MaxSidebarWidth,
5
+ MinSidebarWidth,
6
+ } from "@/app/sidebar-preferences/sidebar-preferences.ts";
7
+ import { cn } from "@/lib/utils";
8
+
9
+ const KeyboardStep = 16;
10
+
11
+ // The registry has no resizable sidebar, so this is the one piece of the shell that is not a
12
+ // primitive. It is an ARIA window splitter rather than a bare div: a drag-only handle exists for
13
+ // nobody on a keyboard, and the tree behind it is the entire navigation.
14
+ export function SidebarResizeHandle({
15
+ width,
16
+ label,
17
+ onWidthChange,
18
+ }: {
19
+ width: number;
20
+ label: string;
21
+ onWidthChange(width: number): void;
22
+ }) {
23
+ const drag = useRef<{ pointerId: number; startX: number; startWidth: number } | null>(null);
24
+
25
+ return (
26
+ // The WAI-ARIA window-splitter pattern is a focusable separator carrying a value. The rule's
27
+ // suggested `<hr>` cannot take focus, which would remove the keyboard operation this handle
28
+ // exists to provide.
29
+ // biome-ignore lint/a11y/useSemanticElements: a focusable window splitter cannot be an <hr>
30
+ <button
31
+ type="button"
32
+ role="separator"
33
+ aria-orientation="vertical"
34
+ aria-label={label}
35
+ aria-valuenow={width}
36
+ aria-valuemin={MinSidebarWidth}
37
+ aria-valuemax={MaxSidebarWidth}
38
+ className={cn(
39
+ "absolute inset-y-0 -right-1 z-20 hidden w-2 cursor-col-resize outline-none md:block",
40
+ "after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2",
41
+ "hover:after:bg-sidebar-ring focus-visible:after:bg-sidebar-ring",
42
+ "focus-visible:ring-2 focus-visible:ring-sidebar-ring",
43
+ // Collapsed there is no sidebar left to drag. The shell already unmounts the whole panel
44
+ // then; this keeps the handle from reappearing should it ever be mounted collapsed.
45
+ "group-data-[state=collapsed]:hidden",
46
+ )}
47
+ onPointerDown={(event) => {
48
+ drag.current = {
49
+ pointerId: event.pointerId,
50
+ startX: event.clientX,
51
+ startWidth: width,
52
+ };
53
+ event.currentTarget.setPointerCapture(event.pointerId);
54
+ }}
55
+ onPointerMove={(event) => {
56
+ const active = drag.current;
57
+ if (!active || active.pointerId !== event.pointerId) return;
58
+ // Measured against the width the drag started from, never against the live value: reading
59
+ // back the clamped width would make the handle drift away from the cursor at the limits.
60
+ onWidthChange(clampSidebarWidth(active.startWidth + (event.clientX - active.startX)));
61
+ }}
62
+ onPointerUp={(event) => {
63
+ drag.current = null;
64
+ event.currentTarget.releasePointerCapture(event.pointerId);
65
+ }}
66
+ onPointerCancel={() => {
67
+ drag.current = null;
68
+ }}
69
+ onKeyDown={(event) => {
70
+ const next =
71
+ event.key === "ArrowLeft"
72
+ ? width - KeyboardStep
73
+ : event.key === "ArrowRight"
74
+ ? width + KeyboardStep
75
+ : event.key === "Home"
76
+ ? MinSidebarWidth
77
+ : event.key === "End"
78
+ ? MaxSidebarWidth
79
+ : null;
80
+ if (next === null) return;
81
+ event.preventDefault();
82
+ onWidthChange(clampSidebarWidth(next));
83
+ }}
84
+ />
85
+ );
86
+ }
@@ -0,0 +1,107 @@
1
+ import type {
2
+ ExpandedLevel,
3
+ TreeExpansionDeps,
4
+ TreeExpansionState,
5
+ TreeExpansionStore,
6
+ } from "./tree-expansion.types.ts";
7
+
8
+ const storageKey = "intel.tree";
9
+
10
+ /**
11
+ * How many open levels travel across a reload.
12
+ *
13
+ * ⚠️ NOT a guess about how many folders somebody opens: the written list is already trimmed to what
14
+ * the tree still draws, so it cannot grow on its own. The cap is against a stored value nobody
15
+ * wrote — a hand-edited or corrupted entry would otherwise become that many level queries at the
16
+ * next start, because every remembered level is one query (#11).
17
+ */
18
+ const MaxRememberedLevels = 200;
19
+
20
+ function browserStorage(): Pick<Storage, "getItem" | "setItem"> | null {
21
+ // Reading `localStorage` throws outright in a few privacy modes, so the guard is a try, not a
22
+ // typeof check. Losing which folders were open is acceptable; losing the tree is not.
23
+ try {
24
+ return typeof window === "undefined" ? null : window.localStorage;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ const empty: TreeExpansionState = { levels: [], revealed: null };
31
+
32
+ function levelFrom(value: unknown): ExpandedLevel | null {
33
+ if (typeof value !== "object" || value === null) return null;
34
+ const { id, type } = value as Partial<ExpandedLevel>;
35
+ if (typeof id !== "string" || id.length === 0) return null;
36
+ return type === "folder" || type === "flow" ? { id, type } : null;
37
+ }
38
+
39
+ /**
40
+ * Which levels of the tree stand open, written through so the answer survives what the component
41
+ * does not.
42
+ *
43
+ * ⚠️ A reload is the obvious half and the smaller one. The shell UNMOUNTS the sidebar's content
44
+ * when the panel collapses, so plain component state here dies at the first click on the sidebar
45
+ * trigger — the tree came back closed from a gesture that says nothing about folders (#805).
46
+ *
47
+ * ⚠️ What is stored is a CLAIM about a gesture, not a fact about the tree: a folder that has since
48
+ * been archived, moved away or unshared is still in here. Judging that is the tree's job and it
49
+ * already does it — a level whose row it cannot draw is never loaded (#521), and the next gesture
50
+ * writes the trimmed list back. Trimming here as well would need this file to know what the tree
51
+ * shows, which is the one thing a storage adapter must not.
52
+ */
53
+ export function createTreeExpansion(deps: TreeExpansionDeps = {}): TreeExpansionStore {
54
+ const storage = deps.storage === undefined ? browserStorage() : deps.storage;
55
+
56
+ return {
57
+ read() {
58
+ let raw: string | null = null;
59
+ try {
60
+ raw = storage?.getItem(storageKey) ?? null;
61
+ } catch {
62
+ return empty;
63
+ }
64
+ if (raw === null) return empty;
65
+ let parsed: unknown;
66
+ try {
67
+ parsed = JSON.parse(raw);
68
+ } catch {
69
+ return empty;
70
+ }
71
+ // ⚠️ A bare list is the shape #805 wrote, and a tab that was open across the two releases has
72
+ // one. It is read as levels without a revealed selection, which is exactly what it means.
73
+ const stored: unknown = Array.isArray(parsed) ? { levels: parsed, revealed: null } : parsed;
74
+ if (typeof stored !== "object" || stored === null) return empty;
75
+ const { levels: rawLevels, revealed } = stored as Partial<TreeExpansionState>;
76
+ if (!Array.isArray(rawLevels)) return empty;
77
+ const levels: ExpandedLevel[] = [];
78
+ for (const entry of rawLevels.slice(0, MaxRememberedLevels)) {
79
+ const level = levelFrom(entry);
80
+ if (level === null) continue;
81
+ // ⚠️ A bad entry is skipped rather than emptying the answer: one unreadable line says
82
+ // nothing about the others, and a tree that closes completely because of it is the very
83
+ // failure this file exists to remove. Duplicates go the same way — two identical entries
84
+ // would be two queries for one level.
85
+ if (levels.some((seen) => seen.id === level.id && seen.type === level.type)) continue;
86
+ levels.push(level);
87
+ }
88
+ return {
89
+ levels,
90
+ revealed: typeof revealed === "string" && revealed.length > 0 ? revealed : null,
91
+ };
92
+ },
93
+ write(state) {
94
+ try {
95
+ storage?.setItem(
96
+ storageKey,
97
+ JSON.stringify({
98
+ levels: state.levels
99
+ .slice(0, MaxRememberedLevels)
100
+ .map((level) => ({ id: level.id, type: level.type })),
101
+ revealed: state.revealed,
102
+ }),
103
+ );
104
+ } catch {}
105
+ },
106
+ };
107
+ }
@@ -0,0 +1,31 @@
1
+ // One remembered level of the tree. The root is deliberately NOT one of these: it has no row to
2
+ // open, it is always shown, and an entry for it could only ever be a value nothing reads.
3
+ export interface ExpandedLevel {
4
+ id: string;
5
+ type: "folder" | "flow";
6
+ }
7
+
8
+ /**
9
+ * What the tree knows about itself between two lives of the component.
10
+ *
11
+ * ⚠️ `revealed` travels WITH the levels rather than beside them, and that is the whole reason this
12
+ * is an object rather than a list (#809). It names the selection the tree has already opened itself
13
+ * onto. Kept only in memory it dies with the panel — and the panel dies every time the sidebar is
14
+ * collapsed — so a folder the reader closed by hand would be opened again by a reveal that has
15
+ * forgotten it already happened.
16
+ */
17
+ export interface TreeExpansionState {
18
+ levels: readonly ExpandedLevel[];
19
+ revealed: string | null;
20
+ }
21
+
22
+ export interface TreeExpansionStore {
23
+ read(): TreeExpansionState;
24
+ write(state: TreeExpansionState): void;
25
+ }
26
+
27
+ export interface TreeExpansionDeps {
28
+ // A `null` storage disables persistence. `undefined` means "use the browser's", which is not the
29
+ // same thing: a test passes null, a browser without localStorage produces null on its own.
30
+ storage?: Pick<Storage, "getItem" | "setItem"> | null;
31
+ }
@@ -0,0 +1,427 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import { ChevronLeft, Folder } from "lucide-react";
3
+ import type * as React from "react";
4
+ import { useState } from "react";
5
+ import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
6
+ import { useI18n } from "@/i18n/i18n-context.tsx";
7
+ import { Modal } from "@/modal/modal.tsx";
8
+ import { useIntelRouterContext } from "@/router/router-context.ts";
9
+
10
+ // Where a row would land. `null` is the root of the shared tree; the title is carried along because
11
+ // the dialog names the destination and the row that supplied it is not always still on screen.
12
+ export type MoveDestination = { id: string | null; title: string };
13
+
14
+ export type MoveVerdict = "ok" | "self" | "descendant" | "same-place";
15
+
16
+ // The parent a row is filed under. Nodes and Flows keep their own record (ADR-0004), so the
17
+ // answer is read from whichever one this row is, never from a merged shape.
18
+ //
19
+ // ⚠️ This is the parent the RECORD names, which since #429 is not always the level the row is shown
20
+ // in: a shared document is filed inside the sharer's folder and reaches the root only through the
21
+ // share. Everything about a move — which level loses the row, which one keeps it, and whether the
22
+ // root is "same place" — is a question about the level on screen, so the level travels with the
23
+ // move instead of being read back out of the record here (#446).
24
+ export function parentOf(entry: TreeEntry): string | null {
25
+ return entry.type === "node" ? entry.node.parentId : entry.flow.parentId;
26
+ }
27
+
28
+ // One level of the shared tree, as a query key. The move writes into two of them and the picker
29
+ // reads a third, so the shape is stated once rather than spelled out at each of those places.
30
+ export function treeLevelKey(parentId: string | null): readonly unknown[] {
31
+ return ["tree", parentId];
32
+ }
33
+
34
+ /**
35
+ * Every level of the tree at once — what a write that changed what is IN a folder makes stale.
36
+ *
37
+ * ⚠️ Not the level that was written into: that is the obvious half, and it was the whole of #519.
38
+ * A folder ROW carries `openable` — since #59 the arrow only stands where there is something to
39
+ * open — and that is a statement about the folder's CONTENTS living one level ABOVE it. Refresh
40
+ * only the level below and the row keeps yesterday's answer: the first thing filed into an EMPTY
41
+ * folder has no arrow to appear under, `reveal` opens a level `renderRow` then never renders, and
42
+ * reloading the page was the only thing that read the row again. The other direction is the same
43
+ * bug from the other side — the last child moved or archived out leaves an arrow promising a level
44
+ * that is empty.
45
+ *
46
+ * ⚠️ A prefix rather than "the level, plus the level its folder row sits in", because that second
47
+ * level is not always the one the record names: since #429 a shared row also stands at the root
48
+ * (`levelsToClear`).
49
+ *
50
+ * ⚠️ What it costs is one request per level the reader has OPEN — bounded by the screen, not by how
51
+ * long the tab has been sitting there. `app-tree.tsx` loads a level only while the row it hangs on
52
+ * still stands in the level above it, so a folder that was opened and has since left the tree —
53
+ * archived, moved away, its share revoked — takes its query with it rather than being re-read by
54
+ * every write for the rest of the session.
55
+ *
56
+ * The same prefix is what `archive.tsx` has always used for the same reason: a row can come back
57
+ * anywhere, and the screen writing it does not know where.
58
+ */
59
+ export const allTreeLevelsKey: readonly unknown[] = ["tree"];
60
+
61
+ /**
62
+ * Which cached levels a move has to take the row out of.
63
+ *
64
+ * ⚠️ Normally one, and the caller names it. When it does not — the title line has no tree level to
65
+ * name — the answer is not a guess but the full set of places the row can be standing in, and that
66
+ * set has exactly two members.
67
+ *
68
+ * It is two rather than "all of them" because of how a level is read (`db.ts`): every level except
69
+ * the root is strictly `parent_id IS ?`, so there the level and the record always agree. Only the
70
+ * root also collects the top of every readable region — a shared row whose own record names the
71
+ * sharer's folder (#429). So a row is either in the level its record names, or in the root.
72
+ *
73
+ * Cleaning up one level too many costs a re-read of a list the reader is looking at anyway.
74
+ * Cleaning up one too few is #446: the row stays where it no longer is.
75
+ */
76
+ export function levelsToClear(
77
+ entry: TreeEntry,
78
+ level: string | null | undefined,
79
+ ): (string | null)[] {
80
+ if (level !== undefined) return [level];
81
+ const recorded = parentOf(entry);
82
+ return recorded === null ? [null] : [recorded, null];
83
+ }
84
+
85
+ // The optimistic row, filed where it is about to land. Its own parent has to travel with it, or the
86
+ // plus on the moved row would still file into the folder it just left.
87
+ function withParent(entry: TreeEntry, parentId: string | null): TreeEntry {
88
+ return entry.type === "node"
89
+ ? { ...entry, node: { ...entry.node, parentId } }
90
+ : { ...entry, flow: { ...entry.flow, parentId } };
91
+ }
92
+
93
+ /**
94
+ * ⚠️ The trap this ticket is built around: a node must not travel into its own descendants. The
95
+ * service refuses it (`move_cycle`, `nodes.ts:421`), but a refusal that only arrives once the
96
+ * mouse button is already up is the one answer worse than no dragging at all — the drop has to be
97
+ * offered or withheld while the pointer is still moving.
98
+ *
99
+ * It costs no request. The tree already carries the ancestor path of every rendered row for its own
100
+ * cycle guard (`app-tree.tsx`), and "is this target inside the row I am dragging" is exactly the
101
+ * question that path answers: the dragged id appears above the target, or it does not.
102
+ */
103
+ export function moveVerdict(input: {
104
+ draggedId: string;
105
+ // ⚠️ Two answers to "where is this row", and since #429 they can differ — which is why both are
106
+ // asked (#446). `draggedParentId` is what the record says and what a move would overwrite;
107
+ // `draggedLevel` is the level the row is shown in.
108
+ //
109
+ // "Same place" has to be true for either, and for different reasons:
110
+ //
111
+ // - The record: dropping a row on the folder its own record already names writes the value that
112
+ // is already there. Nothing happens, and offering it says something will.
113
+ // - The level: a shared row reaches the root only through the share, and its record names the
114
+ // sharer's folder. Dropping it on the root looks like a no-op to the reader — but it would
115
+ // write `parentId = null` and lift the document OUT of the folder it was shared from. That is
116
+ // the opposite of nothing, done by somebody who was told nothing would happen.
117
+ //
118
+ // `draggedLevel` is optional because a surface without a tree has no level to name — the resource
119
+ // menu opens the same dialog from a title line. There the record is the only answer there is.
120
+ draggedParentId: string | null;
121
+ draggedLevel?: string | null | undefined;
122
+ targetId: string | null;
123
+ targetAncestors: ReadonlySet<string>;
124
+ }): MoveVerdict {
125
+ if (input.targetId === input.draggedId) return "self";
126
+ if (input.targetId !== null && input.targetAncestors.has(input.draggedId)) return "descendant";
127
+ if (input.targetId === input.draggedParentId) return "same-place";
128
+ if (input.draggedLevel !== undefined && input.targetId === input.draggedLevel) {
129
+ return "same-place";
130
+ }
131
+ return "ok";
132
+ }
133
+
134
+ // ⚠️ Five refusals, five sentences. The service tells them apart by `code`
135
+ // (`nodes.ts:331/335/338/361`, `flows.ts:205–217/752`), and so does this: one message for all
136
+ // of them would leave the reader guessing which of "you may not write there", "that is not a
137
+ // folder", "that would be a loop", "that destination is archived" and "somebody else was faster"
138
+ // they have just hit.
139
+ //
140
+ // ⚠️ `parent_archived` reaches this since #679: before that the service raised it for tasks only,
141
+ // which never travel through the tree's move. The board is not a drop target for an archived node,
142
+ // so a person meets it rarely — but a move over MCP or HTTP hits it, and the tree reloads from the
143
+ // same error.
144
+ export function moveErrorKey(error: unknown): string {
145
+ const code =
146
+ typeof error === "object" && error !== null && "code" in error
147
+ ? String((error as { code: unknown }).code)
148
+ : null;
149
+ switch (code) {
150
+ case "node_forbidden":
151
+ case "flow_edit_forbidden":
152
+ return "tree.move.failed.forbidden";
153
+ case "parent_not_folder":
154
+ return "tree.move.failed.notFolder";
155
+ case "parent_archived":
156
+ return "tree.move.failed.archived";
157
+ case "move_cycle":
158
+ return "tree.move.failed.cycle";
159
+ case "update_conflict":
160
+ case "flow_update_conflict":
161
+ return "tree.move.failed.conflict";
162
+ case "flow_parent_not_found":
163
+ return "tree.move.failed.gone";
164
+ default:
165
+ return "tree.move.failed.other";
166
+ }
167
+ }
168
+
169
+ /**
170
+ * The move, named before it happens.
171
+ *
172
+ * ⚠️ Since #16 a share hangs on the folder and reaches everything beneath it (`db-grants.ts`,
173
+ * `nodeVerbQuery`), so moving a document is a change of who may read it — without the word "share"
174
+ * appearing anywhere. That is why every move is confirmed rather than performed on drop.
175
+ *
176
+ * What this deliberately does not do is name the people. `listGrants` answers with the
177
+ * grants sitting on that one folder, needs `share` on it (`nodes.ts:459`), and says nothing
178
+ * about the grants above it that reach down. A list built from that would be short of exactly the
179
+ * entries that matter and would read as complete — "nobody is on this folder" while an ancestor
180
+ * opens it to the whole organization. Naming the mechanism truthfully beats enumerating it wrongly;
181
+ * an effective-audience answer is the server's to give, and no endpoint offers one today.
182
+ */
183
+ export function MoveDialog({
184
+ entry,
185
+ level,
186
+ close,
187
+ submit,
188
+ }: {
189
+ entry: TreeEntry;
190
+ // `undefined` where no tree level was rendered — the verdict then rests on the record alone.
191
+ level: string | null | undefined;
192
+ close(): void;
193
+ submit(destination: MoveDestination): void;
194
+ }) {
195
+ const { data } = useIntelRouterContext();
196
+ const i18n = useI18n();
197
+ // The browsed path, root first. It is also the descendant guard for the keyboard route: the row
198
+ // being moved is never offered, so its subtree can never be entered in the first place.
199
+ const [path, setPath] = useState<MoveDestination[]>([]);
200
+ const here: MoveDestination = path.at(-1) ?? { id: null, title: i18n.t("tree.move.root") };
201
+ const children = useQuery({
202
+ queryKey: ["tree", here.id],
203
+ queryFn: async () => await data.listTreeChildren(here.id),
204
+ });
205
+ const folders = (children.data ?? []).filter(
206
+ (child) => child.kind === "folder" && child.id !== entry.id,
207
+ );
208
+ const verdict = moveVerdict({
209
+ draggedId: entry.id,
210
+ draggedParentId: parentOf(entry),
211
+ // Handed down by the caller, because the dialog is opened from two places and only one of them
212
+ // has a tree level to name (#446).
213
+ draggedLevel: level,
214
+ targetId: here.id,
215
+ targetAncestors: new Set(path.map((step) => step.id).filter((id) => id !== null)),
216
+ });
217
+
218
+ return (
219
+ <Modal title={i18n.t("tree.move.title", { title: entry.title })} close={close}>
220
+ {
221
+ <div className="space-y-3">
222
+ <p className="text-sm text-muted-foreground">{i18n.t("tree.move.pick")}</p>
223
+ <div className="flex items-center gap-2">
224
+ <button
225
+ type="button"
226
+ disabled={path.length === 0}
227
+ onClick={() => setPath((current) => current.slice(0, -1))}
228
+ aria-label={i18n.t("tree.move.up")}
229
+ className="rounded-md border p-1 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
230
+ >
231
+ <ChevronLeft aria-hidden="true" className="size-4" />
232
+ </button>
233
+ <p className="min-w-0 flex-1 truncate text-sm font-medium">{here.title}</p>
234
+ </div>
235
+ <ul className="max-h-56 space-y-1 overflow-y-auto">
236
+ {children.isPending ? (
237
+ <li className="px-2 py-1.5 text-sm text-muted-foreground">
238
+ {i18n.t("common.loading")}
239
+ </li>
240
+ ) : folders.length === 0 ? (
241
+ <li className="px-2 py-1.5 text-sm text-muted-foreground">
242
+ {i18n.t("tree.move.noFolders")}
243
+ </li>
244
+ ) : (
245
+ folders.map((folder) => (
246
+ <li key={folder.id}>
247
+ <button
248
+ type="button"
249
+ onClick={() => setPath((current) => [...current, folder])}
250
+ aria-label={i18n.t("tree.move.open", { title: folder.title })}
251
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
252
+ >
253
+ <Folder aria-hidden="true" className="size-4 shrink-0" />
254
+ <span className="truncate">{folder.title}</span>
255
+ </button>
256
+ </li>
257
+ ))
258
+ )}
259
+ </ul>
260
+ {/* ⚠️ This IS the move, not a choice that opens a confirmation. Until #776 it set a
261
+ destination and a second screen asked again, carrying a sentence about inherited
262
+ sharing; Jack removed both on 2026-08-24 — inheritance is what a person expects of a
263
+ folder tree, and a warning that says the same self-evident thing on every move is one
264
+ nobody reads by the third time. The refusals are untouched: `moveErrorKey` still words
265
+ all six, and a silent failure would be worse than the dialog ever was. */}
266
+ <button
267
+ type="button"
268
+ disabled={verdict !== "ok"}
269
+ onClick={() => submit(here)}
270
+ className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
271
+ >
272
+ {i18n.t("tree.move.here", { title: here.title })}
273
+ </button>
274
+ {verdict === "same-place" ? (
275
+ <p className="text-sm text-muted-foreground">{i18n.t("tree.move.alreadyThere")}</p>
276
+ ) : null}
277
+ </div>
278
+ }
279
+ </Modal>
280
+ );
281
+ }
282
+
283
+ /**
284
+ * Moving a row, wherever it is asked for.
285
+ *
286
+ * ⚠️ It is asked for from two places now (#58): a drop on a folder in the tree, and the menu in the
287
+ * title line — the keyboard route, which since #58 is the only one the menu still has anywhere. Both
288
+ * are the same move, so both take it from here rather than each carrying its own mutation. Two
289
+ * copies of an optimistic update that rewrites two cached levels is how a rejected move ends up
290
+ * leaving a row standing twice.
291
+ *
292
+ * ⚠️ Optimistic, never authoritative. The row is lifted out of one level and dropped into the other
293
+ * before the server answers, and every refusal — 403, `parent_not_folder`, `move_cycle`,
294
+ * `update_conflict` — puts both levels back exactly as they were and then re-reads them.
295
+ *
296
+ * The caller renders `dialog` where it likes and words `error` itself with `moveErrorKey`: the tree
297
+ * says it in the sidebar, the menu over the screen, and neither position belongs to the move.
298
+ */
299
+ export function useTreeMove({
300
+ onMoved,
301
+ }: {
302
+ // What the caller wants to do with the destination once the move is through — the tree opens that
303
+ // folder so the row is where the eye follows it. Nobody else has a tree to open.
304
+ onMoved?: ((destination: MoveDestination) => void) | undefined;
305
+ } = {}): {
306
+ // `level` is the one the row is standing in, and the caller says it or says `undefined` — the tree
307
+ // knows it from the level it rendered into, the title line has no tree at all. Guessing it here
308
+ // with `parentOf` is what filed a moved row into a cache level that does not exist (#446).
309
+ //
310
+ // ⚠️ `undefined` is not "root". It means the level is unknown, and it is handled by cleaning up
311
+ // BOTH levels such a row can stand in — see `levelsToClear`.
312
+ start(
313
+ entry: TreeEntry,
314
+ level: string | null | undefined,
315
+ destination: MoveDestination | null,
316
+ ): void;
317
+ error: unknown;
318
+ dialog: React.ReactNode;
319
+ } {
320
+ const { data } = useIntelRouterContext();
321
+ const queryClient = useQueryClient();
322
+ const [moving, setMoving] = useState<{
323
+ entry: TreeEntry;
324
+ level: string | null | undefined;
325
+ initial: MoveDestination | null;
326
+ } | null>(null);
327
+
328
+ const move = useMutation({
329
+ mutationFn: async ({
330
+ entry,
331
+ destination,
332
+ }: {
333
+ entry: TreeEntry;
334
+ level: string | null | undefined;
335
+ destination: MoveDestination;
336
+ }) => {
337
+ // `baseUpdatedAt` travels with the move: it is what turns a concurrent edit into a 409 the
338
+ // view can act on instead of an overwrite nobody notices.
339
+ if (entry.type === "flow") {
340
+ await data.updateFlow({
341
+ flowId: entry.id,
342
+ baseUpdatedAt: entry.flow.updatedAt,
343
+ parentId: destination.id,
344
+ idempotencyKey: crypto.randomUUID(),
345
+ });
346
+ } else {
347
+ await data.updateNode({
348
+ nodeId: entry.id,
349
+ baseUpdatedAt: entry.node.updatedAt,
350
+ parentId: destination.id,
351
+ idempotencyKey: crypto.randomUUID(),
352
+ });
353
+ }
354
+ },
355
+ onMutate: async ({ entry, level, destination }) => {
356
+ const fromKeys = levelsToClear(entry, level).map(treeLevelKey);
357
+ const toKey = treeLevelKey(destination.id);
358
+ await Promise.all(
359
+ [...fromKeys, toKey].map(async (queryKey) => await queryClient.cancelQueries({ queryKey })),
360
+ );
361
+ const snapshot = [
362
+ ...fromKeys.map((key) => [key, queryClient.getQueryData<TreeEntry[]>(key)] as const),
363
+ [toKey, queryClient.getQueryData<TreeEntry[]>(toKey)] as const,
364
+ ];
365
+ for (const key of fromKeys) {
366
+ queryClient.setQueryData<TreeEntry[]>(key, (current) =>
367
+ current?.filter((row) => row.id !== entry.id),
368
+ );
369
+ }
370
+ // A level nobody has opened stays unloaded: writing one here would show a folder's contents
371
+ // that were never read.
372
+ queryClient.setQueryData<TreeEntry[]>(toKey, (current) =>
373
+ current === undefined
374
+ ? current
375
+ : [...current.filter((row) => row.id !== entry.id), withParent(entry, destination.id)],
376
+ );
377
+ return { snapshot };
378
+ },
379
+ onError: (_error, _variables, context) => {
380
+ for (const [key, value] of context?.snapshot ?? []) queryClient.setQueryData(key, value);
381
+ },
382
+ onSuccess: (_result, { destination }) => onMoved?.(destination),
383
+ onSettled: async (_result, _error, { entry }) => {
384
+ await Promise.all([
385
+ // Both ends of the move and the rows they hang under: a folder that just received its first
386
+ // row needs its arrow, and one that lost its last has to give it back (#519).
387
+ queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
388
+ queryClient.invalidateQueries({
389
+ queryKey: [entry.type === "flow" ? "flows" : "node-graph"],
390
+ }),
391
+ queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
392
+ ]);
393
+ },
394
+ });
395
+
396
+ return {
397
+ /**
398
+ * ⚠️ A destination that is already known writes straight away — a drop names its folder, and
399
+ * asking again about the folder the pointer just landed on is a question with one answer
400
+ * (#776). Only the route that has NO destination opens the picker: the menu, which is the
401
+ * keyboard path.
402
+ *
403
+ * The optimistic update in `onMutate` is what makes this safe to do without a screen in
404
+ * between: the row moves at once and every refusal puts both levels back.
405
+ */
406
+ start(entry, level, destination) {
407
+ move.reset();
408
+ if (destination !== null) {
409
+ move.mutate({ entry, level, destination });
410
+ return;
411
+ }
412
+ setMoving({ entry, level, initial: null });
413
+ },
414
+ error: move.isError ? move.error : null,
415
+ dialog: moving ? (
416
+ <MoveDialog
417
+ entry={moving.entry}
418
+ level={moving.level}
419
+ close={() => setMoving(null)}
420
+ submit={(destination) => {
421
+ setMoving(null);
422
+ move.mutate({ entry: moving.entry, level: moving.level, destination });
423
+ }}
424
+ />
425
+ ) : null,
426
+ };
427
+ }