@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.90

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 (316) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +42 -34
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +77 -51
  10. package/src/App.diff.test.tsx +259 -45
  11. package/src/App.export.test.tsx +58 -41
  12. package/src/App.focus.test.ts +89 -0
  13. package/src/App.identity.test.tsx +78 -0
  14. package/src/App.plan-render.test.tsx +209 -0
  15. package/src/App.plan-review.test.tsx +330 -161
  16. package/src/App.prototype-markdown.test.tsx +128 -0
  17. package/src/App.prototype.test.tsx +176 -0
  18. package/src/App.readonly.test.tsx +129 -67
  19. package/src/App.regression.test.tsx +94 -0
  20. package/src/App.rendering.test.tsx +61 -35
  21. package/src/App.share-dialog.test.tsx +172 -0
  22. package/src/App.submit-confirm.test.tsx +264 -0
  23. package/src/App.test.tsx +402 -126
  24. package/src/App.toast.test.tsx +98 -0
  25. package/src/App.tsx +1402 -1060
  26. package/src/App.visual.test.tsx +57 -0
  27. package/src/App.walk.test.tsx +299 -0
  28. package/src/App.workbench.test.tsx +774 -0
  29. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  30. package/src/annotation-palette.ts +62 -0
  31. package/src/ansi-screen.test.ts +28 -0
  32. package/src/ansi-screen.ts +72 -0
  33. package/src/app-key-state.ts +61 -0
  34. package/src/app-screens.tsx +577 -0
  35. package/src/app-view-model.ts +168 -0
  36. package/src/attribution.test.ts +31 -0
  37. package/src/attribution.ts +42 -0
  38. package/src/client-extension-registry.test.tsx +61 -0
  39. package/src/client-extension-registry.ts +159 -0
  40. package/src/clipboard.test.ts +23 -0
  41. package/src/clipboard.ts +33 -0
  42. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  43. package/src/components/AnnotatableFileView.test.tsx +102 -0
  44. package/src/components/AnnotatableFileView.tsx +134 -0
  45. package/src/components/AnnotationCards.stories.tsx +86 -0
  46. package/src/components/AnnotationCards.tsx +341 -0
  47. package/src/components/AnsiScreen.stories.tsx +12 -0
  48. package/src/components/AnsiScreen.tsx +28 -0
  49. package/src/components/AppHeader.stories.tsx +50 -0
  50. package/src/components/AppHeader.tsx +83 -0
  51. package/src/components/AppShell.brand.test.tsx +58 -0
  52. package/src/components/AppShell.extensions.test.tsx +191 -0
  53. package/src/components/AppShell.stories.tsx +89 -0
  54. package/src/components/AppShell.tsx +362 -0
  55. package/src/components/AppShell.zoom.test.tsx +135 -0
  56. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  57. package/src/components/BareWorkbenchFileView.tsx +64 -0
  58. package/src/components/Breadcrumb.stories.tsx +19 -0
  59. package/src/components/Breadcrumb.tsx +45 -0
  60. package/src/components/ChangesColumn.stories.tsx +44 -0
  61. package/src/components/ChangesColumn.tsx +199 -0
  62. package/src/components/ChangesFileTree.test.tsx +61 -0
  63. package/src/components/CodeBlock.stories.tsx +42 -0
  64. package/src/components/CodeBlock.tsx +84 -0
  65. package/src/components/CompletionOverlay.stories.tsx +37 -0
  66. package/src/components/CompletionOverlay.tsx +77 -0
  67. package/src/components/ConfirmCard.stories.tsx +37 -0
  68. package/src/components/ConfirmCard.tsx +150 -0
  69. package/src/components/ConfirmDialog.stories.tsx +21 -0
  70. package/src/components/ConfirmDialog.tsx +72 -0
  71. package/src/components/DiffContentView.scroll.test.tsx +149 -0
  72. package/src/components/DiffContentView.split.test.tsx +140 -0
  73. package/src/components/DiffContentView.stories.tsx +80 -0
  74. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  75. package/src/components/DiffContentView.tsx +906 -0
  76. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  77. package/src/components/DiscussionMarkerRail.tsx +186 -0
  78. package/src/components/EditorGrid.stories.tsx +59 -0
  79. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  80. package/src/components/EditorGrid.tsx +426 -0
  81. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  82. package/src/components/ExtensionZone.stories.tsx +28 -0
  83. package/src/components/ExtensionZone.tsx +145 -0
  84. package/src/components/FileContentsView.stories.tsx +16 -0
  85. package/src/components/FileContentsView.test.tsx +29 -0
  86. package/src/components/FileContentsView.tsx +98 -0
  87. package/src/components/GridTabContent.stories.tsx +59 -0
  88. package/src/components/GridTabContent.tsx +182 -0
  89. package/src/components/HandleChipsInput.stories.tsx +24 -0
  90. package/src/components/HandleChipsInput.test.tsx +35 -0
  91. package/src/components/HandleChipsInput.tsx +145 -0
  92. package/src/components/KeybindsDialog.stories.tsx +31 -0
  93. package/src/components/KeybindsDialog.tsx +103 -0
  94. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  95. package/src/components/MarkdownGridBlock.tsx +72 -0
  96. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  97. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  98. package/src/components/MarkdownThreadEditor.tsx +162 -0
  99. package/src/components/MenuBar.stories.tsx +28 -0
  100. package/src/components/MenuBar.tsx +44 -0
  101. package/src/components/NavModeHint.stories.tsx +40 -0
  102. package/src/components/NavModeHint.tsx +31 -0
  103. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  104. package/src/components/OverlayScrollbar.test.tsx +139 -0
  105. package/src/components/OverlayScrollbar.tsx +129 -0
  106. package/src/components/PaneGrid.stories.tsx +84 -0
  107. package/src/components/PaneGrid.tsx +128 -0
  108. package/src/components/PanelColumn.stories.tsx +25 -0
  109. package/src/components/PanelColumn.tsx +98 -0
  110. package/src/components/ProjectTreeView.stories.tsx +25 -0
  111. package/src/components/ProjectTreeView.test.tsx +75 -0
  112. package/src/components/ProjectTreeView.tsx +125 -0
  113. package/src/components/PromptDialog.stories.tsx +36 -0
  114. package/src/components/PromptDialog.test.tsx +61 -0
  115. package/src/components/PromptDialog.tsx +99 -0
  116. package/src/components/PrototypeContentView.stories.tsx +19 -0
  117. package/src/components/PrototypeContentView.tsx +446 -0
  118. package/src/components/RootOverlay.stories.tsx +45 -0
  119. package/src/components/RootOverlay.tsx +48 -0
  120. package/src/components/ScrollArea.stories.tsx +20 -0
  121. package/src/components/ScrollArea.tsx +44 -0
  122. package/src/components/SettingsDialog.stories.tsx +117 -0
  123. package/src/components/SettingsDialog.tsx +186 -0
  124. package/src/components/SettingsRows.stories.tsx +18 -0
  125. package/src/components/SettingsRows.tsx +112 -0
  126. package/src/components/ShareDialog.stories.tsx +121 -0
  127. package/src/components/ShareDialog.tsx +552 -0
  128. package/src/components/ShellHeader.stories.tsx +50 -0
  129. package/src/components/ShellHeader.tsx +137 -0
  130. package/src/components/SlashComposer.stories.tsx +37 -0
  131. package/src/components/SlashComposer.tsx +87 -0
  132. package/src/components/ThreadFooter.stories.tsx +30 -0
  133. package/src/components/ThreadFooter.test.tsx +47 -0
  134. package/src/components/ThreadFooter.tsx +76 -0
  135. package/src/components/ThreadTree.stories.tsx +46 -0
  136. package/src/components/ThreadTree.tsx +255 -0
  137. package/src/components/ThreadView.selection.test.tsx +951 -0
  138. package/src/components/ThreadView.slash.test.tsx +146 -0
  139. package/src/components/ThreadView.stories.tsx +26 -0
  140. package/src/components/ThreadView.tsx +525 -0
  141. package/src/components/Toast.stories.tsx +19 -0
  142. package/src/components/Toast.tsx +52 -0
  143. package/src/components/Tooltip.stories.tsx +26 -0
  144. package/src/components/Tooltip.tsx +72 -0
  145. package/src/components/TreePane.stories.tsx +106 -0
  146. package/src/components/TreePane.tsx +157 -0
  147. package/src/components/WalkWizard.stories.tsx +100 -0
  148. package/src/components/WalkWizard.tsx +150 -0
  149. package/src/components/WelcomePlayground.stories.tsx +12 -0
  150. package/src/components/WelcomePlayground.test.tsx +49 -0
  151. package/src/components/WelcomePlayground.tsx +139 -0
  152. package/src/components/WorkspacePanels.stories.tsx +36 -0
  153. package/src/components/WorkspacePanels.tsx +116 -0
  154. package/src/components/__snapshots__/stories.test.tsx.snap +2264 -0
  155. package/src/components/diff-content-view-layout.test.ts +198 -0
  156. package/src/components/diff-content-view-layout.ts +84 -0
  157. package/src/components/editor-grid.test.ts +86 -0
  158. package/src/components/editor-grid.ts +138 -0
  159. package/src/components/file-tree.test.ts +60 -0
  160. package/src/components/file-tree.ts +112 -0
  161. package/src/components/menu-control.stories.tsx +35 -0
  162. package/src/components/menu-control.tsx +43 -0
  163. package/src/components/pages/Workspace.stories.tsx +181 -0
  164. package/src/components/primitives/Button.stories.tsx +32 -0
  165. package/src/components/primitives/Button.tsx +59 -0
  166. package/src/components/primitives/Card.stories.tsx +25 -0
  167. package/src/components/primitives/Card.tsx +63 -0
  168. package/src/components/primitives/Dialog.stories.tsx +24 -0
  169. package/src/components/primitives/Dialog.test.tsx +37 -0
  170. package/src/components/primitives/Dialog.tsx +71 -0
  171. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  172. package/src/components/primitives/DialogActions.tsx +39 -0
  173. package/src/components/primitives/IconButton.stories.tsx +36 -0
  174. package/src/components/primitives/IconButton.tsx +62 -0
  175. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  176. package/src/components/primitives/StatusBar.tsx +23 -0
  177. package/src/components/primitives/Switch.stories.tsx +17 -0
  178. package/src/components/primitives/Switch.tsx +32 -0
  179. package/src/components/primitives/Tabs.stories.tsx +29 -0
  180. package/src/components/primitives/Tabs.tsx +102 -0
  181. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  182. package/src/components/primitives/Toolbar.tsx +14 -0
  183. package/src/components/primitives/Tree.stories.tsx +124 -0
  184. package/src/components/primitives/Tree.tsx +100 -0
  185. package/src/components/primitives/frame.test.ts +12 -0
  186. package/src/components/primitives/frame.ts +17 -0
  187. package/src/components/primitives/icons.ts +61 -0
  188. package/src/components/primitives/tree-model.test.ts +99 -0
  189. package/src/components/primitives/tree-model.ts +137 -0
  190. package/src/components/quick-actions-editor.stories.tsx +58 -0
  191. package/src/components/quick-actions-editor.test.tsx +117 -0
  192. package/src/components/quick-actions-editor.tsx +226 -0
  193. package/src/components/share-dialog-store.ts +58 -0
  194. package/src/components/stories-app.tsx +193 -0
  195. package/src/components/stories.test.tsx +74 -0
  196. package/src/components/story-fixtures.ts +102 -0
  197. package/src/components/story-tree.test.ts +108 -0
  198. package/src/components/story.ts +159 -0
  199. package/src/components/syntax-highlight.ts +133 -0
  200. package/src/components/terminal-pane.test.tsx +40 -0
  201. package/src/components/terminal-pane.ts +199 -0
  202. package/src/components/theme-context.stories.tsx +46 -0
  203. package/src/components/theme-context.tsx +37 -0
  204. package/src/components/thread-tree.test.ts +134 -0
  205. package/src/components/thread-tree.ts +109 -0
  206. package/src/components/truncate-text.ts +7 -0
  207. package/src/components/truncate-title.test.ts +31 -0
  208. package/src/components/truncate-title.ts +15 -0
  209. package/src/config-identity.test.ts +78 -0
  210. package/src/config.test.ts +579 -34
  211. package/src/config.ts +533 -57
  212. package/src/diff-fold.test.ts +78 -0
  213. package/src/diff-fold.ts +126 -0
  214. package/src/diff-hunk-curate.test.ts +315 -0
  215. package/src/diff-hunk-curate.ts +153 -0
  216. package/src/diff-intraline.test.ts +238 -0
  217. package/src/diff-intraline.ts +236 -0
  218. package/src/diff-syntax.test.ts +112 -0
  219. package/src/diff-syntax.ts +179 -0
  220. package/src/discussions.ts +79 -0
  221. package/src/editor.test.ts +149 -0
  222. package/src/editor.ts +139 -9
  223. package/src/ghostty-terminal.test.ts +85 -0
  224. package/src/ghostty-terminal.ts +199 -0
  225. package/src/github-identity.test.ts +58 -0
  226. package/src/github-identity.ts +41 -0
  227. package/src/herdr.test.ts +9 -34
  228. package/src/herdr.ts +25 -21
  229. package/src/index.ts +28 -0
  230. package/src/integrations.test.ts +41 -0
  231. package/src/integrations.ts +32 -0
  232. package/src/intent-dispatch.test.ts +724 -0
  233. package/src/intent-dispatch.ts +690 -0
  234. package/src/key-bindings.test.ts +156 -0
  235. package/src/key-bindings.ts +459 -0
  236. package/src/keymap.test.ts +456 -115
  237. package/src/keymap.ts +315 -81
  238. package/src/kitty-image.test.ts +66 -0
  239. package/src/kitty-image.ts +138 -0
  240. package/src/launch-layout.test.ts +67 -0
  241. package/src/launch-layout.ts +47 -0
  242. package/src/mark-runs.ts +121 -0
  243. package/src/markdown-editor-syntax-style.ts +50 -0
  244. package/src/markdown-grid.test.ts +101 -0
  245. package/src/markdown-grid.ts +203 -0
  246. package/src/markdown-highlight.test.ts +61 -0
  247. package/src/markdown-highlight.ts +122 -0
  248. package/src/pasted-image.test.ts +67 -0
  249. package/src/pasted-image.ts +60 -0
  250. package/src/perf/otlp-trace.test.ts +116 -0
  251. package/src/perf/otlp-trace.ts +130 -0
  252. package/src/perf/perf-report.ts +17 -0
  253. package/src/perf/perf-timings.test.ts +18 -0
  254. package/src/perf/perf-timings.ts +38 -0
  255. package/src/prototype-browser.test.ts +81 -0
  256. package/src/prototype-browser.ts +329 -0
  257. package/src/prototype-pixels.tsx +35 -0
  258. package/src/pty.test.ts +81 -0
  259. package/src/pty.ts +245 -0
  260. package/src/ready-signal.test.ts +85 -0
  261. package/src/ready-signal.ts +52 -0
  262. package/src/run.ts +85 -12
  263. package/src/serve.test.ts +24 -12
  264. package/src/serve.ts +52 -10
  265. package/src/share.ts +237 -0
  266. package/src/skills.test.ts +74 -0
  267. package/src/skills.ts +75 -0
  268. package/src/slash-palette.test.ts +103 -0
  269. package/src/slash-palette.ts +129 -0
  270. package/src/split-diff.test.ts +110 -0
  271. package/src/split-diff.ts +149 -0
  272. package/src/test-support.test.ts +264 -0
  273. package/src/test-support.ts +369 -0
  274. package/src/theme-presets.test.ts +51 -0
  275. package/src/theme-presets.ts +219 -0
  276. package/src/theme.ts +88 -19
  277. package/src/thread-chords.test.ts +79 -0
  278. package/src/thread-chords.ts +163 -0
  279. package/src/thread-code-syntax.ts +45 -0
  280. package/src/thread-controller.curate.test.ts +476 -0
  281. package/src/thread-controller.derivation.test.ts +62 -0
  282. package/src/thread-controller.live-diff.test.ts +322 -0
  283. package/src/thread-controller.optimistic.test.ts +69 -0
  284. package/src/thread-controller.share.test.ts +561 -0
  285. package/src/thread-controller.switch-cache.test.ts +176 -0
  286. package/src/thread-controller.tree.test.ts +261 -0
  287. package/src/thread-controller.ts +2050 -0
  288. package/src/thread-selection.test.ts +210 -0
  289. package/src/thread-selection.ts +243 -0
  290. package/src/tree-view.test.ts +128 -0
  291. package/src/tree-view.ts +153 -0
  292. package/src/use-annotation-surface.tsx +975 -0
  293. package/src/use-changes-workbench.ts +190 -0
  294. package/src/use-frame-measure.ts +41 -0
  295. package/src/use-remember-layout.ts +31 -0
  296. package/src/use-repo-changes.ts +87 -0
  297. package/src/use-settings-dialog.tsx +364 -0
  298. package/src/use-terminal-virtualizer.ts +259 -0
  299. package/src/use-thread-body-editing.tsx +58 -0
  300. package/src/version.ts +5 -0
  301. package/src/view-diff.test.ts +279 -9
  302. package/src/view-diff.ts +460 -24
  303. package/src/view-plan.regression.test.ts +60 -0
  304. package/src/view-plan.test.ts +545 -0
  305. package/src/view-plan.ts +1019 -0
  306. package/src/visual/contrast.test.ts +35 -0
  307. package/src/visual/contrast.ts +33 -0
  308. package/src/walk.test.ts +121 -0
  309. package/src/walk.ts +74 -0
  310. package/src/workbench-snapshot.test.ts +99 -0
  311. package/src/workbench-snapshot.ts +59 -0
  312. package/src/session-controller.ts +0 -398
  313. package/src/syntax.test.ts +0 -41
  314. package/src/syntax.ts +0 -88
  315. package/src/view.test.ts +0 -157
  316. package/src/view.ts +0 -364
@@ -0,0 +1,78 @@
1
+ /** Per-file collapse and full-file weave over the flat diff rows. */
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import type { DiffRow } from "./view-diff";
5
+ import { applyFold, weaveFullFileRows } from "./diff-fold";
6
+
7
+ function row(kind: DiffRow["kind"], text: string, extra: Partial<DiffRow> = {}): DiffRow {
8
+ return { kind, text, file: "a.ts", ...extra };
9
+ }
10
+
11
+ // a two-line change in a four-line file: line 2 swapped, lines 1/3/4 unchanged
12
+ const base: DiffRow[] = [
13
+ row("file", "a.ts"),
14
+ row("hunk", "@@ -1,3 +1,3 @@"),
15
+ row("ctx", "line one", { oldLine: 1, newLine: 1 }),
16
+ row("del", "old two", { oldLine: 2 }),
17
+ row("add", "new two", { newLine: 2 }),
18
+ row("ctx", "line three", { oldLine: 3, newLine: 3 }),
19
+ ];
20
+
21
+ const contents = {
22
+ path: "a.ts",
23
+ oldContents: "line one\nold two\nline three\nline four\n",
24
+ newContents: "line one\nnew two\nline three\nline four\n",
25
+ };
26
+
27
+ describe("applyFold", () => {
28
+ test("with no fold state it returns the base rows unchanged", () => {
29
+ expect(applyFold(base, new Set(), new Set(), [contents])).toBe(base);
30
+ });
31
+
32
+ test("a collapsed file keeps only its file band", () => {
33
+ // Act
34
+ const folded = applyFold(base, new Set(["a.ts"]), new Set(), [contents]);
35
+
36
+ // Assert
37
+ expect(folded).toHaveLength(1);
38
+ expect(folded[0]).toEqual(row("file", "a.ts"));
39
+ });
40
+
41
+ test("an expanded file weaves the unchanged tail line the hunk never showed", () => {
42
+ // Act
43
+ const folded = applyFold(base, new Set(), new Set(["a.ts"]), [contents]);
44
+
45
+ // Assert - the hunk header is gone and "line four" (outside the hunk) now shows
46
+ expect(folded.some((r) => r.kind === "hunk")).toBe(false);
47
+ const four = folded.find((r) => r.text === "line four");
48
+
49
+ expect(four).toEqual(row("ctx", "line four", { oldLine: 4, newLine: 4 }));
50
+ });
51
+
52
+ test("expand is a no-op without the file's full contents", () => {
53
+ // Act - no contents entry, so the file keeps its hunks
54
+ const folded = applyFold(base, new Set(), new Set(["a.ts"]), undefined);
55
+
56
+ // Assert
57
+ expect(folded).toEqual(base);
58
+ });
59
+ });
60
+
61
+ describe("weaveFullFileRows", () => {
62
+ test("keeps the change rows and fills every unchanged line as context in order", () => {
63
+ // Arrange - the file's code rows (no headers)
64
+ const codeRows = base.slice(2);
65
+
66
+ // Act
67
+ const woven = weaveFullFileRows("a.ts", codeRows, contents.newContents);
68
+
69
+ // Assert - full file: ctx1, del, add, ctx3, ctx4
70
+ expect(woven.map((r) => `${r.kind}:${r.text}`)).toEqual([
71
+ "ctx:line one",
72
+ "del:old two",
73
+ "add:new two",
74
+ "ctx:line three",
75
+ "ctx:line four",
76
+ ]);
77
+ });
78
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Per-file fold state for the diff sheet, applied over the flat diff rows so the
3
+ * cursor, curation, and rendering all read one array. A collapsed file shows only
4
+ * its file band; an expanded file shows its whole contents with the diff woven in
5
+ * (unchanged lines become context rows), an "expand all lines" view.
6
+ */
7
+
8
+ import type { DiffRow } from "./view-diff";
9
+
10
+ /** The full old/new contents a diff carries per file (curatable diffs do). */
11
+ export interface FileContents {
12
+ path: string;
13
+ newContents: string;
14
+ oldContents: string;
15
+ }
16
+
17
+ /** Split file contents into lines, dropping the single trailing newline the file carries. */
18
+ function contentLines(contents: string): string[] {
19
+ if (contents === "") return [];
20
+
21
+ return contents.replace(/\n$/, "").split("\n");
22
+ }
23
+
24
+ /**
25
+ * Weave a file's hunk rows into its full contents: unchanged lines between and
26
+ * around the hunks become context rows, so the whole file renders with the diff
27
+ * inline. `codeRows` are the file's ctx/add/del rows in order (no headers).
28
+ */
29
+ export function weaveFullFileRows(
30
+ fileName: string,
31
+ codeRows: DiffRow[],
32
+ newContents: string,
33
+ ): DiffRow[] {
34
+ const newLines = contentLines(newContents);
35
+ const woven: DiffRow[] = [];
36
+ // the next unchanged old/new line numbers to emit while filling gaps between hunks
37
+ let newCursor = 1;
38
+ let oldCursor = 1;
39
+ const fillTo = (newLine: number): void => {
40
+ while (newCursor < newLine) {
41
+ woven.push({
42
+ kind: "ctx",
43
+ text: newLines[newCursor - 1] ?? "",
44
+ file: fileName,
45
+ oldLine: oldCursor,
46
+ newLine: newCursor,
47
+ });
48
+ newCursor += 1;
49
+ oldCursor += 1;
50
+ }
51
+ };
52
+
53
+ for (const row of codeRows) {
54
+ if (row.newLine !== undefined) {
55
+ fillTo(row.newLine);
56
+ woven.push(row);
57
+ newCursor = row.newLine + 1;
58
+ if (row.kind === "ctx") oldCursor = (row.oldLine ?? oldCursor) + 1;
59
+ } else {
60
+ // a deletion consumes an old line only; it keeps its place before the additions
61
+ woven.push(row);
62
+ oldCursor = (row.oldLine ?? oldCursor) + 1;
63
+ }
64
+ }
65
+ // the unchanged tail after the last hunk
66
+ while (newCursor <= newLines.length) {
67
+ woven.push({
68
+ kind: "ctx",
69
+ text: newLines[newCursor - 1] ?? "",
70
+ file: fileName,
71
+ oldLine: oldCursor,
72
+ newLine: newCursor,
73
+ });
74
+ newCursor += 1;
75
+ oldCursor += 1;
76
+ }
77
+
78
+ return woven;
79
+ }
80
+
81
+ /**
82
+ * Rebuild the diff rows with per-file fold state. A collapsed file keeps only its
83
+ * file band; an expanded file with known contents shows its whole file woven; every
84
+ * other file keeps its hunks. Row order and the file bands themselves are preserved.
85
+ */
86
+ export function applyFold(
87
+ base: DiffRow[],
88
+ collapsed: ReadonlySet<string>,
89
+ expanded: ReadonlySet<string>,
90
+ files: readonly FileContents[] | undefined,
91
+ ): DiffRow[] {
92
+ if (collapsed.size === 0 && expanded.size === 0) return base;
93
+ const contentsByPath = new Map((files ?? []).map((file) => [file.path, file]));
94
+ const out: DiffRow[] = [];
95
+ let index = 0;
96
+
97
+ while (index < base.length) {
98
+ const row = base[index]!;
99
+
100
+ if (row.kind !== "file") {
101
+ out.push(row);
102
+ index += 1;
103
+ continue;
104
+ }
105
+ out.push(row);
106
+ const file = row.file;
107
+ let end = index + 1;
108
+
109
+ while (end < base.length && base[end]!.kind !== "file") end += 1;
110
+ const body = base.slice(index + 1, end);
111
+ const contents = contentsByPath.get(file);
112
+
113
+ if (collapsed.has(file)) {
114
+ // only the file band; the body is hidden
115
+ } else if (expanded.has(file) && contents) {
116
+ const codeRows = body.filter((bodyRow) => bodyRow.kind !== "hunk");
117
+
118
+ out.push(...weaveFullFileRows(file, codeRows, contents.newContents));
119
+ } else {
120
+ out.push(...body);
121
+ }
122
+ index = end;
123
+ }
124
+
125
+ return out;
126
+ }
@@ -0,0 +1,315 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { unifiedDiffText, type DiffFileContents } from "@cueloop/schema";
3
+ import {
4
+ changeRejectionForRow,
5
+ curateDiff,
6
+ hunkRejectionForRow,
7
+ isRowRejected,
8
+ locateLine,
9
+ parseFileDiff,
10
+ type HunkRejection,
11
+ } from "./diff-hunk-curate";
12
+ import type { DiffRow } from "./view-diff";
13
+
14
+ const OLD = "line1\nline2\nline3\nline4\nline5\n";
15
+ // Two changes in one hunk: line2 modified, then a pure addition after line4.
16
+ const NEW = "line1\nCHANGED2\nline3\nline4\nADDED\nline5\n";
17
+ const FILE: DiffFileContents = {
18
+ path: "src/a.ts",
19
+ oldContents: OLD,
20
+ newContents: NEW,
21
+ status: "modified",
22
+ };
23
+
24
+ // Two changes far apart, so the model splits into two hunks.
25
+ const MULTI_OLD = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\n";
26
+ const MULTI_NEW = "a\nB\nc\nd\ne\nf\ng\nh\ni\nj\nK\nl\n";
27
+ const MULTI: DiffFileContents = {
28
+ path: "src/b.ts",
29
+ oldContents: MULTI_OLD,
30
+ newContents: MULTI_NEW,
31
+ status: "modified",
32
+ };
33
+
34
+ describe("curateDiff", () => {
35
+ test("no rejections reproduces the full per-file diff", () => {
36
+ // Arrange
37
+ const rejections: HunkRejection[] = [];
38
+
39
+ // Act
40
+ const patch = curateDiff([FILE], rejections);
41
+
42
+ // Assert
43
+ expect(patch).toBe(unifiedDiffText(OLD, NEW, FILE.path)!);
44
+ expect(patch).toContain("+CHANGED2");
45
+ expect(patch).toContain("+ADDED");
46
+ });
47
+
48
+ test("whole-hunk reject drops every change in that hunk", () => {
49
+ // Arrange
50
+ const rejections: HunkRejection[] = [{ path: FILE.path, hunkIndex: 0 }];
51
+
52
+ // Act
53
+ const patch = curateDiff([FILE], rejections);
54
+
55
+ // Assert - the only hunk was reverted, so nothing is left to apply
56
+ expect(patch).toBe("");
57
+ });
58
+
59
+ test("change-level reject keeps the other accepted change exactly", () => {
60
+ // Arrange - reject the line2 modification (hunkContent index 1), keep ADDED
61
+ const rejections: HunkRejection[] = [{ path: FILE.path, hunkIndex: 0, changeIndex: 1 }];
62
+ const expectedNew = "line1\nline2\nline3\nline4\nADDED\nline5\n";
63
+
64
+ // Act
65
+ const patch = curateDiff([FILE], rejections);
66
+
67
+ // Assert
68
+ expect(patch).toBe(unifiedDiffText(OLD, expectedNew, FILE.path)!);
69
+ expect(patch).not.toContain("CHANGED2");
70
+ expect(patch).toContain("+ADDED");
71
+ });
72
+
73
+ test("change-level reject of the addition keeps the modification", () => {
74
+ // Arrange - reject the pure addition (hunkContent index 3), keep CHANGED2
75
+ const rejections: HunkRejection[] = [{ path: FILE.path, hunkIndex: 0, changeIndex: 3 }];
76
+ const expectedNew = "line1\nCHANGED2\nline3\nline4\nline5\n";
77
+
78
+ // Act
79
+ const patch = curateDiff([FILE], rejections);
80
+
81
+ // Assert
82
+ expect(patch).toBe(unifiedDiffText(OLD, expectedNew, FILE.path)!);
83
+ expect(patch).toContain("+CHANGED2");
84
+ expect(patch).not.toContain("ADDED");
85
+ });
86
+
87
+ test("rejecting every change in a file yields an empty patch", () => {
88
+ // Arrange
89
+ const rejections: HunkRejection[] = [
90
+ { path: FILE.path, hunkIndex: 0, changeIndex: 1 },
91
+ { path: FILE.path, hunkIndex: 0, changeIndex: 3 },
92
+ ];
93
+
94
+ // Act
95
+ const patch = curateDiff([FILE], rejections);
96
+
97
+ // Assert
98
+ expect(patch).toBe("");
99
+ });
100
+
101
+ test("whole-hunk reject supersedes a change reject for the same hunk", () => {
102
+ // Arrange - a redundant change reject alongside the whole-hunk reject
103
+ const rejections: HunkRejection[] = [
104
+ { path: FILE.path, hunkIndex: 0 },
105
+ { path: FILE.path, hunkIndex: 0, changeIndex: 1 },
106
+ ];
107
+
108
+ // Act
109
+ const patch = curateDiff([FILE], rejections);
110
+
111
+ // Assert
112
+ expect(patch).toBe("");
113
+ });
114
+
115
+ test("multi-file curation keeps each file's accepted changes independent", () => {
116
+ // Arrange - reject file A's addition; leave file B whole
117
+ const rejections: HunkRejection[] = [{ path: FILE.path, hunkIndex: 0, changeIndex: 3 }];
118
+
119
+ // Act
120
+ const patch = curateDiff([FILE, MULTI], rejections);
121
+
122
+ // Assert
123
+ expect(patch).toContain("src/a.ts");
124
+ expect(patch).toContain("src/b.ts");
125
+ expect(patch).toContain("+CHANGED2");
126
+ expect(patch).not.toContain("+ADDED");
127
+ expect(patch).toContain("+B");
128
+ expect(patch).toContain("+K");
129
+ });
130
+
131
+ test("rejecting one of two hunks reverts only that hunk", () => {
132
+ // Arrange - reject the first hunk (B), keep the second (K)
133
+ const rejections: HunkRejection[] = [{ path: MULTI.path, hunkIndex: 0 }];
134
+ const expectedNew = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nK\nl\n";
135
+
136
+ // Act
137
+ const patch = curateDiff([MULTI], rejections);
138
+
139
+ // Assert
140
+ expect(patch).toBe(unifiedDiffText(MULTI_OLD, expectedNew, MULTI.path)!);
141
+ });
142
+ });
143
+
144
+ describe("locateLine and row mapping", () => {
145
+ test("locateLine resolves a modified new line to its change block", () => {
146
+ // Arrange
147
+ const model = parseFileDiff(FILE);
148
+
149
+ // Act - CHANGED2 is new line 2
150
+ const located = locateLine(model, "addition", 2);
151
+
152
+ // Assert
153
+ expect(located).toEqual({ hunkIndex: 0, changeIndex: 1 });
154
+ });
155
+
156
+ test("locateLine resolves the pure addition to its change block", () => {
157
+ // Arrange
158
+ const model = parseFileDiff(FILE);
159
+
160
+ // Act - ADDED is new line 5
161
+ const located = locateLine(model, "addition", 5);
162
+
163
+ // Assert
164
+ expect(located).toEqual({ hunkIndex: 0, changeIndex: 3 });
165
+ });
166
+
167
+ test("locateLine resolves a context line to a hunk with no change index", () => {
168
+ // Arrange
169
+ const model = parseFileDiff(FILE);
170
+
171
+ // Act - line1 is unchanged context
172
+ const located = locateLine(model, "addition", 1);
173
+
174
+ // Assert
175
+ expect(located).toEqual({ hunkIndex: 0, changeIndex: undefined });
176
+ });
177
+
178
+ test("changeRejectionForRow targets the change under an addition row", () => {
179
+ // Arrange
180
+ const model = parseFileDiff(FILE);
181
+ const row: DiffRow = { kind: "add", text: "CHANGED2\n", file: FILE.path, newLine: 2 };
182
+
183
+ // Act
184
+ const rejection = changeRejectionForRow(FILE.path, model, row);
185
+
186
+ // Assert
187
+ expect(rejection).toEqual({ path: FILE.path, hunkIndex: 0, changeIndex: 1 });
188
+ });
189
+
190
+ test("changeRejectionForRow targets the change under a deletion row", () => {
191
+ // Arrange
192
+ const model = parseFileDiff(FILE);
193
+ const row: DiffRow = { kind: "del", text: "line2\n", file: FILE.path, oldLine: 2 };
194
+
195
+ // Act
196
+ const rejection = changeRejectionForRow(FILE.path, model, row);
197
+
198
+ // Assert
199
+ expect(rejection).toEqual({ path: FILE.path, hunkIndex: 0, changeIndex: 1 });
200
+ });
201
+
202
+ test("changeRejectionForRow returns null on a context row", () => {
203
+ // Arrange
204
+ const model = parseFileDiff(FILE);
205
+ const row: DiffRow = { kind: "ctx", text: "line1\n", file: FILE.path, oldLine: 1, newLine: 1 };
206
+
207
+ // Act
208
+ const rejection = changeRejectionForRow(FILE.path, model, row);
209
+
210
+ // Assert
211
+ expect(rejection).toBeNull();
212
+ });
213
+
214
+ test("hunkRejectionForRow targets the enclosing hunk of a context row", () => {
215
+ // Arrange
216
+ const model = parseFileDiff(FILE);
217
+ const row: DiffRow = { kind: "ctx", text: "line1\n", file: FILE.path, oldLine: 1, newLine: 1 };
218
+
219
+ // Act
220
+ const rejection = hunkRejectionForRow(FILE.path, model, row);
221
+
222
+ // Assert
223
+ expect(rejection).toEqual({ path: FILE.path, hunkIndex: 0 });
224
+ });
225
+
226
+ test("hunkRejectionForRow resolves the second hunk in a multi-hunk file", () => {
227
+ // Arrange
228
+ const model = parseFileDiff(MULTI);
229
+ const row: DiffRow = { kind: "add", text: "K\n", file: MULTI.path, newLine: 11 };
230
+
231
+ // Act
232
+ const rejection = hunkRejectionForRow(MULTI.path, model, row);
233
+
234
+ // Assert
235
+ expect(rejection).toEqual({ path: MULTI.path, hunkIndex: 1 });
236
+ });
237
+ });
238
+
239
+ describe("isRowRejected", () => {
240
+ test("a change row is rejected when its whole hunk is rejected", () => {
241
+ // Arrange
242
+ const model = parseFileDiff(FILE);
243
+ const row: DiffRow = { kind: "add", text: "ADDED\n", file: FILE.path, newLine: 5 };
244
+ const rejections: HunkRejection[] = [{ path: FILE.path, hunkIndex: 0 }];
245
+
246
+ // Act
247
+ const rejected = isRowRejected(FILE.path, model, row, rejections);
248
+
249
+ // Assert
250
+ expect(rejected).toBe(true);
251
+ });
252
+
253
+ test("a change row is rejected only by its own change-level decision", () => {
254
+ // Arrange
255
+ const model = parseFileDiff(FILE);
256
+ const addedRow: DiffRow = { kind: "add", text: "ADDED\n", file: FILE.path, newLine: 5 };
257
+ const changedRow: DiffRow = { kind: "add", text: "CHANGED2\n", file: FILE.path, newLine: 2 };
258
+ const rejections: HunkRejection[] = [{ path: FILE.path, hunkIndex: 0, changeIndex: 3 }];
259
+
260
+ // Act + Assert - only the addition's change is dropped
261
+ expect(isRowRejected(FILE.path, model, addedRow, rejections)).toBe(true);
262
+ expect(isRowRejected(FILE.path, model, changedRow, rejections)).toBe(false);
263
+ });
264
+
265
+ test("a context row is never marked rejected", () => {
266
+ // Arrange
267
+ const model = parseFileDiff(FILE);
268
+ const row: DiffRow = { kind: "ctx", text: "line1\n", file: FILE.path, oldLine: 1, newLine: 1 };
269
+ const rejections: HunkRejection[] = [{ path: FILE.path, hunkIndex: 0 }];
270
+
271
+ // Act
272
+ const rejected = isRowRejected(FILE.path, model, row, rejections);
273
+
274
+ // Assert
275
+ expect(rejected).toBe(false);
276
+ });
277
+ });
278
+
279
+ describe("curateDiff file-state headers", () => {
280
+ test("an accepted created file points its old side at /dev/null", () => {
281
+ // Arrange - a new file has empty old contents
282
+ const created: DiffFileContents = {
283
+ path: "src/new.ts",
284
+ oldContents: "",
285
+ newContents: "one\ntwo\n",
286
+ status: "added",
287
+ };
288
+
289
+ // Act
290
+ const patch = curateDiff([created], []);
291
+
292
+ // Assert
293
+ expect(patch).toContain("--- /dev/null");
294
+ expect(patch).toContain("+++ b/src/new.ts");
295
+ expect(patch).toContain("+one");
296
+ });
297
+
298
+ test("an accepted deleted file points its new side at /dev/null", () => {
299
+ // Arrange - a deleted file has empty new contents
300
+ const deleted: DiffFileContents = {
301
+ path: "src/gone.ts",
302
+ oldContents: "one\ntwo\n",
303
+ newContents: "",
304
+ status: "deleted",
305
+ };
306
+
307
+ // Act
308
+ const patch = curateDiff([deleted], []);
309
+
310
+ // Assert
311
+ expect(patch).toContain("--- a/src/gone.ts");
312
+ expect(patch).toContain("+++ /dev/null");
313
+ expect(patch).toContain("-one");
314
+ });
315
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Diff hunk curation: turn a set of reject decisions over a working-tree diff
3
+ * into the curated unified-diff patch of the accepted changes only. Each file
4
+ * is re-parsed from its FULL old/new contents (isPartial: false), so
5
+ * `diffAcceptRejectHunk` can revert a whole hunk or a single change and the
6
+ * serialized patch keeps exact line numbers and stays applyable.
7
+ *
8
+ * A rejection is addressed by parse-model coordinates, never by rendered-row
9
+ * position: `hunkIndex` indexes `model.hunks`, and `changeIndex` indexes that
10
+ * hunk's `hunkContent` (context and change blocks combined - the same space
11
+ * `diffAcceptRejectHunk`'s config consumes). A cursor row is mapped to those
12
+ * coordinates by its absolute file line number, so it stays correct even when
13
+ * the stored git patch and the model split hunks differently.
14
+ */
15
+
16
+ import type { FileDiffMetadata } from "@pierre/diffs";
17
+ import type { HunkRejection } from "@cueloop/schema";
18
+ import type { DiffRow } from "./view-diff";
19
+
20
+ export { curateDiff, parseFileDiff } from "@cueloop/daemon/curate";
21
+ export type { HunkRejection } from "@cueloop/schema";
22
+
23
+ /** Which side of the diff a line number addresses. */
24
+ type DiffSide = "addition" | "deletion";
25
+
26
+ /** A line resolved onto its owning hunk, and its change block when it is one. */
27
+ export interface LocatedLine {
28
+ hunkIndex: number;
29
+ /** Present only when the line sits inside a change block (an add/del line). */
30
+ changeIndex?: number;
31
+ }
32
+
33
+ /**
34
+ * Resolve a 1-based file line on one side to its hunk and, when the line is
35
+ * part of a change block, that block's `hunkContent` index.
36
+ */
37
+ export function locateLine(
38
+ model: FileDiffMetadata,
39
+ side: DiffSide,
40
+ lineNumber: number,
41
+ ): LocatedLine | null {
42
+ const index = lineNumber - 1;
43
+
44
+ for (const [hunkIndex, hunk] of model.hunks.entries()) {
45
+ for (const [contentIndex, content] of hunk.hunkContent.entries()) {
46
+ const base = side === "addition" ? content.additionLineIndex : content.deletionLineIndex;
47
+ const count =
48
+ content.type === "context"
49
+ ? content.lines
50
+ : side === "addition"
51
+ ? content.additions
52
+ : content.deletions;
53
+
54
+ if (count > 0 && index >= base && index < base + count) {
55
+ return { hunkIndex, changeIndex: content.type === "change" ? contentIndex : undefined };
56
+ }
57
+ }
58
+ }
59
+
60
+ return null;
61
+ }
62
+
63
+ /** The side and line number a change row addresses; null for headers/context. */
64
+ function changeSideLine(
65
+ row: Pick<DiffRow, "kind" | "oldLine" | "newLine">,
66
+ ): { side: DiffSide; lineNumber: number } | null {
67
+ if (row.kind === "add" && row.newLine !== undefined)
68
+ return { side: "addition", lineNumber: row.newLine };
69
+ if (row.kind === "del" && row.oldLine !== undefined)
70
+ return { side: "deletion", lineNumber: row.oldLine };
71
+
72
+ return null;
73
+ }
74
+
75
+ /**
76
+ * The whole-hunk rejection for the row under the cursor, or null when the row
77
+ * is not inside a hunk. Context rows resolve through their new-file line.
78
+ */
79
+ export function hunkRejectionForRow(
80
+ path: string,
81
+ model: FileDiffMetadata,
82
+ row: Pick<DiffRow, "kind" | "oldLine" | "newLine">,
83
+ ): HunkRejection | null {
84
+ const change = changeSideLine(row);
85
+ const located = change
86
+ ? locateLine(model, change.side, change.lineNumber)
87
+ : row.newLine !== undefined
88
+ ? locateLine(model, "addition", row.newLine)
89
+ : row.oldLine !== undefined
90
+ ? locateLine(model, "deletion", row.oldLine)
91
+ : null;
92
+
93
+ return located ? { path, hunkIndex: located.hunkIndex } : null;
94
+ }
95
+
96
+ /**
97
+ * The change-level rejection for the row under the cursor, or null when the row
98
+ * carries no change (a header or context line).
99
+ */
100
+ export function changeRejectionForRow(
101
+ path: string,
102
+ model: FileDiffMetadata,
103
+ row: Pick<DiffRow, "kind" | "oldLine" | "newLine">,
104
+ ): HunkRejection | null {
105
+ const change = changeSideLine(row);
106
+
107
+ if (!change) return null;
108
+ const located = locateLine(model, change.side, change.lineNumber);
109
+
110
+ if (!located || located.changeIndex === undefined) return null;
111
+
112
+ return { path, hunkIndex: located.hunkIndex, changeIndex: located.changeIndex };
113
+ }
114
+
115
+ /** Whether two rejections address the same target. */
116
+ export function sameRejection(left: HunkRejection, right: HunkRejection): boolean {
117
+ return (
118
+ left.path === right.path &&
119
+ left.hunkIndex === right.hunkIndex &&
120
+ left.changeIndex === right.changeIndex
121
+ );
122
+ }
123
+
124
+ /** Whether `rejection` drops the whole hunk that `target` sits in. */
125
+ export function rejectsWholeHunk(rejection: HunkRejection, target: HunkRejection): boolean {
126
+ return (
127
+ rejection.path === target.path &&
128
+ rejection.hunkIndex === target.hunkIndex &&
129
+ rejection.changeIndex === undefined
130
+ );
131
+ }
132
+
133
+ /** Whether a change row is dropped by the current decisions (for dimming). */
134
+ export function isRowRejected(
135
+ path: string,
136
+ model: FileDiffMetadata,
137
+ row: Pick<DiffRow, "kind" | "oldLine" | "newLine">,
138
+ rejections: HunkRejection[],
139
+ ): boolean {
140
+ const change = changeSideLine(row);
141
+
142
+ if (!change) return false;
143
+ const located = locateLine(model, change.side, change.lineNumber);
144
+
145
+ if (!located || located.changeIndex === undefined) return false;
146
+
147
+ return rejections.some(
148
+ (rejection) =>
149
+ rejection.path === path &&
150
+ rejection.hunkIndex === located.hunkIndex &&
151
+ (rejection.changeIndex === undefined || rejection.changeIndex === located.changeIndex),
152
+ );
153
+ }