@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
@@ -1,13 +1,14 @@
1
- import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
1
+ import { useState } from 'preact/hooks';
2
2
  import { useSessionUiState } from '@/client/hooks/workspace/session-state';
3
- import { parseUnifiedDiff } from '@/shared/lib/fs/diff-parser';
4
- import { isUnmergedCode, type ConflictMarkers } from '@/shared/lib/fs/git-unmerged';
3
+ import { isUnmergedCode } from '@/shared/lib/fs/git-unmerged';
5
4
  import { useFileDiff } from '@/client/hooks/workspace/diff-file';
5
+ import { useDiffGitActions } from '@/client/hooks/workspace/diff-git-actions';
6
6
  import { getLanguageFromPath } from '@/shared/lib/code/language';
7
7
  import { DiffToolbar, type DiffContentMode, type DiffViewMode } from '@/client/components/workspace/diff-panel/Toolbar';
8
8
  import { ConflictBanner } from '@/client/components/workspace/diff-panel/ConflictBanner';
9
- import { UnifiedView } from '@/client/components/workspace/diff-panel/UnifiedView';
10
- import { SplitView } from '@/client/components/workspace/diff-panel/SplitView';
9
+ import { ThemedMergeDiffView } from '@/client/components/workspace/diff-panel/ThemedMergeDiffView';
10
+ import { useEditorFontStack } from '@/client/hooks/editor/font';
11
+ import { EDITOR_DEFAULT_FONT_SIZE } from '@/shared/lib/code/editor/typography';
11
12
  import { usePluginDiffView } from '@/client/components/workspace/editor/plugin-file-view';
12
13
  import { PluginIdProvider } from '@kabmo-labs/kabmo-ui';
13
14
  import { AlertCircle, RefreshCw } from 'lucide-preact';
@@ -26,6 +27,12 @@ interface DiffPanelProps {
26
27
  * per tab (and per view shape). Null for a host with no tab of its own.
27
28
  */
28
29
  tabKey?: string | null;
30
+ /**
31
+ * Offer the two width-mode buttons. On by default (the editor column is where
32
+ * they belong); the phone's full-screen overlay turns them off, because it
33
+ * already covers the chat and the sidebar.
34
+ */
35
+ viewModeButtons?: boolean;
29
36
  }
30
37
 
31
38
  export function DiffPanel({
@@ -38,6 +45,7 @@ export function DiffPanel({
38
45
  onFileSaved,
39
46
  className = '',
40
47
  tabKey = null,
48
+ viewModeButtons = true,
41
49
  }: DiffPanelProps) {
42
50
  // Read and persist view mode & whitespace preference in session_ui_state
43
51
  const [viewMode, setViewMode] = useSessionUiState<DiffViewMode>('diff.viewMode', 'unified');
@@ -45,42 +53,26 @@ export function DiffPanel({
45
53
  const [wordWrap, setWordWrap] = useSessionUiState<boolean>('diff.wordWrap', true);
46
54
  const [ignoreWhitespace, setIgnoreWhitespace] = useSessionUiState<boolean>('diff.ignoreWhitespace', false);
47
55
 
48
- const [isBusy, setIsBusy] = useState<boolean>(false);
49
- const [actionError, setActionError] = useState<string | null>(null);
56
+ const [clipboardError, setClipboardError] = useState<string | null>(null);
50
57
  const [isCopied, setIsCopied] = useState<boolean>(false);
51
58
  const [showDiscardConfirm, setShowDiscardConfirm] = useState<boolean>(false);
52
- /**
53
- * The side labels from the file's own conflict markers.
54
- *
55
- * Read only while the file IS conflicted, and only when the status says so —
56
- * the read is the whole file, and `status` is what the caller already knows.
57
- * A failed read leaves `{}`, which falls back to the words `ours`/`theirs`:
58
- * the actions still work, only the naming is less precise.
59
- */
60
- const [markers, setMarkers] = useState<ConflictMarkers>({});
59
+ /** Additions/deletions of the pair on screen, reported by the merge view. */
60
+ const [counts, setCounts] = useState<{ additions: number; deletions: number }>({ additions: 0, deletions: 0 });
61
+ const editorFontFamily = useEditorFontStack();
61
62
 
62
63
  const {
63
64
  rawDiff,
65
+ oldContent,
66
+ newContent,
64
67
  currentStatus,
65
68
  currentStaged,
66
69
  isLoading,
67
70
  errorMessage,
68
71
  fetchDiff,
69
72
  adoptState,
70
- } = useFileDiff({ filePath, status, isStaged, repo, root, contentMode });
73
+ } = useFileDiff({ filePath, status, isStaged, repo, root });
71
74
  const conflicted = isUnmergedCode(currentStatus);
72
75
 
73
- // Full-code mode parses the whole file, not the hunks, so the parse is keyed
74
- // to its inputs: every render used to re-walk the diff (measured 21 ms on an
75
- // 80k-line file), for a result that only changes when the diff or the
76
- // whitespace toggle does.
77
- // Only the rendered view is built: `views` keeps the shared flush (so the
78
- // whitespace decision and the counts cannot diverge) while skipping the
79
- // object graph nobody draws.
80
- const parsed = useMemo(
81
- () => parseUnifiedDiff(rawDiff, { ignoreWhitespace, views: viewMode === 'split' ? 'split' : 'unified' }),
82
- [rawDiff, ignoreWhitespace, viewMode],
83
- );
84
76
  const language = getLanguageFromPath(filePath);
85
77
  // A contributed renderer replaces the built-in views below, never the toolbar.
86
78
  const pluginDiffView = usePluginDiffView({ path: filePath, diff: rawDiff });
@@ -108,117 +100,31 @@ export function DiffPanel({
108
100
  setIsCopied(true);
109
101
  setTimeout(() => setIsCopied(false), 2000);
110
102
  },
111
- () => setActionError('Clipboard write was refused by the browser'),
103
+ () => setClipboardError('Clipboard write was refused by the browser'),
112
104
  );
113
105
  };
114
106
 
115
107
  /**
116
- * POST one git action and report whether it landed. The panel used to fire
117
- * and forget (`fetcher.submit` + an optimistic flip + a 300ms timer), which
118
- * made a rejected action indistinguishable from a successful one: the badge
119
- * flipped and the diff silently stayed put.
108
+ * The git writes, their busy flag, their refusals, and the conflict-marker
109
+ * read behind the banner — see `diff-git-actions.ts`. The one exception is
110
+ * the clipboard, which is not a write: it reads the buffer already on screen
111
+ * and must stay available while a write is in flight.
120
112
  */
121
- const runGitAction = useCallback(
122
- async (fields: Record<string, string>): Promise<boolean> => {
123
- const fd = new FormData();
124
- for (const [key, value] of Object.entries(fields)) fd.set(key, value);
125
- fd.set('repo', repo || '.');
126
- if (root) fd.set('root', root);
127
- try {
128
- const res = await fetch('/api/fs/git', { method: 'POST', body: fd });
129
- const data = (await res.json().catch(() => null)) as { success?: boolean; error?: string } | null;
130
- if (!res.ok || !data?.success) {
131
- setActionError(data?.error || `Request failed (HTTP ${res.status})`);
132
- return false;
133
- }
134
- setActionError(null);
135
- return true;
136
- } catch (err: unknown) {
137
- setActionError(err instanceof Error ? err.message : 'Request failed');
138
- return false;
139
- }
140
- },
141
- [repo, root],
142
- );
143
-
144
- const handleStageUnstage = async () => {
145
- if (isBusy) return;
146
- const nextStaged = !currentStaged;
147
- setIsBusy(true);
148
- try {
149
- const ok = await runGitAction({
150
- actionType: currentStaged ? 'unstage' : 'stage',
151
- file: filePath,
152
- });
153
- if (!ok) return;
154
- adoptState({ staged: nextStaged });
155
- onFileSaved?.();
156
- await fetchDiff({ staged: nextStaged });
157
- } finally {
158
- setIsBusy(false);
159
- }
160
- };
161
-
162
- const handleDiscard = () => {
163
- setShowDiscardConfirm(true);
164
- };
165
-
166
- // The marker labels are fetched when the file turns out to be conflicted, and
167
- // re-fetched when the path changes. Keyed on the path and the status so a
168
- // resolve (which changes the status away from `UU`) clears the banner.
169
- useEffect(() => {
170
- if (!conflicted) {
171
- setMarkers({});
172
- return;
173
- }
174
- let cancelled = false;
175
- const params = new URLSearchParams({ conflictMarkers: '1', file: filePath, repo: repo || '.' });
176
- if (root) params.set('root', root);
177
- void fetch(`/api/fs/git?${params.toString()}`)
178
- .then((res) => res.json() as Promise<{ markers?: ConflictMarkers }>)
179
- .then((body) => {
180
- if (!cancelled) setMarkers(body.markers ?? {});
181
- })
182
- .catch(() => {
183
- if (!cancelled) setMarkers({});
184
- });
185
- return () => { cancelled = true; };
186
- }, [conflicted, filePath, repo, root]);
187
-
188
- /** Resolve this file: `ours`/`theirs` write a stage, `''` only stages. */
189
- const handleResolve = async (side: 'ours' | 'theirs' | '') => {
190
- if (isBusy) return;
191
- setIsBusy(true);
192
- try {
193
- const ok = await runGitAction({
194
- actionType: 'resolve_conflict',
195
- file: filePath,
196
- ...(side ? { side } : {}),
197
- });
198
- if (!ok) return;
199
- onFileSaved?.();
200
- await fetchDiff();
201
- } finally {
202
- setIsBusy(false);
203
- }
204
- };
205
-
206
- const confirmDiscard = async () => {
207
- setShowDiscardConfirm(false);
208
- setIsBusy(true);
209
- try {
210
- // `revert` is the server's own name for "restore this file from the index",
211
- // and it is the one that handles the untracked case (there the file is
212
- // removed). Sending `discard` — which no handler matched — returned
213
- // `{success:true}` for doing nothing at all.
214
- const ok = await runGitAction({ actionType: 'revert', file: filePath });
215
- if (!ok) return;
216
- onFileSaved?.();
217
- await fetchDiff();
218
- } finally {
219
- setIsBusy(false);
220
- }
221
- };
113
+ const gitActions = useDiffGitActions({
114
+ filePath,
115
+ repo,
116
+ root,
117
+ status: currentStatus,
118
+ staged: currentStaged,
119
+ conflicted,
120
+ adoptState,
121
+ fetchDiff,
122
+ onFileSaved,
123
+ });
124
+ const { isBusy, markers } = gitActions;
125
+ // Two refusal surfaces with one source: the clipboard's own failure is local
126
+ // to this panel, while a git refusal comes from the server.
127
+ const actionError = clipboardError ?? gitActions.error;
222
128
 
223
129
  return (
224
130
  // The panel fills, it does not size itself: the caller owns the height
@@ -238,17 +144,18 @@ export function DiffPanel({
238
144
  contentMode={contentMode}
239
145
  wordWrap={wordWrap}
240
146
  ignoreWhitespace={ignoreWhitespace}
241
- additions={parsed.additions}
242
- deletions={parsed.deletions}
147
+ additions={counts.additions}
148
+ deletions={counts.deletions}
243
149
  isLoading={isLoading}
244
150
  isCopied={isCopied}
245
151
  isBusy={isBusy}
152
+ viewModeButtons={viewModeButtons}
246
153
  onSetViewMode={handleSetViewMode}
247
154
  onSetContentMode={handleSetContentMode}
248
155
  onToggleWordWrap={handleToggleWordWrap}
249
156
  onToggleWhitespace={handleToggleWhitespace}
250
- onStageUnstage={handleStageUnstage}
251
- onDiscard={handleDiscard}
157
+ onStageUnstage={() => void gitActions.stageUnstage()}
158
+ onDiscard={() => setShowDiscardConfirm(true)}
252
159
  onOpenInEditor={onOpenInEditor}
253
160
  onRefresh={() => void fetchDiff()}
254
161
  onCopyDiff={handleCopyDiff}
@@ -259,8 +166,11 @@ export function DiffPanel({
259
166
  markers={markers}
260
167
  isBusy={isBusy}
261
168
  error={actionError}
262
- onResolve={handleResolve}
263
- onDismissError={() => setActionError(null)}
169
+ onResolve={(side) => void gitActions.resolve(side)}
170
+ onDismissError={() => {
171
+ gitActions.dismissError();
172
+ setClipboardError(null);
173
+ }}
264
174
  />
265
175
  )}
266
176
 
@@ -270,7 +180,10 @@ export function DiffPanel({
270
180
  <span className="min-w-0 flex-1 truncate" title={actionError}>{actionError}</span>
271
181
  <button
272
182
  type="button"
273
- onClick={() => setActionError(null)}
183
+ onClick={() => {
184
+ gitActions.dismissError();
185
+ setClipboardError(null);
186
+ }}
274
187
  className="px-1.5 rounded hover:bg-error/15 cursor-pointer"
275
188
  title="Dismiss"
276
189
  aria-label="Dismiss"
@@ -302,10 +215,25 @@ export function DiffPanel({
302
215
  <PluginIdProvider pluginId={pluginDiffView.pluginId}>
303
216
  <pluginDiffView.Component {...pluginDiffView.props} />
304
217
  </PluginIdProvider>
305
- ) : viewMode === 'split' ? (
306
- <SplitView rows={parsed.splitRows} language={language} wordWrap={wordWrap} tabKey={tabKey} />
307
218
  ) : (
308
- <UnifiedView lines={parsed.lines} language={language} wordWrap={wordWrap} tabKey={tabKey} />
219
+ // Both shapes are the SAME component: `@codemirror/merge` owns the
220
+ // diff and the rows, and a shape switch is a re-mount of a different
221
+ // object (an `EditorView` vs a `MergeView`), which is why it is a prop
222
+ // rather than a second component.
223
+ <ThemedMergeDiffView
224
+ original={oldContent}
225
+ next={newContent}
226
+ language={language}
227
+ viewMode={viewMode}
228
+ contentMode={contentMode}
229
+ wordWrap={wordWrap}
230
+ ignoreWhitespace={ignoreWhitespace}
231
+ tabKey={tabKey}
232
+ onCounts={setCounts}
233
+ empty={!rawDiff || !rawDiff.trim()}
234
+ fontFamily={editorFontFamily}
235
+ fontSize={EDITOR_DEFAULT_FONT_SIZE}
236
+ />
309
237
  )}
310
238
  </div>
311
239
 
@@ -327,7 +255,10 @@ export function DiffPanel({
327
255
  </button>
328
256
  <button
329
257
  type="button"
330
- onClick={() => void confirmDiscard()}
258
+ onClick={() => {
259
+ setShowDiscardConfirm(false);
260
+ void gitActions.revert();
261
+ }}
331
262
  className="px-3 py-1.5 text-xs bg-error text-white rounded hover:bg-error/90 font-medium transition-colors cursor-pointer"
332
263
  >
333
264
  Discard Changes
@@ -0,0 +1,315 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The diff panel's CodeMirror side: the two view shapes, their theme, and the
8
+ * counts the toolbar prints.
9
+ *
10
+ * What this replaced: a unified-diff PARSER feeding two hand-built tables
11
+ * (1 066 lines across `UnifiedView` / `SplitView` plus the diff half of
12
+ * `diff-parser`), with a Shiki pass per row and its own wrapping rules.
13
+ * `@codemirror/merge` does the diff itself and draws the rows, so a chunk is a
14
+ * real editor range: the gutter markers, the deleted-chunk widgets and the
15
+ * collapsed stretches are the extension's, not ours.
16
+ *
17
+ * Three facts about this API are load-bearing and none is guessable:
18
+ *
19
+ * - `getChunks` is a FUNCTION, not a field: `state.field(getChunks)` throws
20
+ * "Field is not present in this state". The counts do not need it either —
21
+ * `Chunk.build(a, b)` is the same line-level chunking without a view, which
22
+ * is what lets a badge be computed before anything is mounted.
23
+ * - `collapseUnchanged` is CONFIG, so switching *Diff Only* ↔ *Full Code* is a
24
+ * compartment reconfigure. Re-mounting would drop the reader's scroll.
25
+ * - The merge package's own colours are hardcoded hex. They are overridden with
26
+ * the app's palette variables, or a diff would be the one surface that ignores
27
+ * the theme.
28
+ */
29
+
30
+ import { Compartment, EditorState, Text, type Extension } from '@codemirror/state';
31
+ import { EditorView, lineNumbers } from '@codemirror/view';
32
+ import { Chunk, MergeView, diff, unifiedMergeView } from '@codemirror/merge';
33
+
34
+ import { shikiHighlight, type ShikiThemeNames } from '@/client/components/common/code-surface/shiki';
35
+ import { surfaceTheme } from '@/client/components/common/code-surface/theme';
36
+
37
+ /** What the toolbar shows; `diff` collapses the unchanged stretches. */
38
+ export type CollapseMode = 'diff' | 'full';
39
+
40
+ /** The collapsed-stretch settings: git's own `-U3` shape, plus a minimum. */
41
+ const COLLAPSE = { margin: 3, minSize: 4 } as const;
42
+
43
+ /** Both sides of a diff are a READER here: the toolbar writes, not the editor. */
44
+ const READ_ONLY = [EditorState.readOnly.of(true), EditorView.editable.of(false)];
45
+
46
+ /**
47
+ * The app's palette, applied to the merge package's own classes.
48
+ *
49
+ * `&.cm-merge-a` / `&.cm-merge-b` are spelled out because the extension's base
50
+ * theme uses the same shape — an equally specific rule from a `theme` facet wins
51
+ * on order, and a weaker one loses.
52
+ */
53
+ function diffTheme(): Extension {
54
+ return EditorView.theme({
55
+ '&': {
56
+ height: '100%',
57
+ backgroundColor: 'var(--editor-bg, var(--theme-paper))',
58
+ color: 'var(--editor-fg, var(--theme-ink))',
59
+ fontSize: 'inherit',
60
+ fontFamily: 'inherit',
61
+ lineHeight: 'inherit',
62
+ },
63
+ '&.cm-focused': { outline: 'none' },
64
+ '.cm-scroller': { fontFamily: 'inherit', fontSize: 'inherit', lineHeight: 'inherit' },
65
+ '.cm-gutters': {
66
+ backgroundColor: 'var(--editor-bg, var(--theme-canvas))',
67
+ color: 'var(--editor-gutter-fg, color-mix(in srgb, var(--theme-ink) 30%, transparent))',
68
+ border: 'none',
69
+ borderRight: '1px solid var(--editor-border, color-mix(in srgb, var(--theme-ink) 10%, transparent))',
70
+ fontFamily: 'inherit',
71
+ fontSize: 'inherit',
72
+ lineHeight: 'inherit',
73
+ },
74
+ // The diff bands follow the editor theme when one is chosen (its own
75
+ // inserted/removed colours) and the app's signal pair otherwise.
76
+ '&.cm-merge-a .cm-changedLine, .cm-deletedChunk': {
77
+ backgroundColor: 'var(--editor-removed, color-mix(in srgb, var(--theme-error) 10%, transparent))',
78
+ },
79
+ '&.cm-merge-b .cm-changedLine, .cm-inlineChangedLine': {
80
+ backgroundColor: 'var(--editor-added, color-mix(in srgb, var(--theme-success) 10%, transparent))',
81
+ },
82
+ // The per-token underline is deliberately NOT drawn, and it CANNOT be
83
+ // removed from here: `@codemirror/merge`'s base theme paints a 2px gradient
84
+ // along the bottom of every `.cm-changedText` / `.cm-deletedText` fragment,
85
+ // and its rules are spelled with the `&light`/`&dark` prefixes that only
86
+ // `EditorView.baseTheme` accepts — `EditorView.theme` throws
87
+ // `RangeError: Unsupported selector: &light` (measured; it took the whole
88
+ // merge view down with an empty host). `tailwind.css` therefore owns the
89
+ // suppression, under the same `.cm-changedText` class. See the block there.
90
+ '&.cm-merge-a .cm-changedLineGutter, .cm-deletedLineGutter': {
91
+ background: 'var(--editor-removed-gutter, var(--theme-error))',
92
+ },
93
+ '&.cm-merge-b .cm-changedLineGutter': {
94
+ background: 'var(--editor-added-gutter, var(--theme-success))',
95
+ },
96
+ '.cm-collapsedLines': {
97
+ color: 'color-mix(in srgb, var(--editor-fg, var(--theme-ink)) 45%, transparent)',
98
+ background: 'var(--editor-bg, var(--theme-canvas))',
99
+ fontSize: '11px',
100
+ },
101
+ '.cm-changeGutter': { width: '3px', paddingLeft: '1px' },
102
+ '.cm-mergeView': { height: '100%' },
103
+ });
104
+ }
105
+
106
+ export interface DiffViewOptions {
107
+ /** The document before the change (HEAD for a staged diff, the index otherwise). */
108
+ original: string;
109
+ /** The document after it. */
110
+ next: string;
111
+ language: string;
112
+ wordWrap: boolean;
113
+ collapse: CollapseMode;
114
+ /** The two Shiki theme names; absent keeps the app's dual pair. */
115
+ themes?: ShikiThemeNames;
116
+ /** `data-testid` for the scroller, so the e2e layer can address it. */
117
+ scrollTestId: string;
118
+ /** The element that scrolls, reported on mount and cleared on unmount. */
119
+ onScroller: (element: HTMLElement | null) => void;
120
+ }
121
+
122
+ export interface DiffViewHandle {
123
+ /** Swap the documents (a refresh, a stage, a discard). */
124
+ setDocuments(original: string, next: string): void;
125
+ /** Re-collapse or expand the unchanged stretches without re-mounting. */
126
+ setCollapse(collapse: CollapseMode): void;
127
+ setWordWrap(wordWrap: boolean): void;
128
+ /** The element that actually scrolls, for scroll restore. */
129
+ scroller(): HTMLElement | null;
130
+ destroy(): void;
131
+ }
132
+
133
+ /** What `collapseUnchanged` is given, or `undefined` for the full file. */
134
+ function collapseConfig(collapse: CollapseMode): { margin: number; minSize: number } | undefined {
135
+ return collapse === 'diff' ? COLLAPSE : undefined;
136
+ }
137
+
138
+ /** The extension stack both shapes share. */
139
+ function baseExtensions(options: DiffViewOptions, wrap: Compartment): Extension[] {
140
+ return [
141
+ ...READ_ONLY,
142
+ lineNumbers(),
143
+ shikiHighlight(options.language, options.themes),
144
+ surfaceTheme(),
145
+ diffTheme(),
146
+ wrap.of(options.wordWrap ? EditorView.lineWrapping : []),
147
+ // The browser's spellchecker underlines every identifier in a diff, and
148
+ // autocorrect would be offered on code.
149
+ EditorView.contentAttributes.of({ spellcheck: 'false', autocorrect: 'off', autocapitalize: 'off' }),
150
+ ];
151
+ }
152
+
153
+ /** Replace a document wholesale — the diff is a reader, so there is no history to keep. */
154
+ function replaceDoc(view: EditorView, text: string): void {
155
+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
156
+ }
157
+
158
+ /** The Unified shape: one editor whose content IS the new document. */
159
+ export function mountUnifiedDiff(parent: HTMLElement, options: DiffViewOptions): DiffViewHandle {
160
+ const collapse = new Compartment();
161
+ const wrap = new Compartment();
162
+ /** The pair the LIVE view is comparing; re-stated whenever collapse changes. */
163
+ let original = options.original;
164
+
165
+ const unified = (source: string): Extension =>
166
+ unifiedMergeView({
167
+ original: source,
168
+ collapseUnchanged: collapseConfig(options.collapse),
169
+ // The panel's toolbar owns stage/unstage/discard; per-chunk accept/reject
170
+ // would be a second, weaker write path into the working tree.
171
+ mergeControls: false,
172
+ gutter: true,
173
+ // Deleted lines are widgets, so the Shiki decorations cannot reach them;
174
+ // leaving this on would colour them from a grammar the editor does not
175
+ // have installed.
176
+ syntaxHighlightDeletions: false,
177
+ });
178
+
179
+ const view = new EditorView({
180
+ parent,
181
+ state: EditorState.create({
182
+ doc: options.next,
183
+ extensions: [...baseExtensions(options, wrap), collapse.of(unified(original))],
184
+ }),
185
+ });
186
+ if (options.scrollTestId) view.scrollDOM.dataset.testid = options.scrollTestId;
187
+ options.onScroller(view.scrollDOM);
188
+
189
+ return {
190
+ setDocuments(nextOriginal, next) {
191
+ original = nextOriginal;
192
+ view.dispatch({
193
+ changes: { from: 0, to: view.state.doc.length, insert: next },
194
+ // The merge extension reads `original` from its own configuration, so a
195
+ // new pair means reconfiguring it — a document-only dispatch would keep
196
+ // comparing against the previous side.
197
+ effects: collapse.reconfigure(unified(original)),
198
+ });
199
+ },
200
+ setCollapse(mode) {
201
+ options.collapse = mode;
202
+ view.dispatch({ effects: collapse.reconfigure(unified(original)) });
203
+ },
204
+ setWordWrap(wordWrap) {
205
+ options.wordWrap = wordWrap;
206
+ view.dispatch({ effects: wrap.reconfigure(wordWrap ? EditorView.lineWrapping : []) });
207
+ },
208
+ scroller: () => view.scrollDOM,
209
+ destroy: () => {
210
+ options.onScroller(null);
211
+ view.destroy();
212
+ },
213
+ };
214
+ }
215
+
216
+ /** The Split shape: two editors, aligned by the merge view. */
217
+ export function mountSplitDiff(parent: HTMLElement, options: DiffViewOptions): DiffViewHandle {
218
+ const wrap = new Compartment();
219
+ const merge = new MergeView({
220
+ parent,
221
+ a: { doc: options.original, extensions: baseExtensions(options, wrap) },
222
+ b: { doc: options.next, extensions: baseExtensions(options, wrap) },
223
+ collapseUnchanged: collapseConfig(options.collapse),
224
+ highlightChanges: true,
225
+ gutter: true,
226
+ orientation: 'a-b',
227
+ });
228
+ // The merge view's own element is the scroller: it styles `.cm-mergeView` with
229
+ // a height and `overflow: auto`, and the two editors inside it do not scroll.
230
+ const scroller = merge.dom;
231
+ if (options.scrollTestId) scroller.dataset.testid = options.scrollTestId;
232
+ options.onScroller(scroller);
233
+
234
+ return {
235
+ setDocuments(original, next) {
236
+ replaceDoc(merge.a, original);
237
+ replaceDoc(merge.b, next);
238
+ // `chunks` is recomputed from the editors' states, so this re-runs the
239
+ // diff against the new pair.
240
+ merge.reconfigure({ collapseUnchanged: collapseConfig(options.collapse) });
241
+ },
242
+ setCollapse(mode) {
243
+ options.collapse = mode;
244
+ merge.reconfigure({ collapseUnchanged: collapseConfig(mode) });
245
+ },
246
+ setWordWrap(wordWrap) {
247
+ options.wordWrap = wordWrap;
248
+ const effects = wrap.reconfigure(wordWrap ? EditorView.lineWrapping : []);
249
+ merge.a.dispatch({ effects });
250
+ merge.b.dispatch({ effects });
251
+ },
252
+ scroller: () => scroller,
253
+ destroy: () => {
254
+ options.onScroller(null);
255
+ merge.destroy();
256
+ },
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Additions and deletions, counted from the SAME line-level diff the view draws.
262
+ *
263
+ * `Chunk.build` is the merge view's own chunking — a line-level diff, which is
264
+ * what its rows are. `diff()` would be the wrong source here: it is a
265
+ * CHARACTER diff, so changing `line 5` into `line 5 changed` reports one
266
+ * insertion of `" changed"` and no deletion at all, and the badge would claim
267
+ * two additions for two edited lines.
268
+ *
269
+ * Counting from the view's own chunks is also what makes the whitespace toggle
270
+ * honest: a change folded out of the comparison is not a changed row on screen,
271
+ * and it is not counted.
272
+ */
273
+ export function countChanges(original: string, next: string): { additions: number; deletions: number } {
274
+ const a = Text.of(original.split('\n'));
275
+ const b = Text.of(next.split('\n'));
276
+ let additions = 0;
277
+ let deletions = 0;
278
+ for (const chunk of Chunk.build(a, b)) {
279
+ // `endA`/`endB` are the end of the chunk's last line, and equal the start
280
+ // when the chunk covers no lines on that side (a pure insertion/deletion).
281
+ if (chunk.endA > chunk.fromA) deletions += a.lineAt(chunk.endA).number - a.lineAt(chunk.fromA).number + 1;
282
+ if (chunk.endB > chunk.fromB) additions += b.lineAt(chunk.endB).number - b.lineAt(chunk.fromB).number + 1;
283
+ }
284
+ return { additions, deletions };
285
+ }
286
+
287
+ /**
288
+ * `original` with every whitespace-only change replaced by the new text.
289
+ *
290
+ * The toolbar's whitespace toggle means "do not show me formatting noise", and
291
+ * the merge view compares DOCUMENTS — so the way to honour it is to make the two
292
+ * sides agree there. The old table parser folded a `-`/`+` pair whose trimmed
293
+ * text matched back into a context row; this does the same at the source, which
294
+ * is why the toggle changes the CHUNKS rather than only their colour.
295
+ *
296
+ * Only line-for-line changes are folded: an inserted or deleted line is a real
297
+ * change even when it is blank, and hiding it would make the file's line count
298
+ * disagree with what is on screen.
299
+ */
300
+ export function foldWhitespaceOnly(original: string, next: string): string {
301
+ const changes = diff(original, next);
302
+ let folded = original;
303
+ // Back to front: an earlier splice would move every later offset.
304
+ for (let index = changes.length - 1; index >= 0; index--) {
305
+ const change = changes[index];
306
+ const before = original.slice(change.fromA, change.toA);
307
+ const after = next.slice(change.fromB, change.toB);
308
+ const beforeLines = before.split('\n');
309
+ const afterLines = after.split('\n');
310
+ if (beforeLines.length !== afterLines.length) continue;
311
+ if (!beforeLines.every((line, lineIndex) => line.trim() === afterLines[lineIndex].trim())) continue;
312
+ folded = folded.slice(0, change.fromA) + after + folded.slice(change.toA);
313
+ }
314
+ return folded;
315
+ }