@kabmo-labs/kabmo 0.7.0 → 0.9.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 (351) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +403 -384
  4. package/dist/client/static/js/{index-0wvbca0b.js → index-bpptfkz2.js} +905 -875
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +10 -1
  7. package/src/client/components/common/code-surface/documents.test.ts +120 -0
  8. package/src/client/components/common/code-surface/documents.ts +201 -0
  9. package/src/client/components/common/code-surface/extensions.test.ts +110 -0
  10. package/src/client/components/common/code-surface/extensions.ts +162 -0
  11. package/src/client/components/common/code-surface/handle.ts +102 -0
  12. package/src/client/components/common/code-surface/index.tsx +253 -314
  13. package/src/client/components/common/code-surface/keymap.test.ts +133 -0
  14. package/src/client/components/common/code-surface/keymap.ts +267 -0
  15. package/src/client/components/common/code-surface/marks.ts +74 -0
  16. package/src/client/components/common/code-surface/shiki.ts +234 -0
  17. package/src/client/components/common/code-surface/sync.test.ts +185 -0
  18. package/src/client/components/common/code-surface/sync.ts +58 -0
  19. package/src/client/components/common/code-surface/theme.test.ts +202 -0
  20. package/src/client/components/common/code-surface/theme.ts +111 -0
  21. package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
  22. package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
  23. package/src/client/components/common/header-controls/index.tsx +24 -1
  24. package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
  25. package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
  26. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  27. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  28. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  29. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  30. package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
  31. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
  32. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  33. package/src/client/components/layout/desktop-layout/index.tsx +55 -69
  34. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  35. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
  36. package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
  37. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
  38. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  39. package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
  40. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  41. package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
  42. package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
  43. package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
  44. package/src/client/components/mobile/LayoutWrapper.tsx +23 -16
  45. package/src/client/components/mobile/RightSidebar.tsx +21 -10
  46. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  47. package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
  48. package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
  49. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  50. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  51. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  52. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  53. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  54. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
  55. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
  56. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
  57. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
  58. package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
  59. package/src/client/components/mobile/worktree-session.ts +51 -0
  60. package/src/client/components/settings/Modal.tsx +8 -0
  61. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  62. package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
  63. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  64. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  65. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +146 -0
  66. package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
  67. package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
  68. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +36 -55
  69. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
  70. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  71. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  72. package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
  73. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  74. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  75. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  76. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  77. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  78. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  79. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  80. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  81. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  82. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
  83. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +32 -17
  84. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  85. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  86. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  87. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  88. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  89. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  90. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  91. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  92. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  93. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  94. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  95. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  96. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  97. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  98. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  99. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  100. package/src/client/components/workspace/editor/Body.tsx +4 -7
  101. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  102. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  103. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  104. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  105. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  106. package/src/client/components/workspace/editor/index.tsx +7 -9
  107. package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
  108. package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
  109. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  110. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  111. package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
  112. package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
  113. package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
  114. package/src/client/components/workspace/file-explorer/filter.ts +47 -0
  115. package/src/client/components/workspace/file-explorer/index.tsx +77 -64
  116. package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
  117. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  118. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  119. package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
  120. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  121. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  122. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  123. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  124. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  125. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  126. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  127. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  128. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  129. package/src/client/hooks/chat/timeline/index.ts +27 -28
  130. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  131. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  132. package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
  133. package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
  134. package/src/client/hooks/chat/timeline/send.test.ts +2 -0
  135. package/src/client/hooks/chat/timeline/send.ts +11 -1
  136. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  137. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  138. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  139. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  140. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  141. package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
  142. package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
  143. package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
  144. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  145. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  146. package/src/client/hooks/editor/find-state.ts +1 -1
  147. package/src/client/hooks/editor/theme.test.ts +206 -0
  148. package/src/client/hooks/editor/theme.ts +82 -0
  149. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  150. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  151. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  152. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  153. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  154. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  155. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  156. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  157. package/src/client/hooks/ui/long-press.test.ts +174 -0
  158. package/src/client/hooks/ui/long-press.ts +112 -0
  159. package/src/client/hooks/ui/settings-modal.ts +56 -0
  160. package/src/client/hooks/ui/updates/shell-download.test.ts +75 -0
  161. package/src/client/hooks/ui/updates/shell-download.ts +153 -28
  162. package/src/client/hooks/ui/updates/shell-offer.test.ts +213 -0
  163. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  164. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  165. package/src/client/hooks/workspace/diff-file.ts +29 -10
  166. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  167. package/src/client/hooks/workspace/editor-view.ts +61 -0
  168. package/src/client/hooks/workspace/file-create.ts +41 -11
  169. package/src/client/hooks/workspace/file-listing.ts +56 -1
  170. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  171. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  172. package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
  173. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  174. package/src/client/hooks/workspace/worktree/index.ts +165 -0
  175. package/src/client/lib/desktop/bridge.ts +45 -0
  176. package/src/client/lib/search-reveal.ts +1 -1
  177. package/src/client/main.tsx +5 -1
  178. package/src/client/tailwind.css +56 -8
  179. package/src/server/index.ts +7 -2
  180. package/src/server/lib/auth/guard.test.ts +28 -0
  181. package/src/server/lib/auth/guard.ts +10 -0
  182. package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
  183. package/src/server/lib/db/schema.ts +4 -0
  184. package/src/server/lib/db/worktree-registry.server.ts +91 -0
  185. package/src/server/lib/fs/canonical.ts +52 -0
  186. package/src/server/lib/fs/relative-path.ts +48 -0
  187. package/src/server/lib/fs/root-worktree.test.ts +116 -0
  188. package/src/server/lib/fs/root.ts +39 -10
  189. package/src/server/lib/fs/worktree/carry.ts +134 -0
  190. package/src/server/lib/fs/worktree/create.test.ts +249 -0
  191. package/src/server/lib/fs/worktree/create.ts +262 -0
  192. package/src/server/lib/fs/worktree/errors.ts +43 -0
  193. package/src/server/lib/fs/worktree/list.test.ts +207 -0
  194. package/src/server/lib/fs/worktree/list.ts +142 -0
  195. package/src/server/lib/fs/worktree/location.test.ts +206 -0
  196. package/src/server/lib/fs/worktree/location.ts +114 -0
  197. package/src/server/lib/fs/worktree/project-id.ts +53 -0
  198. package/src/server/lib/fs/worktree/remove.test.ts +242 -0
  199. package/src/server/lib/fs/worktree/remove.ts +152 -0
  200. package/src/server/lib/fs/worktree/settings.server.ts +56 -0
  201. package/src/server/lib/fs/worktree/topology.ts +142 -0
  202. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  203. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  204. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  205. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  206. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  207. package/src/server/lib/lifecycle/probe.ts +121 -13
  208. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  209. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  210. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  211. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  212. package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
  213. package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
  214. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  215. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  216. package/src/server/lib/panels/local-edits.server.ts +33 -0
  217. package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
  218. package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
  219. package/src/server/lib/panels/sync.server.test.ts +125 -147
  220. package/src/server/lib/panels/sync.server.ts +153 -48
  221. package/src/server/lib/realtime/signals.server.ts +10 -0
  222. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  223. package/src/server/lib/realtime/topics.server.ts +16 -0
  224. package/src/server/lib/updates/shell-assets.test.ts +114 -0
  225. package/src/server/lib/updates/shell-assets.ts +200 -0
  226. package/src/server/lib/updates/shell-cache.ts +105 -0
  227. package/src/server/lib/updates/shell-pages.test.ts +187 -0
  228. package/src/server/lib/updates/shell-pages.ts +157 -0
  229. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  230. package/src/server/lib/updates/shell-release.test.ts +59 -71
  231. package/src/server/lib/updates/shell-release.ts +178 -198
  232. package/src/server/routes/fs/action-create.test.ts +18 -13
  233. package/src/server/routes/fs/action-nested.test.ts +184 -0
  234. package/src/server/routes/fs/action.ts +47 -16
  235. package/src/server/routes/fs/git-merge.test.ts +11 -1
  236. package/src/server/routes/fs/read.test.ts +24 -7
  237. package/src/server/routes/fs/scope.test.ts +15 -10
  238. package/src/server/routes/fs/scope.ts +3 -14
  239. package/src/server/routes/index.ts +2 -0
  240. package/src/server/routes/updates/android.ts +6 -1
  241. package/src/server/routes/updates/desktop.ts +59 -0
  242. package/src/server/routes/updates/index.ts +2 -0
  243. package/src/server/routes/updates/ios.ts +5 -1
  244. package/src/server/routes/worktrees/create.ts +138 -0
  245. package/src/server/routes/worktrees/index.ts +25 -0
  246. package/src/server/routes/worktrees/list.ts +134 -0
  247. package/src/server/routes/worktrees/remove.ts +102 -0
  248. package/src/server/routes/worktrees/routes.test.ts +326 -0
  249. package/src/server/routes/worktrees/shared.ts +90 -0
  250. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  251. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  252. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  253. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  254. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  255. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  256. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  257. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  258. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  259. package/src/shared/lib/code/editor/keymap.ts +3 -3
  260. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  261. package/src/shared/lib/code/editor/lines.ts +8 -48
  262. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  263. package/src/shared/lib/code/editor/marks.ts +13 -25
  264. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  265. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  266. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  267. package/src/shared/lib/code/editor/themes.ts +165 -0
  268. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  269. package/src/shared/lib/code/highlight-memo.ts +149 -0
  270. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  271. package/src/shared/lib/code/highlighter.ts +3 -1
  272. package/src/shared/lib/code/idle.ts +37 -0
  273. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  274. package/src/shared/lib/code/outline/rows.ts +94 -0
  275. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  276. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  277. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  278. package/src/shared/lib/fs/file-kind.ts +47 -13
  279. package/src/shared/lib/fs/file-tree.test.ts +183 -0
  280. package/src/shared/lib/fs/file-tree.ts +159 -0
  281. package/src/shared/lib/fs/file-url.test.ts +52 -0
  282. package/src/shared/lib/fs/file-url.ts +37 -0
  283. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  284. package/src/shared/lib/fs/patch-sides.ts +139 -0
  285. package/src/shared/lib/fs/paths.test.ts +24 -1
  286. package/src/shared/lib/fs/paths.ts +17 -0
  287. package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
  288. package/src/shared/lib/omp/session/prewarm.ts +7 -0
  289. package/src/shared/lib/realtime/protocol.ts +16 -0
  290. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  291. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  292. package/src/shared/lib/updates/shell-arch.ts +54 -0
  293. package/src/shared/lib/updates/shell-ua.ts +24 -4
  294. package/src/shared/lib/workspace/active-project.ts +23 -0
  295. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  296. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  297. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  298. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  299. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  300. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  301. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  302. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  303. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  304. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  305. package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
  306. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  307. package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
  308. package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
  309. package/src/shared/lib/workspace/worktree/types.ts +104 -0
  310. package/src/shared/types/fs.ts +10 -0
  311. package/src/shared/types/realtime.ts +29 -0
  312. package/src/shared/types/settings/state.ts +10 -0
  313. package/src/shared/types/workspace.ts +14 -0
  314. package/src/test-support/file-explorer-disk.test-util.ts +76 -0
  315. package/src/test-support/file-explorer.test-util.ts +71 -40
  316. package/src/test-support/loopback.ts +183 -0
  317. package/dist/client/static/js/index-nzyjf92m.css +0 -1
  318. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  319. package/src/client/components/common/code-editor/handler.ts +0 -175
  320. package/src/client/components/common/code-editor/index.tsx +0 -314
  321. package/src/client/components/common/code-surface/measure.ts +0 -148
  322. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  323. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  324. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  325. package/src/client/hooks/editor/scroll-container.ts +0 -26
  326. package/src/client/hooks/editor/use-code-window.ts +0 -100
  327. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  328. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  329. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  330. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  331. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  332. package/src/shared/lib/code/editor/commands.ts +0 -260
  333. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  334. package/src/shared/lib/code/editor/edits.ts +0 -122
  335. package/src/shared/lib/code/editor/history.test.ts +0 -118
  336. package/src/shared/lib/code/editor/history.ts +0 -101
  337. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  338. package/src/shared/lib/code/editor/layers.ts +0 -67
  339. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  340. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  341. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  342. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  343. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  344. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  345. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  346. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  347. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  348. package/src/shared/lib/code/lazy-window.ts +0 -156
  349. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  350. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  351. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Installing a desktop shell's own update, from the console.
8
+ *
9
+ * ## Why the shell does the work
10
+ *
11
+ * A page cannot replace the executable it runs inside, and a browser download
12
+ * leaves a file in a folder the user has to find and open. So the button asks
13
+ * the SHELL (`update_apply` over the bridge), and the shell downloads, verifies,
14
+ * replaces and relaunches. What this hook owns is only the part a page can own:
15
+ * the run's state, and reporting the outcome where the user clicked.
16
+ *
17
+ * ## Why it is a hook rather than a function at the call site
18
+ *
19
+ * Both sidebars draw this control and each one unmounts at a different moment (a
20
+ * phone closes the drawer, a desktop swaps sessions). The state has to live with
21
+ * the control that shows it, and the two must report the SAME vocabulary — so the
22
+ * phases and their sentences are here, not in either footer.
23
+ *
24
+ * ## A failure is always a sentence
25
+ *
26
+ * The shell sends its own reason ("the downloaded file is not a signed Kabmo
27
+ * build", "the installer refused with code 5"), which is the only thing that can
28
+ * tell a user what to do next. Nothing here replaces it with a generic failure,
29
+ * and nothing reports success before the shell said so.
30
+ */
31
+
32
+ import { useCallback, useRef, useState } from 'preact/hooks';
33
+ import {
34
+ applyShellUpdate,
35
+ bridgeError,
36
+ isDesktopShell,
37
+ type ShellUpdateOutcome,
38
+ } from '@/client/lib/desktop/bridge';
39
+ import type { ShellDownload } from '@/client/hooks/ui/updates/shell-download';
40
+
41
+ /** What the control is doing right now. */
42
+ export type ShellUpdatePhase = 'idle' | 'working' | 'done' | 'error';
43
+
44
+ export interface ShellUpdateState {
45
+ phase: ShellUpdatePhase;
46
+ /** The shell's own sentence, for `done` and `error`. */
47
+ message: string | null;
48
+ }
49
+
50
+ export interface UseShellUpdateResult extends ShellUpdateState {
51
+ /** Run the update this offer names. */
52
+ apply: (download: ShellDownload) => Promise<void>;
53
+ /**
54
+ * Forget the last run, back to `idle`.
55
+ *
56
+ * The dialog calls this when it OPENS: a run that failed last time must not greet
57
+ * the user with its old refusal the next time they press the control.
58
+ */
59
+ reset: () => void;
60
+ /** True while a run is in flight, so the control can disable itself. */
61
+ working: boolean;
62
+ }
63
+
64
+ /**
65
+ * Whether this offer is something the shell can install by itself.
66
+ *
67
+ * Two facts, and both are required. The artifact must be a FILE (`direct`), and
68
+ * the page must be inside the desktop shell that can install it — an Android
69
+ * shell installs through `PackageInstaller` and iOS cannot install at all, so
70
+ * neither belongs to this path. A browser on a desktop gets neither, because
71
+ * there is no shell to ask.
72
+ */
73
+ export function canSelfUpdate(download: ShellDownload | null | undefined): boolean {
74
+ if (!download) return false;
75
+ if (!download.direct) return false;
76
+ if (download.platform === 'android' || download.platform === 'ios') return false;
77
+ return isDesktopShell();
78
+ }
79
+
80
+ export function useShellUpdate(): UseShellUpdateResult {
81
+ const [state, setState] = useState<ShellUpdateState>({ phase: 'idle', message: null });
82
+ // A second click while the shell is replacing itself would be a second
83
+ // installer beside the first, so the run is claimed by a ref rather than by
84
+ // the render state — a fast double click lands before the state is read.
85
+ const running = useRef(false);
86
+
87
+ const apply = useCallback(async (download: ShellDownload) => {
88
+ if (running.current) return;
89
+ running.current = true;
90
+ setState({ phase: 'working', message: null });
91
+
92
+ try {
93
+ const outcome: ShellUpdateOutcome = await applyShellUpdate(download.url, download.latest);
94
+ setState({
95
+ phase: outcome.success ? 'done' : 'error',
96
+ // The shell's sentence is the report. `?? null` rather than a fallback
97
+ // string: a shell that answered nothing has said nothing, and inventing
98
+ // "Update failed" over it would hide that.
99
+ message: outcome.message ?? null,
100
+ });
101
+ } catch (cause) {
102
+ // A refused command (an older shell with no `update_apply`) lands here,
103
+ // and its own message says so.
104
+ setState({ phase: 'error', message: bridgeError(cause) });
105
+ } finally {
106
+ running.current = false;
107
+ }
108
+ }, []);
109
+
110
+ const reset = useCallback(() => {
111
+ // A run in flight is NOT resettable: the shell is already replacing itself, and
112
+ // reporting `idle` over it would re-arm a control whose second press would start
113
+ // a second installer.
114
+ if (running.current) return;
115
+ setState({ phase: 'idle', message: null });
116
+ }, []);
117
+
118
+ return { ...state, apply, reset, working: state.phase === 'working' };
119
+ }
@@ -1,5 +1,4 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
- import type { DiffContentMode } from '@/client/components/workspace/diff-panel/Toolbar';
3
2
 
4
3
  interface UseFileDiffOptions {
5
4
  filePath: string;
@@ -7,11 +6,14 @@ interface UseFileDiffOptions {
7
6
  isStaged: boolean;
8
7
  repo: string;
9
8
  root?: string;
10
- contentMode: DiffContentMode;
11
9
  }
12
10
 
13
11
  export interface FileDiffState {
14
12
  rawDiff: string;
13
+ /** The document before the change (HEAD, or the index for an unstaged read). */
14
+ oldContent: string;
15
+ /** The document after it (the working file). */
16
+ newContent: string;
15
17
  currentStatus: string;
16
18
  currentStaged: boolean;
17
19
  isLoading: boolean;
@@ -31,8 +33,10 @@ export interface FileDiffState {
31
33
  * earlier read overwriting a newer one, and a dependency on the staged/status
32
34
  * copies re-keying the callback and firing a fetch per render.
33
35
  */
34
- export function useFileDiff({ filePath, status, isStaged, repo, root, contentMode }: UseFileDiffOptions): FileDiffState {
36
+ export function useFileDiff({ filePath, status, isStaged, repo, root }: UseFileDiffOptions): FileDiffState {
35
37
  const [rawDiff, setRawDiff] = useState<string>('');
38
+ const [oldContent, setOldContent] = useState<string>('');
39
+ const [newContent, setNewContent] = useState<string>('');
36
40
  const [currentStatus, setCurrentStatus] = useState<string>(status);
37
41
  const [currentStaged, setCurrentStaged] = useState<boolean>(isStaged);
38
42
  const [isLoading, setIsLoading] = useState<boolean>(true);
@@ -62,11 +66,9 @@ export function useFileDiff({ filePath, status, isStaged, repo, root, contentMod
62
66
  staged: staged ? '1' : '0',
63
67
  repo: repo || '.',
64
68
  });
65
- // `fullContext=1` makes git emit the whole file rather than the hunks.
66
- // Only the user's own toggle changes it, so it is safe as a callback
67
- // dependency (unlike the staged/status copies above, which the action
68
- // path updates while a fetch of its own is already in flight).
69
- if (contentMode === 'full') params.set('fullContext', '1');
69
+ // No `fullContext`: the panel renders both SIDES of the file now, and
70
+ // how much of them is shown is a client-side collapse. Asking the server
71
+ // for the whole file as a patch would be a second answer to that question.
70
72
  if (statusForFetch) params.set('status', statusForFetch);
71
73
  if (root) params.set('root', root);
72
74
 
@@ -74,6 +76,8 @@ export function useFileDiff({ filePath, status, isStaged, repo, root, contentMod
74
76
  const data = (await res.json().catch(() => null)) as {
75
77
  success?: boolean;
76
78
  diff?: string;
79
+ oldContent?: string;
80
+ newContent?: string;
77
81
  status?: string;
78
82
  staged?: boolean;
79
83
  error?: string;
@@ -83,6 +87,11 @@ export function useFileDiff({ filePath, status, isStaged, repo, root, contentMod
83
87
  if (seq !== fetchSeqRef.current) return;
84
88
  if (data?.success) {
85
89
  setRawDiff(data.diff || '');
90
+ // Both sides travel on every read: the merge view compares documents,
91
+ // and a whitespace fold has to be applied to the ORIGINAL, not to the
92
+ // patch (see `foldWhitespaceOnly`).
93
+ setOldContent(data.oldContent ?? '');
94
+ setNewContent(data.newContent ?? '');
86
95
  if (data.status) {
87
96
  statusRef.current = data.status;
88
97
  setCurrentStatus(data.status);
@@ -100,7 +109,7 @@ export function useFileDiff({ filePath, status, isStaged, repo, root, contentMod
100
109
  } finally {
101
110
  if (seq === fetchSeqRef.current) setIsLoading(false);
102
111
  }
103
- }, [filePath, repo, root, contentMode]);
112
+ }, [filePath, repo, root]);
104
113
 
105
114
  useEffect(() => {
106
115
  setCurrentStatus(status);
@@ -124,5 +133,15 @@ export function useFileDiff({ filePath, status, isStaged, repo, root, contentMod
124
133
  }
125
134
  }, []);
126
135
 
127
- return { rawDiff, currentStatus, currentStaged, isLoading, errorMessage, fetchDiff, adoptState };
136
+ return {
137
+ rawDiff,
138
+ oldContent,
139
+ newContent,
140
+ currentStatus,
141
+ currentStaged,
142
+ isLoading,
143
+ errorMessage,
144
+ fetchDiff,
145
+ adoptState,
146
+ };
128
147
  }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The diff panel's git writes, and the conflict-marker read behind its banner.
8
+ *
9
+ * Split out of the panel because these are the only parts of it that talk to
10
+ * the server: the panel used to fire and forget (`fetcher.submit` + an
11
+ * optimistic flip + a 300ms timer), which made a rejected action
12
+ * indistinguishable from a successful one — the badge flipped and the diff
13
+ * silently stayed put. Every write here AWAITS its answer and reports the
14
+ * server's own reason.
15
+ *
16
+ * `revert` is the server's name for "restore this file from the index", and it
17
+ * is the one that handles the untracked case (there the file is removed).
18
+ * Sending `discard`, which no handler matched, returned `{success: true}` for
19
+ * doing nothing at all.
20
+ */
21
+
22
+ import { useCallback, useEffect, useState } from 'preact/hooks';
23
+ import type { ConflictMarkers } from '@/shared/lib/fs/git-unmerged';
24
+
25
+ export interface DiffGitActions {
26
+ /** A write is in flight; the toolbar locks its actions. */
27
+ isBusy: boolean;
28
+ /** The server's refusal, or a transport failure, as a sentence. */
29
+ error: string | null;
30
+ /** The conflict sides' labels from the file's own markers (`{}` when none). */
31
+ markers: ConflictMarkers;
32
+ dismissError: () => void;
33
+ /** Stage or unstage this path, then re-read the diff at the new side. */
34
+ stageUnstage: () => Promise<void>;
35
+ /** Take one side of a conflict (or only stage a resolved file with `''`). */
36
+ resolve: (side: 'ours' | 'theirs' | '') => Promise<void>;
37
+ /** Restore this file from the index — the untracked case removes it. */
38
+ revert: () => Promise<void>;
39
+ }
40
+
41
+ export interface DiffGitActionsArgs {
42
+ filePath: string;
43
+ repo: string;
44
+ root?: string;
45
+ /** The status the panel currently believes (`UU`, `M`, …). */
46
+ status: string;
47
+ staged: boolean;
48
+ /** The panel is showing a conflicted file, so its marker labels are read. */
49
+ conflicted: boolean;
50
+ /** Adopt a state change the server confirmed. */
51
+ adoptState: (patch: { staged: boolean }) => void;
52
+ /** Re-read the diff, optionally at the other side of the index. */
53
+ fetchDiff: (options?: { staged: boolean }) => Promise<void>;
54
+ /** Something on disk changed, so the workspace panels should refresh. */
55
+ onFileSaved?: () => void;
56
+ }
57
+
58
+ export function useDiffGitActions(args: DiffGitActionsArgs): DiffGitActions {
59
+ const { filePath, repo, root, staged, conflicted, adoptState, fetchDiff, onFileSaved } = args;
60
+ const [isBusy, setIsBusy] = useState(false);
61
+ const [error, setError] = useState<string | null>(null);
62
+ const [markers, setMarkers] = useState<ConflictMarkers>({});
63
+
64
+ const run = useCallback(
65
+ async (fields: Record<string, string>): Promise<boolean> => {
66
+ const fd = new FormData();
67
+ for (const [key, value] of Object.entries(fields)) fd.set(key, value);
68
+ fd.set('repo', repo || '.');
69
+ if (root) fd.set('root', root);
70
+ try {
71
+ const res = await fetch('/api/fs/git', { method: 'POST', body: fd });
72
+ const data = (await res.json().catch(() => null)) as { success?: boolean; error?: string } | null;
73
+ if (!res.ok || !data?.success) {
74
+ setError(data?.error || `Request failed (HTTP ${res.status})`);
75
+ return false;
76
+ }
77
+ setError(null);
78
+ return true;
79
+ } catch (err: unknown) {
80
+ setError(err instanceof Error ? err.message : 'Request failed');
81
+ return false;
82
+ }
83
+ },
84
+ [repo, root],
85
+ );
86
+
87
+ /** One write, its busy flag, and the refresh every action ends with. */
88
+ const write = useCallback(
89
+ async (fields: Record<string, string>, after: () => Promise<void>): Promise<void> => {
90
+ setIsBusy(true);
91
+ try {
92
+ if (!(await run(fields))) return;
93
+ await after();
94
+ } finally {
95
+ setIsBusy(false);
96
+ }
97
+ },
98
+ [run],
99
+ );
100
+
101
+ // The marker labels are fetched when the file turns out to be conflicted, and
102
+ // re-fetched when the path changes. Keyed on the path and the status so a
103
+ // resolve (which changes the status away from `UU`) clears the banner.
104
+ useEffect(() => {
105
+ if (!conflicted) {
106
+ setMarkers({});
107
+ return;
108
+ }
109
+ let cancelled = false;
110
+ const params = new URLSearchParams({ conflictMarkers: '1', file: filePath, repo: repo || '.' });
111
+ if (root) params.set('root', root);
112
+ void fetch(`/api/fs/git?${params.toString()}`)
113
+ .then((res) => res.json() as Promise<{ markers?: ConflictMarkers }>)
114
+ .then((body) => {
115
+ if (!cancelled) setMarkers(body.markers ?? {});
116
+ })
117
+ .catch(() => {
118
+ if (!cancelled) setMarkers({});
119
+ });
120
+ return () => {
121
+ cancelled = true;
122
+ };
123
+ }, [conflicted, filePath, repo, root]);
124
+
125
+ return {
126
+ isBusy,
127
+ error,
128
+ markers,
129
+ dismissError: () => setError(null),
130
+ stageUnstage: () =>
131
+ write({ actionType: staged ? 'unstage' : 'stage', file: filePath }, async () => {
132
+ adoptState({ staged: !staged });
133
+ onFileSaved?.();
134
+ await fetchDiff({ staged: !staged });
135
+ }),
136
+ resolve: (side) =>
137
+ write({ actionType: 'resolve_conflict', file: filePath, ...(side ? { side } : {}) }, async () => {
138
+ onFileSaved?.();
139
+ await fetchDiff();
140
+ }),
141
+ revert: () =>
142
+ write({ actionType: 'revert', file: filePath }, async () => {
143
+ onFileSaved?.();
144
+ await fetchDiff();
145
+ }),
146
+ };
147
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The desktop editor column's WIDTH mode, read and written through the session
8
+ * slot every surface follows.
9
+ *
10
+ * Two surfaces read it — the layout (which collapses the columns) and both
11
+ * toolbars (which draw the mode buttons) — so it goes through
12
+ * `useSharedSessionState`: `useSessionState` hands each caller a private copy,
13
+ * and a copy would leave the diff toolbar showing `normal` after the editor
14
+ * toolbar entered compact view. A private copy is right for state one component
15
+ * owns; this one is a decision two toolbars and the layout share.
16
+ *
17
+ * The stored mode only means anything while the editor column is OPEN. With it
18
+ * closed the chat is the only thing on screen, and honouring the mode would
19
+ * leave a blank workspace; the effective mode is therefore DERIVED rather than
20
+ * written back, so closing and reopening the column returns the user to the
21
+ * width they chose.
22
+ */
23
+
24
+ import { useSharedSessionState } from '@/client/hooks/workspace/session-state';
25
+ import {
26
+ DEFAULT_EDITOR_VIEW_MODE,
27
+ EDITOR_VIEW_MODE_KEY,
28
+ editorViewHides,
29
+ isEditorViewMode,
30
+ type EditorViewMode,
31
+ } from '@/shared/lib/workspace/editor-view-mode';
32
+
33
+ export interface EditorViewLayout {
34
+ /** The mode in force, after the editor-open guard. */
35
+ mode: EditorViewMode;
36
+ /** The chat column is collapsed and the editor takes its width. */
37
+ chatHidden: boolean;
38
+ /** The session sidebar is collapsed as well (full view only). */
39
+ sidebarHidden: boolean;
40
+ /** Enter a mode, or return to `normal` — see `toggledEditorViewMode`. */
41
+ setMode: (next: EditorViewMode) => void;
42
+ /** Leave `full` for `compact`: the chat stays hidden, the sidebar returns. */
43
+ revealSidebar: () => void;
44
+ }
45
+
46
+ export function useEditorViewLayout(editorOpen: boolean): EditorViewLayout {
47
+ const [stored, setMode] = useSharedSessionState<EditorViewMode>(EDITOR_VIEW_MODE_KEY, DEFAULT_EDITOR_VIEW_MODE);
48
+ const mode: EditorViewMode = editorOpen && isEditorViewMode(stored) ? stored : DEFAULT_EDITOR_VIEW_MODE;
49
+
50
+ return {
51
+ mode,
52
+ chatHidden: editorViewHides(mode, 'chat'),
53
+ sidebarHidden: editorViewHides(mode, 'sidebar'),
54
+ setMode,
55
+ // Only `full` hides the sidebar, so only `full` has anything to step back
56
+ // from. A caller that asks for the sidebar (the navbar's own toggle) must be
57
+ // able to call this unconditionally: turning `normal` into `compact` would
58
+ // hide the chat for a user who only wanted their sidebar back.
59
+ revealSidebar: () => setMode((previous) => (previous === 'full' ? 'compact' : previous)),
60
+ };
61
+ }
@@ -4,7 +4,8 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Creating one empty file in a named folder of the Files panel, and opening it.
7
+ * Creating one entry in a named folder of the Files panel, and opening it when
8
+ * it is a file.
8
9
  *
9
10
  * Extracted from `useFileActions` because the Files panel needs the SAME flow
10
11
  * from two places that share no target: a row's context menu (create beside the
@@ -12,6 +13,12 @@
12
13
  * root). A second copy would drift on the two things that matter — a refusal
13
14
  * keeps the dialog open with the server's reason beside the field, and a success
14
15
  * OPENS the file, because the user asked for something to work on.
16
+ *
17
+ * The name may carry SEGMENTS (`lib/parse.ts`), so one dialog creates a nested
18
+ * path and every directory that leads to it. The grammar that keeps the result
19
+ * inside the named folder is the server's (`normalizeRelativePath`); this side
20
+ * only reports its refusal, because a second copy of that rule is how the field
21
+ * accepts something the route then rejects.
15
22
  */
16
23
 
17
24
  import { useEffect, useRef, useState } from 'preact/hooks';
@@ -21,30 +28,46 @@ import { useFetcher } from '@/client/lib/router/fetcher';
21
28
  /** The shape `/api/fs/action` answers a create with. */
22
29
  interface CreateResponse {
23
30
  success?: boolean;
24
- /** Relative path of the file that was created. */
31
+ /** `file` or `folder` — what the request asked for. */
32
+ kind?: 'file' | 'folder';
33
+ /** Relative path of the entry that was created. */
25
34
  path?: string;
26
35
  error?: string;
27
36
  }
28
37
 
38
+ /** What a create produced, for the caller to place in its tree. */
39
+ export interface CreatedEntry {
40
+ path: string;
41
+ name: string;
42
+ kind: 'file' | 'folder';
43
+ }
44
+
29
45
  export interface FileCreateDeps {
30
- /** Folder the file goes in, relative to the listed root (`.` for the root). */
46
+ /** Folder the entry goes in, relative to the listed root (`.` for the root). */
31
47
  folderPath: string;
32
48
  rootPath?: string;
33
49
  repo?: string;
34
- /** Fired after the file exists, so the tree re-reads. */
35
- onCreated: () => void;
36
- /** Fired with the new file's relative path, so the panel can open its tab. */
50
+ /**
51
+ * Fired after the entry exists. The tree inserts it directly rather than
52
+ * re-reading: a create answers with the whole result, and a reload would cost
53
+ * one request per open folder to learn what this call already knows.
54
+ */
55
+ onCreated: (entry: CreatedEntry) => void;
56
+ /** Fired with a created FILE's relative path, so the panel can open its tab. */
37
57
  onOpenCreated?: (file: { path: string; name: string }) => void;
38
58
  }
39
59
 
40
60
  export interface FileCreate {
41
61
  open: boolean;
62
+ /** Whether the open dialog makes a folder; a row's menu offers both. */
63
+ kind: 'file' | 'folder';
42
64
  name: string;
43
65
  setName: (value: string) => void;
44
66
  /** The server's refusal, rendered inside the dialog. */
45
67
  error: string | null;
46
68
  busy: boolean;
47
- openDialog: () => void;
69
+ /** Open the dialog; `kind` defaults to a file. */
70
+ openDialog: (kind?: 'file' | 'folder') => void;
48
71
  closeDialog: () => void;
49
72
  submit: (event: FormEvent) => void;
50
73
  }
@@ -52,6 +75,7 @@ export interface FileCreate {
52
75
  export function useFileCreate({ folderPath, rootPath, repo, onCreated, onOpenCreated }: FileCreateDeps): FileCreate {
53
76
  const fetcher = useFetcher<CreateResponse>();
54
77
  const [open, setOpen] = useState(false);
78
+ const [kind, setKind] = useState<'file' | 'folder'>('file');
55
79
  const [name, setName] = useState('');
56
80
  const [error, setError] = useState<string | null>(null);
57
81
 
@@ -71,24 +95,29 @@ export function useFileCreate({ folderPath, rootPath, repo, onCreated, onOpenCre
71
95
  // The fetcher parses the body whatever the status, so a refusal arrives as
72
96
  // `data.error` rather than as a throw — which is what keeps the dialog open.
73
97
  if (data.success && data.path) {
98
+ const createdKind = data.kind === 'folder' ? 'folder' : 'file';
74
99
  setOpen(false);
75
100
  setName('');
76
101
  setError(null);
77
- onCreated();
78
- onOpenCreated?.({ path: data.path, name: data.path.split('/').pop() || data.path });
102
+ const name = data.path.split('/').pop() || data.path;
103
+ onCreated({ path: data.path, name, kind: createdKind });
104
+ // A folder has nothing to open: the user is about to put something in it.
105
+ if (createdKind === 'file') onOpenCreated?.({ path: data.path, name });
79
106
  } else {
80
- setError(data.error || 'Could not create the file');
107
+ setError(data.error || `Could not create the ${kind}`);
81
108
  }
82
109
  // eslint-disable-next-line react-hooks/exhaustive-deps
83
110
  }, [fetcher.state, fetcher.data]);
84
111
 
85
112
  return {
86
113
  open,
114
+ kind,
87
115
  name,
88
116
  setName,
89
117
  error,
90
118
  busy: fetcher.state !== 'idle',
91
- openDialog: () => {
119
+ openDialog: (nextKind: 'file' | 'folder' = 'file') => {
120
+ setKind(nextKind);
92
121
  setName('');
93
122
  setError(null);
94
123
  setOpen(true);
@@ -103,6 +132,7 @@ export function useFileCreate({ folderPath, rootPath, repo, onCreated, onOpenCre
103
132
  formData.append('actionType', 'create');
104
133
  formData.append('path', folderPath);
105
134
  formData.append('name', trimmed);
135
+ if (kind === 'folder') formData.append('kind', 'folder');
106
136
  if (repo && repo !== '.') formData.append('repo', repo);
107
137
  if (rootPath) formData.append('root', rootPath);
108
138
  void fetcher.submit(formData, { method: 'post', action: '/api/fs/action' });
@@ -25,7 +25,7 @@
25
25
 
26
26
  import { useRef, useState } from 'preact/hooks';
27
27
  import type { FsNode } from '@/shared/types';
28
- import { rehydrateTree, setChildrenAt } from '@/shared/lib/fs/file-tree';
28
+ import { placeEntry, rehydrateTree, setChildrenAt, takeEntry, rekeyCache } from '@/shared/lib/fs/file-tree';
29
29
  import { isRecord } from '@/shared/lib/util/guards';
30
30
 
31
31
  /**
@@ -86,6 +86,20 @@ export interface FileListing {
86
86
  loadChildren: (path: string) => Promise<void>;
87
87
  /** Adopt a realtime root snapshot, then re-read the open folders. */
88
88
  adoptRoot: (payload: unknown) => void;
89
+ /**
90
+ * Insert a row the panel just created, without a reload.
91
+ *
92
+ * A create answers with the entry's path, so the tree already knows the whole
93
+ * result; re-reading the root plus every open folder to learn it back would
94
+ * be one request per open folder for an answer the caller holds.
95
+ */
96
+ insertEntry: (entry: { path: string; kind: 'file' | 'folder' }) => void;
97
+ /**
98
+ * Move a row the panel just renamed, without a reload. Returns the folders
99
+ * whose path moved, so the caller can rekey its expansion set — a renamed
100
+ * folder's open branches are still open, under their new names.
101
+ */
102
+ moveEntry: (fromPath: string, toPath: string) => void;
89
103
  /** Forget the root listing and every cached child (a repo switch). */
90
104
  reset: () => void;
91
105
  }
@@ -117,6 +131,8 @@ export function useFileListing({
117
131
  const scopeRef = useRef(scope);
118
132
  scopeRef.current = scope;
119
133
  const listedScopeRef = useRef('');
134
+ /** The absolute base dir the current listing was read for — what `commit` stamps. */
135
+ const listedRootRef = useRef('');
120
136
  const expandedPathsRef = useRef(expandedPaths);
121
137
  expandedPathsRef.current = expandedPaths;
122
138
 
@@ -132,6 +148,7 @@ export function useFileListing({
132
148
  /** Render the tree from the current root listing plus the children cache. */
133
149
  const commit = (requested: string, root: string) => {
134
150
  listedScopeRef.current = requested;
151
+ listedRootRef.current = root;
135
152
  setState({
136
153
  scope: requested,
137
154
  files: rehydrateTree(rootFilesRef.current, childrenCacheRef.current),
@@ -223,9 +240,45 @@ export function useFileListing({
223
240
  childrenCacheRef.current = {};
224
241
  rootFilesRef.current = [];
225
242
  listedScopeRef.current = '';
243
+ listedRootRef.current = '';
226
244
  setState({ scope: '', files: [], root: '' });
227
245
  };
228
246
 
247
+ /**
248
+ * The tree edits below patch the same two sources a read fills — the root
249
+ * list and the children cache — and then commit, so `rehydrateTree` remains
250
+ * the only thing that builds what is rendered. Editing the rendered tree
251
+ * directly would be undone by the next commit, which rebuilds from these.
252
+ */
253
+ const insertEntry = (entry: { path: string; kind: 'file' | 'folder' }): void => {
254
+ const requested = scopeRef.current;
255
+ if (listedScopeRef.current !== requested) return;
256
+ const node: FsNode = {
257
+ name: entry.path.slice(entry.path.lastIndexOf('/') + 1),
258
+ path: entry.path,
259
+ type: entry.kind,
260
+ ignored: false,
261
+ };
262
+ const placed = placeEntry(rootFilesRef.current, childrenCacheRef.current, node);
263
+ if (placed.nodes === rootFilesRef.current) return;
264
+ rootFilesRef.current = placed.nodes;
265
+ childrenCacheRef.current = placed.cache;
266
+ commit(requested, listedRootRef.current);
267
+ };
268
+
269
+ const moveEntry = (fromPath: string, toPath: string): void => {
270
+ const requested = scopeRef.current;
271
+ if (listedScopeRef.current !== requested) return;
272
+ const taken = takeEntry(rootFilesRef.current, childrenCacheRef.current, fromPath);
273
+ if (!taken.entry) return;
274
+ childrenCacheRef.current = rekeyCache(taken.cache, fromPath, toPath);
275
+ const moved: FsNode = { ...taken.entry, path: toPath, name: toPath.slice(toPath.lastIndexOf('/') + 1) };
276
+ const placed = placeEntry(taken.nodes, childrenCacheRef.current, moved);
277
+ rootFilesRef.current = placed.nodes;
278
+ childrenCacheRef.current = placed.cache;
279
+ commit(requested, listedRootRef.current);
280
+ };
281
+
229
282
  // A listing read for another scope is not this one's to render: the panel
230
283
  // shows an empty tree until the current scope's read lands.
231
284
  const current = state.scope === scope;
@@ -237,6 +290,8 @@ export function useFileListing({
237
290
  reload,
238
291
  loadChildren,
239
292
  adoptRoot,
293
+ insertEntry,
294
+ moveEntry,
240
295
  reset,
241
296
  };
242
297
  }