@kabmo-labs/kabmo 0.8.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 (254) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +314 -314
  4. package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +9 -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/shell-update-button/Modal.tsx +163 -0
  24. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  25. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  26. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  27. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  28. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  29. package/src/client/components/layout/desktop-layout/index.tsx +47 -67
  30. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  31. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  32. package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
  33. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  34. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  35. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  36. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  37. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  38. package/src/client/components/settings/Modal.tsx +8 -0
  39. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  40. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  41. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  42. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
  43. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
  44. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  45. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  46. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  47. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  48. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  49. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  57. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  58. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  59. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  65. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  66. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  67. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  68. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  69. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  70. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  71. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  72. package/src/client/components/workspace/editor/Body.tsx +4 -7
  73. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  74. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  75. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  76. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  77. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  78. package/src/client/components/workspace/editor/index.tsx +7 -9
  79. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  80. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  81. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  82. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  83. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  84. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  85. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  86. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  87. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  88. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  89. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  90. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  91. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  92. package/src/client/hooks/chat/timeline/index.ts +19 -24
  93. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  94. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  95. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  96. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  97. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  98. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  99. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  100. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  101. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  102. package/src/client/hooks/editor/find-state.ts +1 -1
  103. package/src/client/hooks/editor/theme.test.ts +206 -0
  104. package/src/client/hooks/editor/theme.ts +82 -0
  105. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  106. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  107. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  108. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  109. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  110. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  111. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  112. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  113. package/src/client/hooks/ui/settings-modal.ts +56 -0
  114. package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
  115. package/src/client/hooks/ui/updates/shell-download.ts +55 -14
  116. package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
  117. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  118. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  119. package/src/client/hooks/workspace/diff-file.ts +29 -10
  120. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  121. package/src/client/hooks/workspace/editor-view.ts +61 -0
  122. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  123. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  124. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  125. package/src/client/hooks/workspace/worktree/index.ts +20 -5
  126. package/src/client/lib/desktop/bridge.ts +45 -0
  127. package/src/client/lib/search-reveal.ts +1 -1
  128. package/src/client/main.tsx +5 -1
  129. package/src/client/tailwind.css +37 -8
  130. package/src/server/lib/auth/guard.test.ts +28 -0
  131. package/src/server/lib/auth/guard.ts +10 -0
  132. package/src/server/lib/fs/worktree/create.ts +2 -3
  133. package/src/server/lib/fs/worktree/list.ts +13 -0
  134. package/src/server/lib/fs/worktree/remove.ts +3 -3
  135. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  136. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  137. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  138. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  139. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  140. package/src/server/lib/lifecycle/probe.ts +121 -13
  141. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  142. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  143. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  144. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  145. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  146. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  147. package/src/server/lib/realtime/signals.server.ts +10 -0
  148. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  149. package/src/server/lib/realtime/topics.server.ts +16 -0
  150. package/src/server/lib/updates/shell-assets.ts +94 -3
  151. package/src/server/lib/updates/shell-cache.ts +105 -0
  152. package/src/server/lib/updates/shell-pages.test.ts +33 -2
  153. package/src/server/lib/updates/shell-pages.ts +17 -5
  154. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  155. package/src/server/lib/updates/shell-release.test.ts +10 -27
  156. package/src/server/lib/updates/shell-release.ts +107 -111
  157. package/src/server/routes/fs/git-merge.test.ts +11 -1
  158. package/src/server/routes/fs/read.test.ts +24 -7
  159. package/src/server/routes/fs/scope.test.ts +15 -10
  160. package/src/server/routes/fs/scope.ts +3 -14
  161. package/src/server/routes/updates/android.ts +6 -1
  162. package/src/server/routes/updates/desktop.ts +29 -7
  163. package/src/server/routes/updates/ios.ts +5 -1
  164. package/src/server/routes/worktrees/list.ts +11 -1
  165. package/src/server/routes/worktrees/routes.test.ts +17 -0
  166. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  167. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  168. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  169. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  170. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  171. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  172. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  173. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  174. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  175. package/src/shared/lib/code/editor/keymap.ts +3 -3
  176. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  177. package/src/shared/lib/code/editor/lines.ts +8 -48
  178. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  179. package/src/shared/lib/code/editor/marks.ts +13 -25
  180. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  181. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  182. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  183. package/src/shared/lib/code/editor/themes.ts +165 -0
  184. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  185. package/src/shared/lib/code/highlight-memo.ts +149 -0
  186. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  187. package/src/shared/lib/code/highlighter.ts +3 -1
  188. package/src/shared/lib/code/idle.ts +37 -0
  189. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  190. package/src/shared/lib/code/outline/rows.ts +94 -0
  191. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  192. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  193. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  194. package/src/shared/lib/fs/file-kind.ts +47 -13
  195. package/src/shared/lib/fs/file-url.test.ts +52 -0
  196. package/src/shared/lib/fs/file-url.ts +37 -0
  197. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  198. package/src/shared/lib/fs/patch-sides.ts +139 -0
  199. package/src/shared/lib/realtime/protocol.ts +16 -0
  200. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  201. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  202. package/src/shared/lib/updates/shell-arch.ts +54 -0
  203. package/src/shared/lib/updates/shell-ua.ts +4 -4
  204. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  205. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  206. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  207. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  208. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  209. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  210. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  211. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  212. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  213. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  214. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  215. package/src/shared/types/realtime.ts +29 -0
  216. package/src/shared/types/settings/state.ts +10 -0
  217. package/src/test-support/loopback.ts +183 -0
  218. package/dist/client/static/js/index-gtbpewr7.css +0 -1
  219. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  220. package/src/client/components/common/code-editor/handler.ts +0 -175
  221. package/src/client/components/common/code-editor/index.tsx +0 -314
  222. package/src/client/components/common/code-surface/measure.ts +0 -148
  223. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
  224. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
  225. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  226. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  227. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  228. package/src/client/hooks/editor/scroll-container.ts +0 -26
  229. package/src/client/hooks/editor/use-code-window.ts +0 -100
  230. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  231. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  232. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  233. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  234. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  235. package/src/shared/lib/code/editor/commands.ts +0 -260
  236. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  237. package/src/shared/lib/code/editor/edits.ts +0 -122
  238. package/src/shared/lib/code/editor/history.test.ts +0 -118
  239. package/src/shared/lib/code/editor/history.ts +0 -101
  240. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  241. package/src/shared/lib/code/editor/layers.ts +0 -67
  242. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  243. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  244. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  245. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  246. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  247. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  248. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  249. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  250. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  251. package/src/shared/lib/code/lazy-window.ts +0 -156
  252. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  253. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  254. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -4,19 +4,20 @@
4
4
  */
5
5
 
6
6
  /**
7
- * A long diff must SCROLL inside its height ceiling.
7
+ * A diff card's own chrome: the height ceiling and the counts.
8
8
  *
9
- * The bug: the wrapper carried only `max-h-72 overflow-hidden`, and the panel
10
- * views resolve `h-full` against their parent. `height: 100%` against a box
11
- * whose height is `auto` resolves to `auto`, so the view grew to its full
12
- * content — measured 1521px inside a 288px parent — and `overflow-hidden`
13
- * clipped the rest. `scrollHeight === clientHeight`, so nothing could scroll
14
- * and the reader simply could not reach the bottom of the diff.
9
+ * The bug the ceiling pins: the wrapper carried only `max-h-72 overflow-hidden`
10
+ * and the view inside resolved `h-full` against its parent. `height: 100%`
11
+ * against a box whose height is `auto` resolves to `auto`, so the view grew to
12
+ * its full content — measured 1521px inside a 288px parent — and `overflow-hidden`
13
+ * clipped the rest. `scrollHeight === clientHeight`, so nothing could scroll.
14
+ * `min-h-0` on the flex column is what gives the child a definite height.
15
15
  *
16
- * Asserted on the classes the fix depends on, because the failure mode is a
17
- * LAYOUT resolution that happy-dom does not compute: `min-h-0` on a flex column
18
- * is what gives the child a definite height, and `overflow-hidden` on the
19
- * wrapper is what stops a second scroller from appearing.
16
+ * The ROWS are not asserted here any more. The card renders through
17
+ * CodeMirror's merge view, which draws only its viewport — and happy-dom
18
+ * computes no layout, so a viewport is empty. Row rendering is covered where it
19
+ * can be observed: `git-panel.e2e.ts` against the real panel, and
20
+ * `patch-sides.test.ts` for the pair the card hands over.
20
21
  */
21
22
 
22
23
  import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
@@ -49,11 +50,11 @@ afterAll(() => {
49
50
  /** omp's excerpt diff, long enough to overflow any ceiling. */
50
51
  const EXCERPT = Array.from({ length: 120 }, (_, i) => `${i + 1}|line ${i + 1}`).join('\n');
51
52
 
52
- async function mount() {
53
+ async function mount(text = EXCERPT) {
53
54
  container = document.createElement('div');
54
55
  document.body.appendChild(container);
55
56
  await act(async () => {
56
- render(h(DiffView, { text: EXCERPT, path: 'src/x.ts' }), container);
57
+ render(h(DiffView, { text, path: 'src/x.ts' }), container);
57
58
  });
58
59
  return container;
59
60
  }
@@ -75,25 +76,23 @@ describe('DiffView height ceiling', () => {
75
76
  expect(wrap.className).toContain('flex');
76
77
  expect(wrap.className).toContain('flex-col');
77
78
  // Without `min-h-0` a flex item refuses to shrink below its content, the
78
- // view's `h-full` resolves to `auto`, and nothing scrolls.
79
+ // view's height resolves to `auto`, and nothing scrolls.
79
80
  expect(wrap.className).toContain('min-h-0');
80
81
  });
81
82
 
82
- test('the view inside is the element that scrolls', async () => {
83
- const el = await mount();
84
- const wrap = el.querySelector('div[class*="max-h-72"]') as HTMLElement;
85
- const view = wrap.firstElementChild as HTMLElement;
86
- expect(view.className).toContain('overflow-auto');
87
- expect(view.className).toContain('h-full');
83
+ test('the header counts come from the patch, not from git', async () => {
84
+ // ` 1|a / -2|b / +2|c` is an excerpt diff: one deletion, one addition.
85
+ const el = await mount(' 1|a\n-2|b\n+2|c');
86
+ const text = el.textContent ?? '';
87
+ expect(text).toContain('+1');
88
+ expect(text).toContain('\u22121');
88
89
  });
89
90
 
90
- test('a short diff still renders its rows', async () => {
91
- container = document.createElement('div');
92
- document.body.appendChild(container);
93
- await act(async () => {
94
- render(h(DiffView, { text: ' 1|a\n-2|b\n+2|c', path: 'src/x.ts' }), container);
95
- });
96
- expect(container.textContent).toContain('c');
97
- expect(container.textContent).not.toContain('2|b');
91
+ test('a header-only patch falls back to the raw text', async () => {
92
+ const el = await mount('--- a/x\n+++ b/x\n');
93
+ // Nothing to compare means nothing to draw: the reader still sees the bytes
94
+ // the tool returned rather than an empty box.
95
+ expect(el.textContent).toContain('--- a/x');
96
+ expect(el.querySelector('.cm-editor')).toBeNull();
98
97
  });
99
98
  });
@@ -17,20 +17,23 @@
17
17
  * first, so ONE parser serves both — and because the conversion recovers the
18
18
  * gutter numbers, the rows get real line numbers instead of `...`.
19
19
  *
20
- * The rendering itself is the diff panel's own `UnifiedView`/`SplitView`, so a
21
- * diff in the chat and a diff of the working tree are the same surface rather
22
- * than two implementations that drift.
20
+ * The rendering is the diff panel's own merge view, so a diff in the chat and a
21
+ * diff of the working tree are the same surface rather than two implementations
22
+ * that drift. A card has only the PATCH, not the two documents, so the two sides
23
+ * are reconstructed from the parsed rows — a context row appears on both, a `-`
24
+ * only on the original, a `+` only on the new one.
23
25
  */
24
26
 
25
27
  import { useMemo, useState } from 'preact/hooks';
26
28
  import type { ReactNode } from 'preact/compat';
27
29
  import { Columns2, Rows3, WrapText } from 'lucide-preact';
28
- import { parseUnifiedDiff } from '@/shared/lib/fs/diff-parser';
30
+ import { patchSides } from '@/shared/lib/fs/patch-sides';
29
31
  import { excerptDiffToUnified, isExcerptDiff } from '@/shared/lib/fs/excerpt-diff';
30
32
  import { getLanguageFromPath } from '@/shared/lib/code/language';
31
33
  import { CopyButton } from '@/client/components/common/CopyButton';
32
- import { UnifiedView } from '@/client/components/workspace/diff-panel/UnifiedView';
33
- import { SplitView } from '@/client/components/workspace/diff-panel/SplitView';
34
+ import { MergeDiffView } from '@/client/components/workspace/diff-panel/MergeDiffView';
35
+ import { useEditorFontStack } from '@/client/hooks/editor/font';
36
+ import { EDITOR_DEFAULT_FONT_SIZE } from '@/shared/lib/code/editor/typography';
34
37
 
35
38
  /** A small icon-only toolbar button, `aria-pressed` for its own state. */
36
39
  function ToggleButton({
@@ -80,21 +83,19 @@ export function DiffView({ text, path = '', maxHeightClass = 'max-h-72' }: DiffV
80
83
  // omp's excerpt is converted first; a git unified diff passes through.
81
84
  const unified = useMemo(() => (isExcerptDiff(text) ? excerptDiffToUnified(text) : text), [text]);
82
85
 
83
- // Only the view on screen is built: a full-context diff would otherwise
84
- // allocate both the flat line list and the paired split rows to draw one.
85
- const parsed = useMemo(
86
- () => parseUnifiedDiff(unified, { views: split ? 'split' : 'unified' }),
87
- [unified, split],
88
- );
86
+ // A card has a PATCH and no documents, so the two sides are rebuilt from it:
87
+ // context on both, `-` on the original, `+` on the new one. That is the same
88
+ // alignment the merge view would compute from two files, restricted to the
89
+ // hunks the patch carries.
90
+ const sides = useMemo(() => patchSides(unified), [unified]);
91
+ const editorFontFamily = useEditorFontStack();
89
92
 
90
93
  // The excerpt's header carries no filename, so the caller's path is the only
91
- // name the block can show; the unified header is preferred when it exists.
92
- const fileLabel = parsed.lines.find((line) => line.type === 'meta')?.text
93
- ? ''
94
- : path.split('/').pop() ?? '';
95
- const language = getLanguageFromPath(path || parsed.splitRows[0]?.right?.text || '');
94
+ // name the block can show — unless the patch names itself.
95
+ const fileLabel = sides.hasHunk ? '' : (path.split('/').pop() ?? '');
96
+ const language = getLanguageFromPath(path || sides.next.split('\n')[0] || '');
96
97
 
97
- if (parsed.lines.length === 0 && parsed.splitRows.length === 0) {
98
+ if (sides.rows === 0) {
98
99
  return (
99
100
  <pre className="max-h-48 overflow-auto rounded-lg border border-ink/8 bg-paper p-3 font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-ink/80 select-text">
100
101
  {text}
@@ -109,8 +110,8 @@ export function DiffView({ text, path = '', maxHeightClass = 'max-h-72' }: DiffV
109
110
  <span className="truncate font-mono text-[10.5px] text-ink/60">{fileLabel}</span>
110
111
  )}
111
112
  <span className="flex items-center gap-1.5 font-mono text-[10px]">
112
- <span className="text-success">+{parsed.additions}</span>
113
- <span className="text-error">{'\u2212'}{parsed.deletions}</span>
113
+ <span className="text-success">+{sides.additions}</span>
114
+ <span className="text-error">{'\u2212'}{sides.deletions}</span>
114
115
  </span>
115
116
  <span className="ml-auto flex items-center gap-1">
116
117
  <ToggleButton
@@ -150,12 +151,24 @@ export function DiffView({ text, path = '', maxHeightClass = 'max-h-72' }: DiffV
150
151
  own content and this wrapper only bounds it. */}
151
152
  <div className={`flex ${maxHeightClass} min-h-0 flex-col overflow-hidden`}>
152
153
  {/* No tab key: this diff lives inside a chat card, and the timeline —
153
- not the card — is what the reader scrolls. */}
154
- {split ? (
155
- <SplitView rows={parsed.splitRows} language={language} wordWrap={wordWrap} tabKey={null} />
156
- ) : (
157
- <UnifiedView lines={parsed.lines} language={language} wordWrap={wordWrap} tabKey={null} />
158
- )}
154
+ not the card — is what the reader scrolls. No counts either: the
155
+ header above already prints them from the patch. */}
156
+ <MergeDiffView
157
+ original={sides.original}
158
+ next={sides.next}
159
+ language={language}
160
+ viewMode={split ? 'split' : 'unified'}
161
+ // Collapsed gaps: the reconstructed sides carry blank padding up to
162
+ // each hunk's real line, so showing every row would draw thousands of
163
+ // empty lines between two changes.
164
+ contentMode="diff"
165
+ wordWrap={wordWrap}
166
+ ignoreWhitespace={false}
167
+ tabKey={null}
168
+ empty={false}
169
+ fontFamily={editorFontFamily}
170
+ fontSize={EDITOR_DEFAULT_FONT_SIZE}
171
+ />
159
172
  </div>
160
173
  </div>
161
174
  );
@@ -1,7 +1,6 @@
1
1
  import { useMemo } from 'preact/hooks';
2
2
  import { getLanguageFromPath } from '@/shared/lib/code/language';
3
- import { highlightLines } from '@/shared/lib/code/syntax-highlight';
4
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
3
+ import { useDeferredHighlightLines } from '@/client/hooks/code/deferred-highlight';
5
4
  import { CopyButton } from '@/client/components/common/CopyButton';
6
5
  import { MAX_OUTPUT_LINES, truncateTailLines } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/truncate';
7
6
 
@@ -45,16 +44,17 @@ interface ExcerptCodeProps {
45
44
  export function ExcerptCode({ lines, path = '', tag }: ExcerptCodeProps) {
46
45
  const rows = useMemo(() => splitExcerptRows(lines), [lines]);
47
46
  const lang = getLanguageFromPath(path);
48
- const syntaxReady = useSyntaxReady();
49
47
 
50
- const { html, hidden } = useMemo(() => {
51
- // Only the tail is rendered, so only the tail is highlighted — tokenizing
52
- // the dropped head would be work whose output is thrown away.
48
+ // Only the tail is rendered, so only the tail is highlighted — tokenizing the
49
+ // dropped head would be work whose output is thrown away. The tokenizer itself
50
+ // runs off the expand that asked for it (see `deferred-highlight`).
51
+ const { code: visibleCode, hidden } = useMemo(() => {
53
52
  const code = rows.map((row) => row.code);
54
53
  const truncated = truncateTailLines(code.join('\n'), MAX_OUTPUT_LINES);
55
54
  const visible = truncated.skipped > 0 ? code.slice(truncated.skipped) : code;
56
- return { html: highlightLines(visible.join('\n'), lang), hidden: truncated.skipped };
57
- }, [rows, lang, syntaxReady]);
55
+ return { code: visible.join('\n'), hidden: truncated.skipped };
56
+ }, [rows]);
57
+ const html = useDeferredHighlightLines(visibleCode, lang);
58
58
 
59
59
  const visibleRows = hidden > 0 ? rows.slice(hidden) : rows;
60
60
 
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * A `<pre>` whose contents are tokenized off the render that asked for them.
8
+ *
9
+ * A hook cannot be called inside a `render` callback (Bash's `OutputWindow` and
10
+ * SearchTool's per-file rows both draw code from one), so the deferred highlight
11
+ * lives in this component instead: a caller hands over the code and the
12
+ * language, and the markup arrives one idle slice later.
13
+ *
14
+ * The `shiki` class is on the `<pre>` from the first paint, so the plain-text
15
+ * frame and the tokenized one are the same element — the swap changes colour
16
+ * and nothing else.
17
+ */
18
+
19
+ import { useDeferredHighlightLines } from '@/client/hooks/code/deferred-highlight';
20
+
21
+ interface HighlightedCodeProps {
22
+ code: string;
23
+ language: string;
24
+ className?: string;
25
+ }
26
+
27
+ export function HighlightedCode({ code, language, className = '' }: HighlightedCodeProps) {
28
+ const lines = useDeferredHighlightLines(code, language);
29
+ return (
30
+ <pre
31
+ className={`shiki ${className}`}
32
+ dangerouslySetInnerHTML={{ __html: lines.join('\n') }}
33
+ />
34
+ );
35
+ }
@@ -2,8 +2,7 @@ import type { ReactNode } from 'preact/compat';
2
2
  import { useMemo } from 'preact/hooks';
3
3
  import { Braces } from 'lucide-preact';
4
4
  import { CopyButton } from '@/client/components/common/CopyButton';
5
- import { highlightJson } from '@/shared/lib/code/syntax-highlight';
6
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
5
+ import { useDeferredHighlight } from '@/client/hooks/code/deferred-highlight';
7
6
 
8
7
  interface JsonCodeBlockProps {
9
8
  /** Formatted JSON string to render */
@@ -33,8 +32,9 @@ export function JsonCodeBlock({
33
32
  }: JsonCodeBlockProps) {
34
33
  const effectiveMaxHeight = maxHeightClass || (compact ? 'max-h-40' : 'max-h-56');
35
34
  const lines = useMemo(() => jsonString.split('\n'), [jsonString]);
36
- const syntaxReady = useSyntaxReady();
37
- const highlighted = useMemo(() => highlightJson(jsonString), [jsonString, syntaxReady]);
35
+ // `json` is an eager grammar, so the memo usually answers on the first paint;
36
+ // a cold card still draws the text immediately and colours it a beat later.
37
+ const highlighted = useDeferredHighlight(jsonString, 'json');
38
38
 
39
39
  return (
40
40
  <div className={`overflow-hidden ${compact ? 'rounded-md' : 'rounded-lg'} border border-ink/8 bg-paper ${className}`}>
@@ -0,0 +1,179 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The diff body: CodeMirror's merge view, mounted once and reconfigured.
8
+ *
9
+ * The panel above this owns the toolbar, the git actions and the persisted view
10
+ * choices; this owns the two editors. Keeping them apart is what lets a stage /
11
+ * unstage / discard re-read the documents without re-mounting the editor (which
12
+ * would drop the reader's scroll position, and does so on every action).
13
+ *
14
+ * `Diff Only` ↔ `Full Code` is a compartment reconfigure, not a re-fetch: the
15
+ * documents are already both sides of the file, and the server's `-U<n>` probe
16
+ * existed only because the old table renderer could not collapse anything.
17
+ */
18
+
19
+ import { useEffect, useRef, useState } from 'preact/hooks';
20
+
21
+ import { useScrollRestore } from '@/client/hooks/editor/use-scroll-restore';
22
+ import { EDITOR_LINE_HEIGHT } from '@/shared/lib/code/editor/typography';
23
+ import {
24
+ countChanges,
25
+ foldWhitespaceOnly,
26
+ mountSplitDiff,
27
+ mountUnifiedDiff,
28
+ type DiffViewHandle,
29
+ } from '@/client/components/workspace/diff-panel/merge';
30
+ import type { DiffContentMode, DiffViewMode } from '@/client/components/workspace/diff-panel/Toolbar';
31
+
32
+ interface MergeDiffViewProps {
33
+ /** The document before the change. */
34
+ original: string;
35
+ /** The document after it. */
36
+ next: string;
37
+ language: string;
38
+ viewMode: DiffViewMode;
39
+ contentMode: DiffContentMode;
40
+ wordWrap: boolean;
41
+ ignoreWhitespace: boolean;
42
+ /** The tab this diff belongs to, so its offset is remembered per shape. */
43
+ tabKey: string | null;
44
+ /**
45
+ * Additions and deletions of the pair actually on screen. Omitted by a host
46
+ * that draws its own badge from the patch (the chat's tool card).
47
+ */
48
+ onCounts?: (counts: { additions: number; deletions: number }) => void;
49
+ /** Nothing to compare (both sides empty). */
50
+ empty: boolean;
51
+ fontFamily: string;
52
+ fontSize: number;
53
+ /** The editor theme's CSS variables (`editorThemeVars`); absent means `auto`. */
54
+ themeVars?: Record<string, string>;
55
+ /** The two Shiki theme names to tokenize with. */
56
+ themes?: { light: string; dark: string };
57
+ }
58
+
59
+ export function MergeDiffView({
60
+ original,
61
+ next,
62
+ language,
63
+ viewMode,
64
+ contentMode,
65
+ wordWrap,
66
+ ignoreWhitespace,
67
+ tabKey,
68
+ onCounts,
69
+ empty,
70
+ fontFamily,
71
+ fontSize,
72
+ themeVars,
73
+ themes,
74
+ }: MergeDiffViewProps) {
75
+ const hostRef = useRef<HTMLDivElement | null>(null);
76
+ const handleRef = useRef<DiffViewHandle | null>(null);
77
+ const [scroller, setScroller] = useState<HTMLElement | null>(null);
78
+ /** The pair the live view holds, so a change is a re-state rather than a re-mount. */
79
+ const pairRef = useRef<{ original: string; next: string }>({ original, next });
80
+
81
+ const left = ignoreWhitespace ? foldWhitespaceOnly(original, next) : original;
82
+
83
+ // The scroll offset is per SHAPE: unified and split rows are different
84
+ // heights, so one view's position says nothing about the other's. The hook
85
+ // wires the element's own `scroll` listener when it is handed one, so there is
86
+ // no binding to spread here.
87
+ useScrollRestore({
88
+ tabKey,
89
+ surface: viewMode === 'split' ? 'split' : 'unified',
90
+ element: scroller,
91
+ });
92
+
93
+ // Mount, and re-mount only when the SHAPE changes — the two shapes are
94
+ // different objects (an `EditorView` and a `MergeView`), so there is nothing
95
+ // to reconfigure between them.
96
+ useEffect(() => {
97
+ const host = hostRef.current;
98
+ if (!host || empty) return;
99
+ const options = {
100
+ original: left,
101
+ next,
102
+ language,
103
+ wordWrap,
104
+ collapse: contentMode,
105
+ themes,
106
+ scrollTestId: 'diff-scroller',
107
+ onScroller: setScroller,
108
+ } as const;
109
+ const handle =
110
+ viewMode === 'split' ? mountSplitDiff(host, { ...options }) : mountUnifiedDiff(host, { ...options });
111
+ handleRef.current = handle;
112
+ pairRef.current = { original: left, next };
113
+
114
+ return () => {
115
+ handle.destroy();
116
+ handleRef.current = null;
117
+ };
118
+ // Re-mounted on the shape, the language, the emptiness and the THEME only:
119
+ // every other input is a reconfigure below. A theme is baked into the
120
+ // tokenizer plugin (Shiki is handed theme NAMES at tokenize time), so a
121
+ // colour change rebuilds it — the documents are re-stated by the effect
122
+ // below, and a diff has no history to lose.
123
+ // eslint-disable-next-line react-hooks/exhaustive-deps
124
+ }, [viewMode, language, empty, themes?.light, themes?.dark]);
125
+
126
+ // Documents changed (a refresh, a stage, a discard, the whitespace toggle).
127
+ useEffect(() => {
128
+ const handle = handleRef.current;
129
+ if (!handle) return;
130
+ if (pairRef.current.original === left && pairRef.current.next === next) return;
131
+ pairRef.current = { original: left, next };
132
+ handle.setDocuments(left, next);
133
+ }, [left, next]);
134
+
135
+ useEffect(() => {
136
+ handleRef.current?.setCollapse(contentMode);
137
+ }, [contentMode]);
138
+
139
+ useEffect(() => {
140
+ handleRef.current?.setWordWrap(wordWrap);
141
+ }, [wordWrap]);
142
+
143
+ // Counts describe the pair ON SCREEN, so the badge cannot disagree with the
144
+ // rows under it (git's own `+n/-n` still counts a hidden whitespace change).
145
+ useEffect(() => {
146
+ if (!onCounts) return;
147
+ onCounts(empty ? { additions: 0, deletions: 0 } : countChanges(left, next));
148
+ }, [left, next, empty, onCounts]);
149
+
150
+ if (empty) {
151
+ return (
152
+ <div className="flex-1 flex items-center justify-center text-ink/40 font-mono text-xs p-8">
153
+ No differences between working file and target.
154
+ </div>
155
+ );
156
+ }
157
+
158
+ return (
159
+ <div
160
+ ref={hostRef}
161
+ className="flex-1 min-h-0 overflow-hidden bg-paper text-ink"
162
+ style={{
163
+ ...themeVars,
164
+ fontFamily,
165
+ fontSize: `${fontSize}px`,
166
+ lineHeight: EDITOR_LINE_HEIGHT,
167
+ // A short diff would otherwise leave the merge view's own height at the
168
+ // panel's, which is what its `.cm-mergeView` rule expects.
169
+ ['--surface-pad' as string]: '8px',
170
+ }}
171
+ // A read-only CodeMirror swallows `drop` too (its handler answers `true`
172
+ // for a read-only state, which cancels the event), so a file dropped on a
173
+ // diff would reach neither the panel's handler nor anything else. Marking
174
+ // the host is what lets the enclosing drop target claim the gesture in the
175
+ // CAPTURE phase and open the file — the same rule the code surface states.
176
+ data-code-surface=""
177
+ />
178
+ );
179
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `MergeDiffView` with the editor theme applied.
8
+ *
9
+ * A wrapper rather than a prop at the call site because the diff panel and the
10
+ * chat's diff card are the same surface: both must wear the editor theme the
11
+ * user picked, and the panel's own file is at the size ceiling. Keeping the
12
+ * subscription here means the theme reaches every diff with no second
13
+ * `useEditorTheme` call to keep in step.
14
+ */
15
+
16
+ import { MergeDiffView } from '@/client/components/workspace/diff-panel/MergeDiffView';
17
+ import { useEditorTheme } from '@/client/hooks/editor/theme';
18
+
19
+ type MergeDiffViewProps = Parameters<typeof MergeDiffView>[0];
20
+
21
+ export function ThemedMergeDiffView(props: MergeDiffViewProps) {
22
+ const editorTheme = useEditorTheme();
23
+ return <MergeDiffView {...props} themeVars={editorTheme.vars} themes={editorTheme.themes} />;
24
+ }