@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,183 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The tree edits that let a create and a rename skip a reload.
8
+ *
9
+ * Each rule here is a way the rendered tree would disagree with the disk:
10
+ *
11
+ * 1. **A nested entry brings the chain that leads to it.** `lib/parse.ts` names
12
+ * a folder the tree does not carry yet; without the chain the row is placed
13
+ * under a key no branch reaches and simply never renders.
14
+ * 2. **A collapsed folder is not guessed at.** Its children are not this
15
+ * tree's knowledge, so an entry created inside one is deliberately NOT
16
+ * inserted — expanding it reads the real directory, which by then holds the
17
+ * new entry. Inserting into a guess would show a listing the server never
18
+ * produced.
19
+ * 3. **A rename re-points every descendant.** A folder's children carry its
20
+ * path as a prefix; moving the folder without rewriting them leaves rows
21
+ * whose `path` no longer resolves and an expansion set keyed by names that
22
+ * are gone.
23
+ * 4. **A rename never loses the moved entry's own loaded children.** Its cache
24
+ * entry is rekeyed with it, so a folder renamed while open stays open with
25
+ * its contents.
26
+ */
27
+
28
+ import { describe, expect, test } from 'bun:test';
29
+ import type { FsNode } from '@/shared/types';
30
+ import {
31
+ baseNameOf,
32
+ parentPathOf,
33
+ placeEntry,
34
+ rekeyCache,
35
+ rekeyExpansion,
36
+ renameEntryAt,
37
+ sortEntries,
38
+ takeEntry,
39
+ } from '@/shared/lib/fs/file-tree';
40
+
41
+ const file = (path: string): FsNode => ({ name: baseNameOf(path), path, type: 'file' });
42
+ const folder = (path: string, children?: FsNode[]): FsNode =>
43
+ children ? { name: baseNameOf(path), path, type: 'folder', children } : { name: baseNameOf(path), path, type: 'folder' };
44
+
45
+ describe('placeEntry', () => {
46
+ test('a top-level entry lands in the root, folders first', () => {
47
+ const placed = placeEntry([folder('src'), file('z.ts')], {}, file('a.ts'));
48
+
49
+ expect(placed.nodes.map((node) => node.path)).toEqual(['src', 'a.ts', 'z.ts']);
50
+ expect(placed.cache).toEqual({});
51
+ });
52
+
53
+ test('a nested entry creates the chain that leads to it', () => {
54
+ const placed = placeEntry([], {}, file('lib/deep/parse.ts'));
55
+
56
+ expect(placed.nodes.map((node) => node.path)).toEqual(['lib']);
57
+ expect(placed.cache.lib?.map((node) => node.path)).toEqual(['lib/deep']);
58
+ expect(placed.cache['lib/deep']?.map((node) => node.path)).toEqual(['lib/deep/parse.ts']);
59
+ });
60
+
61
+ test('an existing chain is extended, not duplicated', () => {
62
+ const placed = placeEntry([folder('lib')], { lib: [folder('lib/deep')], 'lib/deep': [] }, file('lib/deep/x.ts'));
63
+
64
+ expect(placed.nodes).toHaveLength(1);
65
+ expect(placed.cache['lib/deep']?.map((node) => node.path)).toEqual(['lib/deep/x.ts']);
66
+ });
67
+
68
+ test('an entry inside a COLLAPSED folder is not inserted', () => {
69
+ // `src` is listed with `children: null` — the tree has never read it, so
70
+ // there is nothing to place the entry into and no guess to make.
71
+ const tree = [folder('src')];
72
+ const placed = placeEntry(tree, {}, file('src/hidden.ts'));
73
+
74
+ expect(placed.nodes).toBe(tree);
75
+ expect(placed.cache).toEqual({});
76
+ });
77
+
78
+ test('an empty folder is LOADED, and can receive an entry', () => {
79
+ const placed = placeEntry([folder('src')], { src: [] }, file('src/new.ts'));
80
+
81
+ expect(placed.cache.src?.map((node) => node.path)).toEqual(['src/new.ts']);
82
+ });
83
+
84
+ test('a created folder is a folder in the cache too, so it can be expanded', () => {
85
+ const placed = placeEntry([], {}, folder('packages'));
86
+
87
+ expect(placed.nodes[0].type).toBe('folder');
88
+ expect(placed.nodes[0].children).toBeUndefined();
89
+ });
90
+ });
91
+
92
+ describe('takeEntry', () => {
93
+ test('removes a root entry and hands it back', () => {
94
+ const taken = takeEntry([file('a.ts'), file('b.ts')], {}, 'a.ts');
95
+
96
+ expect(taken.entry?.path).toBe('a.ts');
97
+ expect(taken.nodes.map((node) => node.path)).toEqual(['b.ts']);
98
+ });
99
+
100
+ test('removes a nested entry from its parent cache', () => {
101
+ const taken = takeEntry([folder('src')], { src: [file('src/a.ts'), file('src/b.ts')] }, 'src/a.ts');
102
+
103
+ expect(taken.entry?.path).toBe('src/a.ts');
104
+ expect(taken.cache.src?.map((node) => node.path)).toEqual(['src/b.ts']);
105
+ });
106
+
107
+ test('a path the tree does not carry reports no entry and changes nothing', () => {
108
+ const cache = { src: [file('src/a.ts')] };
109
+ const taken = takeEntry([folder('src')], cache, 'src/hidden.ts');
110
+
111
+ expect(taken.entry).toBeNull();
112
+ expect(taken.cache).toBe(cache);
113
+ });
114
+ });
115
+
116
+ describe('renameEntryAt', () => {
117
+ test('re-points the node and every descendant', () => {
118
+ const tree = [folder('src', [folder('src/lib', [file('src/lib/a.ts')])])];
119
+ const moved = renameEntryAt(tree, 'src', 'lib');
120
+
121
+ expect(moved[0].path).toBe('lib');
122
+ expect(moved[0].name).toBe('lib');
123
+ expect(moved[0].children?.[0].path).toBe('lib/lib');
124
+ expect(moved[0].children?.[0].children?.[0].path).toBe('lib/lib/a.ts');
125
+ });
126
+
127
+ test('a sibling whose path merely shares a prefix is left alone', () => {
128
+ const tree = [file('src.ts'), file('src/a.ts')];
129
+ const moved = renameEntryAt(tree, 'src', 'lib');
130
+
131
+ expect(moved[0]).toBe(tree[0]);
132
+ expect(moved[1].path).toBe('lib/a.ts');
133
+ });
134
+
135
+ test('a file rename updates its own name', () => {
136
+ const moved = renameEntryAt([file('a.ts')], 'a.ts', 'nested/b.ts');
137
+
138
+ expect(moved[0].path).toBe('nested/b.ts');
139
+ expect(moved[0].name).toBe('b.ts');
140
+ });
141
+ });
142
+
143
+ describe('rekeyCache', () => {
144
+ test('moves the folder key and every key beneath it', () => {
145
+ const cache = { src: [folder('src/lib')], 'src/lib': [file('src/lib/a.ts')], other: [file('other/x.ts')] };
146
+ const moved = rekeyCache(cache, 'src', 'app');
147
+
148
+ expect(Object.keys(moved).sort()).toEqual(['app', 'app/lib', 'other']);
149
+ expect(moved['app/lib']?.[0].path).toBe('app/lib/a.ts');
150
+ expect(moved.other).toBe(cache.other);
151
+ });
152
+ });
153
+
154
+ describe('rekeyExpansion', () => {
155
+ test('an open folder and its open children stay open under their new paths', () => {
156
+ const next = rekeyExpansion(new Set(['src', 'src/lib', 'docs']), 'src', 'app');
157
+
158
+ expect([...next].sort()).toEqual(['app', 'app/lib', 'docs']);
159
+ });
160
+
161
+ test('a path that only shares a prefix is untouched', () => {
162
+ const next = rekeyExpansion(new Set(['src.ts']), 'src', 'app');
163
+
164
+ expect([...next]).toEqual(['src.ts']);
165
+ });
166
+ });
167
+
168
+ describe('sortEntries', () => {
169
+ test('folders first, then by name — the server’s own order', () => {
170
+ const sorted = sortEntries([file('b.ts'), folder('z'), file('a.ts'), folder('a')]);
171
+
172
+ expect(sorted.map((node) => node.path)).toEqual(['a', 'z', 'a.ts', 'b.ts']);
173
+ });
174
+ });
175
+
176
+ describe('path helpers', () => {
177
+ test('parentPathOf and baseNameOf split a nested path', () => {
178
+ expect(parentPathOf('lib/deep/a.ts')).toBe('lib/deep');
179
+ expect(parentPathOf('a.ts')).toBe('.');
180
+ expect(baseNameOf('lib/deep/a.ts')).toBe('a.ts');
181
+ expect(baseNameOf('a.ts')).toBe('a.ts');
182
+ });
183
+ });
@@ -29,3 +29,162 @@ export function rehydrateTree(nodes: FsNode[], cache: Record<string, FsNode[]>):
29
29
  return { ...node, children: rehydrateTree(cached, cache) };
30
30
  });
31
31
  }
32
+
33
+ /** The folder a path lives in: `.` for a top-level entry. */
34
+ export function parentPathOf(path: string): string {
35
+ const cut = path.lastIndexOf('/');
36
+ return cut === -1 ? '.' : path.slice(0, cut);
37
+ }
38
+
39
+ /** The last segment of a path. */
40
+ export function baseNameOf(path: string): string {
41
+ const cut = path.lastIndexOf('/');
42
+ return cut === -1 ? path : path.slice(cut + 1);
43
+ }
44
+
45
+ /**
46
+ * The server's own ordering (`lib/fs/listing.ts`): folders first, then by name.
47
+ *
48
+ * A locally inserted row has to land where the server would have put it, or the
49
+ * next real read would visibly reorder the tree under the user.
50
+ */
51
+ export function sortEntries(nodes: FsNode[]): FsNode[] {
52
+ return [...nodes].sort((a, b) => {
53
+ if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
54
+ return a.name.localeCompare(b.name);
55
+ });
56
+ }
57
+
58
+ /** The listing after a local edit: the root list plus the children cache. */
59
+ export interface TreeEdit {
60
+ nodes: FsNode[];
61
+ cache: Record<string, FsNode[]>;
62
+ }
63
+
64
+ /**
65
+ * Insert `entry` where its own path says it belongs, creating the folder chain
66
+ * that leads to it when the tree does not carry it yet.
67
+ *
68
+ * This is what lets a create and a rename skip a reload: the panel already
69
+ * knows the whole answer, and re-reading the root plus every open folder to
70
+ * learn it back would collapse nothing but cost a request per open folder.
71
+ *
72
+ * A chain is only extended through folders whose children are KNOWN — the root
73
+ * list plus the cache. A collapsed folder's contents are not this tree's to
74
+ * guess at, so an entry created inside one is simply not inserted: expanding it
75
+ * reads the real directory, which by then holds the new entry. The chain nodes
76
+ * that ARE inserted carry no `children` array, so the row's own expand effect
77
+ * reads them from disk like any other unread folder.
78
+ */
79
+ export function placeEntry(nodes: FsNode[], cache: Record<string, FsNode[]>, entry: FsNode): TreeEdit {
80
+ const segments = entry.path.split('/');
81
+ const nextNodes = [...nodes];
82
+ const nextCache = { ...cache };
83
+ let container = nextNodes;
84
+ let containerKey: string | null = null;
85
+
86
+ for (let i = 0; i < segments.length - 1; i += 1) {
87
+ const folderPath = segments.slice(0, i + 1).join('/');
88
+ const existing = container.find((node) => node.path === folderPath);
89
+ const known = existing ? (nextCache[folderPath] ?? existing.children) : null;
90
+ if (existing && !Array.isArray(known)) return { nodes, cache };
91
+ const children = Array.isArray(known) ? [...known] : [];
92
+ if (!existing) container.push({ name: segments[i], path: folderPath, type: 'folder', ignored: false });
93
+ nextCache[folderPath] = children;
94
+ container = children;
95
+ containerKey = folderPath;
96
+ }
97
+
98
+ container.push(entry);
99
+ if (containerKey !== null) nextCache[containerKey] = sortEntries(container);
100
+ return { nodes: sortEntries(nextNodes), cache: nextCache };
101
+ }
102
+
103
+ /**
104
+ * Remove the entry at `path` from wherever it lives, leaving the rest of the
105
+ * tree untouched. Returns `entry: null` when the path is not in this tree —
106
+ * a collapsed folder's contents are not, and that is not an error.
107
+ */
108
+ export function takeEntry(
109
+ nodes: FsNode[],
110
+ cache: Record<string, FsNode[]>,
111
+ path: string,
112
+ ): TreeEdit & { entry: FsNode | null } {
113
+ const parent = parentPathOf(path);
114
+ if (parent === '.') {
115
+ const entry = nodes.find((node) => node.path === path) ?? null;
116
+ if (!entry) return { entry: null, nodes, cache };
117
+ return { entry, nodes: nodes.filter((node) => node.path !== path), cache };
118
+ }
119
+ const siblings = cache[parent];
120
+ const entry = siblings?.find((node) => node.path === path) ?? null;
121
+ if (!entry || !siblings) return { entry: null, nodes, cache };
122
+ return {
123
+ entry,
124
+ nodes,
125
+ cache: { ...cache, [parent]: siblings.filter((node) => node.path !== path) },
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Re-point one entry and every path beneath it at a new location.
131
+ *
132
+ * A folder's descendants carry their parent's path as a prefix, so moving the
133
+ * folder without rewriting them would leave every child pointing at the old
134
+ * tree — a row whose `path` no longer resolves, and an expansion set keyed by
135
+ * names that are gone.
136
+ *
137
+ * A branch that does not hold the moved path is returned AS IS: the arrays are
138
+ * what React diffs, and a rename that rebuilt every unrelated subtree would
139
+ * re-render the whole tree for a change it did not touch.
140
+ */
141
+ export function renameEntryAt(nodes: FsNode[], fromPath: string, toPath: string): FsNode[] {
142
+ let changed = false;
143
+ const next = nodes.map((node) => {
144
+ const isMoved = node.path === fromPath || node.path.startsWith(`${fromPath}/`);
145
+ const nested = Array.isArray(node.children) ? renameEntryAt(node.children, fromPath, toPath) : undefined;
146
+ // `nested === node.children` holds for a file (`undefined`) and for a folder
147
+ // whose subtree did not move, so an untouched row keeps its identity.
148
+ if (!isMoved && nested === node.children) return node;
149
+ changed = true;
150
+ const path = isMoved ? (node.path === fromPath ? toPath : `${toPath}${node.path.slice(fromPath.length)}`) : node.path;
151
+ const renamed: FsNode = isMoved ? { ...node, path, name: baseNameOf(path) } : { ...node };
152
+ if (nested !== undefined) renamed.children = nested;
153
+ return renamed;
154
+ });
155
+ return changed ? next : nodes;
156
+ }
157
+
158
+ /**
159
+ * Move every cache entry under `fromPath` to `toPath`, paths included.
160
+ *
161
+ * An entry outside the move keeps its own array identity, for the reason
162
+ * `renameEntryAt` does: those arrays are what the tree diffs.
163
+ */
164
+ export function rekeyCache(
165
+ cache: Record<string, FsNode[]>,
166
+ fromPath: string,
167
+ toPath: string,
168
+ ): Record<string, FsNode[]> {
169
+ const next: Record<string, FsNode[]> = {};
170
+ for (const [key, children] of Object.entries(cache)) {
171
+ const moved = key === fromPath
172
+ ? toPath
173
+ : key.startsWith(`${fromPath}/`)
174
+ ? `${toPath}${key.slice(fromPath.length)}`
175
+ : key;
176
+ next[moved] = moved === key ? children : renameEntryAt(children, fromPath, toPath);
177
+ }
178
+ return next;
179
+ }
180
+
181
+ /** Rewrite the expansion set for a renamed folder, so open branches stay open. */
182
+ export function rekeyExpansion(expanded: Set<string>, fromPath: string, toPath: string): Set<string> {
183
+ const next = new Set<string>();
184
+ for (const path of expanded) {
185
+ if (path === fromPath) next.add(toPath);
186
+ else if (path.startsWith(`${fromPath}/`)) next.add(`${toPath}${path.slice(fromPath.length)}`);
187
+ else next.add(path);
188
+ }
189
+ return next;
190
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `pathFromFileUrl` — the platform-specific half of a dropped-file reference.
8
+ *
9
+ * Pinned here rather than through the scope route because the Windows case is
10
+ * invisible on the machine this repository is developed on: the route's own test
11
+ * is `testPosix`, so the drive-letter branch had no test at all until a Windows
12
+ * CI run refused every dropped file.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+ import { pathFromFileUrl } from '@/shared/lib/fs/file-url';
17
+
18
+ describe('pathFromFileUrl', () => {
19
+ test('a POSIX file URL decodes to the path it names', () => {
20
+ expect(pathFromFileUrl('file:///tmp/x/b.ts')).toBe('/tmp/x/b.ts');
21
+ });
22
+
23
+ test('a percent-encoded space is decoded', () => {
24
+ expect(pathFromFileUrl('file:///tmp/x/My%20Notes.md')).toBe('/tmp/x/My Notes.md');
25
+ });
26
+
27
+ test('a Windows drive letter loses the leading slash, in every spelling', () => {
28
+ // All three forms answer `/C:/…` from `new URL().pathname`, and a leading
29
+ // slash on win32 resolves against the CURRENT DRIVE's root — a path no
30
+ // workspace contains.
31
+ expect(pathFromFileUrl('file:///C:/Users/x/b.ts')).toBe('C:/Users/x/b.ts');
32
+ expect(pathFromFileUrl('file://C:/Users/x/b.ts')).toBe('C:/Users/x/b.ts');
33
+ expect(pathFromFileUrl('file://C:\\Users\\x\\b.ts')).toBe('C:/Users/x/b.ts');
34
+ });
35
+
36
+ test('a percent-encoded drive path is decoded before the slash is dropped', () => {
37
+ expect(pathFromFileUrl('file:///D:/a/My%20Notes.md')).toBe('D:/a/My Notes.md');
38
+ });
39
+
40
+ test('a path that is not a file URL is refused rather than guessed at', () => {
41
+ expect(pathFromFileUrl('/tmp/x/b.ts')).toBeNull();
42
+ expect(pathFromFileUrl('https://example.com/x.ts')).toBeNull();
43
+ });
44
+
45
+ test('a malformed escape is refused, not thrown', () => {
46
+ expect(pathFromFileUrl('file:///tmp/%E0%A4%A')).toBeNull();
47
+ });
48
+
49
+ test('a lowercase drive letter is kept as written', () => {
50
+ expect(pathFromFileUrl('file:///c:/x/b.ts')).toBe('c:/x/b.ts');
51
+ });
52
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * A `file://` URL to the path it names.
8
+ *
9
+ * Shared rather than private to the scope route, because the transformation is
10
+ * platform-specific and a private copy can only be exercised on the platform it
11
+ * was written on: the Windows branch below was wrong for as long as it lived
12
+ * inside a route whose test skipped Windows.
13
+ *
14
+ * Two rules, both measured:
15
+ *
16
+ * - **The pathname is percent-encoded** — a space is `%20`, and a Finder drop of
17
+ * `My Notes.md` is exactly that — so decoding is not optional.
18
+ * `decodeURIComponent` throws on a malformed escape, which is answered as
19
+ * "not a path" rather than surfacing as a 500.
20
+ * - **A WINDOWS drive letter loses its leading slash.** `new URL('file://C:\a\b.ts')`
21
+ * and the canonical `file:///C:/a/b.ts` BOTH answer `/C:/a/b.ts`, and
22
+ * `path.resolve` on win32 turns that into `\C:\a\b.ts` — a path under the
23
+ * current DRIVE's root, which no workspace contains. On the Windows CI leg
24
+ * every dropped file was refused with "outside every workspace", so a file
25
+ * dropped on the editor opened nothing at all. `fileURLToPath` drops the same
26
+ * slash; this does it without a `node:url` import in a shared module.
27
+ */
28
+ export function pathFromFileUrl(reference: string): string | null {
29
+ if (!/^file:\/\//i.test(reference)) return null;
30
+ try {
31
+ const pathname = decodeURIComponent(new URL(reference).pathname);
32
+ // `/C:/…` → `C:/…`; `/tmp/…` is left alone.
33
+ return /^\/[A-Za-z]:[\\/]/.test(pathname) ? pathname.slice(1) : pathname;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * A unified patch → the two documents it compares.
8
+ *
9
+ * What this pins is the shape a chat tool card depends on: a card has a PATCH
10
+ * and no files, and it draws the same merge surface the diff panel does — so the
11
+ * sides it hands over must be exactly what the patch says they were. A context
12
+ * row belongs to both, a `-` row only to the original, a `+` row only to the new
13
+ * one; everything else (the `diff --git` header, `index`, `---`/`+++`, mode and
14
+ * rename lines, the "No newline" marker) is metadata and must not appear in
15
+ * either document.
16
+ */
17
+
18
+ import { describe, expect, test } from 'bun:test';
19
+
20
+ import { patchSides } from '@/shared/lib/fs/patch-sides';
21
+
22
+ const GIT_DIFF = [
23
+ 'diff --git a/src/a.ts b/src/a.ts',
24
+ 'index 1111111..2222222 100644',
25
+ '--- a/src/a.ts',
26
+ '+++ b/src/a.ts',
27
+ '@@ -1,3 +1,3 @@',
28
+ ' const one = 1;',
29
+ '-const two = 2;',
30
+ '+const two = 22;',
31
+ ' const three = 3;',
32
+ '',
33
+ ].join('\n');
34
+
35
+ describe('patchSides', () => {
36
+ test('splits a git diff into the documents it compares', () => {
37
+ const sides = patchSides(GIT_DIFF);
38
+
39
+ expect(sides.original).toBe('const one = 1;\nconst two = 2;\nconst three = 3;');
40
+ expect(sides.next).toBe('const one = 1;\nconst two = 22;\nconst three = 3;');
41
+ expect(sides.additions).toBe(1);
42
+ expect(sides.deletions).toBe(1);
43
+ expect(sides.hasHunk).toBe(true);
44
+ });
45
+
46
+ test('keeps the header lines out of both documents', () => {
47
+ // A `--- a/x` header would otherwise land in the original as `-- a/x`, and
48
+ // the merge view would draw a chunk that is not a change.
49
+ const sides = patchSides(GIT_DIFF);
50
+ for (const line of ['diff --git', 'index ', '--- a/', '+++ b/']) {
51
+ expect(sides.original.includes(line)).toBe(false);
52
+ expect(sides.next.includes(line)).toBe(false);
53
+ }
54
+ });
55
+
56
+ test('a new file has an empty original and the whole new document', () => {
57
+ const sides = patchSides(['@@ -0,0 +1,2 @@', '+alpha', '+beta'].join('\n'));
58
+
59
+ expect(sides.original).toBe('');
60
+ expect(sides.next).toBe('alpha\nbeta');
61
+ expect(sides.additions).toBe(2);
62
+ expect(sides.deletions).toBe(0);
63
+ });
64
+
65
+ test('a deleted file is the mirror image', () => {
66
+ const sides = patchSides(['@@ -1,1 +0,0 @@', '-gone'].join('\n'));
67
+
68
+ expect(sides.original).toBe('gone');
69
+ expect(sides.next).toBe('');
70
+ expect(sides.deletions).toBe(1);
71
+ });
72
+
73
+ test('a header-only patch has no rows, so the caller can fall back to text', () => {
74
+ const sides = patchSides('--- a/x\n+++ b/x\n');
75
+
76
+ expect(sides.rows).toBe(0);
77
+ expect(sides.hasHunk).toBe(false);
78
+ });
79
+
80
+ test('an empty patch is the empty answer, not a one-line document', () => {
81
+ expect(patchSides('')).toEqual({ original: '', next: '', additions: 0, deletions: 0, rows: 0, hasHunk: false });
82
+ expect(patchSides(' \n')).toEqual({ original: '', next: '', additions: 0, deletions: 0, rows: 0, hasHunk: false });
83
+ });
84
+
85
+ test('counts rows without the blank line a trailing newline leaves behind', () => {
86
+ const sides = patchSides(['@@ -1 +1 @@', '-a', '+b'].join('\n'));
87
+
88
+ expect(sides.rows).toBe(2);
89
+ });
90
+
91
+ test('a hunk starting mid-file keeps its real line numbers', () => {
92
+ // Without the padding the change would be line 1 of the rebuilt document,
93
+ // and the card's gutter would name a line the file does not have it on.
94
+ const sides = patchSides(['@@ -43,2 +43,2 @@', ' context', '-old', '+new'].join('\n'));
95
+
96
+ expect(sides.original.split('\n')).toHaveLength(44);
97
+ expect(sides.original.split('\n')[42]).toBe('context');
98
+ expect(sides.original.split('\n')[43]).toBe('old');
99
+ expect(sides.next.split('\n')[43]).toBe('new');
100
+ });
101
+
102
+ test('the gap between two hunks is restored from their own headers', () => {
103
+ const sides = patchSides(
104
+ ['@@ -2,1 +2,1 @@', '-a', '+b', '@@ -10,1 +10,1 @@', '-c', '+d'].join('\n'),
105
+ );
106
+
107
+ // Line 2 is the first change, line 10 the second, and everything between is
108
+ // unchanged content the patch did not carry.
109
+ const original = sides.original.split('\n');
110
+ expect(original[1]).toBe('a');
111
+ expect(original[9]).toBe('c');
112
+ expect(original).toHaveLength(10);
113
+ });
114
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * A unified patch as its two DOCUMENTS.
8
+ *
9
+ * This replaced a table parser (`diff-parser.ts`) that produced per-row line
10
+ * numbers and paired split-view rows. Both of those are now the merge view's
11
+ * job — `@codemirror/merge` computes the alignment itself from two documents —
12
+ * so the only thing still needed from a patch is what the two sides were.
13
+ *
14
+ * That matters in one place: a chat tool card has a PATCH and no documents, and
15
+ * the card draws the same merge surface the diff panel does. Reconstructing the
16
+ * sides here keeps one renderer for both, instead of the card growing a second
17
+ * diff implementation that drifts.
18
+ *
19
+ * **The gap between hunks is restored as blank lines, and that is what keeps the
20
+ * gutter honest.** A hunk header names the line it starts on (`@@ -43,7 +43,8`),
21
+ * and a document rebuilt by concatenating hunks would number the change from 1.
22
+ * Padding each side up to the header's own counters puts the change back on the
23
+ * line the file has it on — and because the padding is unchanged content, the
24
+ * merge view collapses it into one marker (`contentMode: 'diff'`).
25
+ *
26
+ * The counters are the patch's own (`+`/`-` rows, hunk headers excluded), which
27
+ * is what the card's header prints. The diff panel deliberately does NOT use
28
+ * them: it has the real documents, and its counts must describe the rows the
29
+ * merge view actually draws (a collapsed or whitespace-folded change is not a
30
+ * changed row on screen).
31
+ */
32
+
33
+ /** One side's text, and what the patch said about it. */
34
+ export interface PatchSides {
35
+ /** The document before the change, with the hunk gaps as blank lines. */
36
+ original: string;
37
+ /** The document after it, the same way. */
38
+ next: string;
39
+ additions: number;
40
+ deletions: number;
41
+ /** Content rows the patch produced — zero for a header-only or empty patch. */
42
+ rows: number;
43
+ /** The patch named at least one hunk (`@@`), so it is a real diff. */
44
+ hasHunk: boolean;
45
+ }
46
+
47
+ const EMPTY: PatchSides = { original: '', next: '', additions: 0, deletions: 0, rows: 0, hasHunk: false };
48
+
49
+ /** Header lines that describe the patch rather than its content. */
50
+ const HEADER_PREFIXES = [
51
+ 'diff --git',
52
+ 'index ',
53
+ '---',
54
+ '+++',
55
+ 'new file mode',
56
+ 'deleted file mode',
57
+ 'similarity index',
58
+ 'dissimilarity index',
59
+ 'rename from',
60
+ 'rename to',
61
+ 'copy from',
62
+ 'copy to',
63
+ 'old mode',
64
+ 'new mode',
65
+ '\\ No newline',
66
+ ];
67
+
68
+ /** `@@ -43,7 +43,8 @@` → the two starting lines; anything else → null. */
69
+ function hunkStarts(line: string): { old: number; next: number } | null {
70
+ const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
71
+ if (!match) return null;
72
+ return { old: Number(match[1]), next: Number(match[2]) };
73
+ }
74
+
75
+ /** Pad `lines` with blanks until it holds `count` lines. */
76
+ function padTo(lines: string[], count: number): void {
77
+ while (lines.length < count) lines.push('');
78
+ }
79
+
80
+ export function patchSides(patch: string): PatchSides {
81
+ if (!patch.trim()) return EMPTY;
82
+
83
+ const before: string[] = [];
84
+ const after: string[] = [];
85
+ let additions = 0;
86
+ let deletions = 0;
87
+ let rows = 0;
88
+ let hasHunk = false;
89
+
90
+ const lines = patch.split(/\r?\n/);
91
+ // A patch ends with a newline, and `split` turns that into a final empty
92
+ // string. It is the terminator, not a blank row: keeping it put an empty line
93
+ // at the end of BOTH documents, which the merge view drew as a chunk.
94
+ if (lines[lines.length - 1] === '') lines.pop();
95
+
96
+ for (const raw of lines) {
97
+ if (HEADER_PREFIXES.some((prefix) => raw.startsWith(prefix))) continue;
98
+
99
+ if (raw.startsWith('@@')) {
100
+ hasHunk = true;
101
+ const starts = hunkStarts(raw);
102
+ // `-0,0` (a new file) has no original line to reach, and `padTo` is a
103
+ // no-op for it because the count is below what is already there.
104
+ if (starts) {
105
+ padTo(before, starts.old - 1);
106
+ padTo(after, starts.next - 1);
107
+ }
108
+ continue;
109
+ }
110
+
111
+ if (raw.startsWith('+')) {
112
+ additions++;
113
+ rows++;
114
+ after.push(raw.slice(1));
115
+ continue;
116
+ }
117
+ if (raw.startsWith('-')) {
118
+ deletions++;
119
+ rows++;
120
+ before.push(raw.slice(1));
121
+ continue;
122
+ }
123
+ // A context row (` ` prefix) is on BOTH sides; a bare line is what a
124
+ // header-less patch carries, and it is context too.
125
+ const text = raw.startsWith(' ') ? raw.slice(1) : raw;
126
+ rows++;
127
+ before.push(text);
128
+ after.push(text);
129
+ }
130
+
131
+ return {
132
+ original: before.join('\n'),
133
+ next: after.join('\n'),
134
+ additions,
135
+ deletions,
136
+ rows,
137
+ hasHunk,
138
+ };
139
+ }