@cueloop/client 0.1.0-alpha.5 → 0.1.0-alpha.50

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 (152) 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 +36 -31
  8. package/src/App.autoclose.test.tsx +137 -0
  9. package/src/App.diff.test.tsx +158 -22
  10. package/src/App.export.test.tsx +44 -37
  11. package/src/App.plan-review.test.tsx +672 -0
  12. package/src/App.prototype.test.tsx +155 -0
  13. package/src/App.readonly.test.tsx +60 -36
  14. package/src/App.rendering.test.tsx +132 -0
  15. package/src/App.review-panel.test.tsx +136 -0
  16. package/src/App.submit-confirm.test.tsx +218 -0
  17. package/src/App.test.tsx +128 -69
  18. package/src/App.toast.test.tsx +75 -0
  19. package/src/App.tsx +0 -0
  20. package/src/App.walk.test.tsx +289 -0
  21. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  22. package/src/attribution.test.ts +31 -0
  23. package/src/attribution.ts +38 -0
  24. package/src/clipboard.test.ts +23 -0
  25. package/src/clipboard.ts +30 -0
  26. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  27. package/src/components/AnnotationCard.stories.tsx +94 -0
  28. package/src/components/AnnotationCard.tsx +241 -0
  29. package/src/components/Breadcrumb.stories.tsx +19 -0
  30. package/src/components/Breadcrumb.tsx +44 -0
  31. package/src/components/CodeBlock.stories.tsx +42 -0
  32. package/src/components/CodeBlock.tsx +83 -0
  33. package/src/components/CompletionOverlay.stories.tsx +23 -0
  34. package/src/components/CompletionOverlay.tsx +59 -0
  35. package/src/components/ConfirmCard.stories.tsx +35 -0
  36. package/src/components/ConfirmCard.tsx +127 -0
  37. package/src/components/ConfirmDialog.stories.tsx +21 -0
  38. package/src/components/ConfirmDialog.tsx +64 -0
  39. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  40. package/src/components/DiffSheet.stories.tsx +78 -0
  41. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  42. package/src/components/DiffSheet.tsx +355 -0
  43. package/src/components/InboxList.stories.tsx +31 -0
  44. package/src/components/InboxList.tsx +62 -0
  45. package/src/components/KeybindsDialog.stories.tsx +31 -0
  46. package/src/components/KeybindsDialog.tsx +58 -0
  47. package/src/components/MarkerPopover.stories.tsx +58 -0
  48. package/src/components/MarkerPopover.tsx +108 -0
  49. package/src/components/MenuBar.stories.tsx +37 -0
  50. package/src/components/MenuBar.tsx +74 -0
  51. package/src/components/PlanSheet.stories.tsx +113 -0
  52. package/src/components/PlanSheet.tsx +456 -0
  53. package/src/components/PromptDialog.stories.tsx +36 -0
  54. package/src/components/PromptDialog.tsx +78 -0
  55. package/src/components/PrototypeSheet.stories.tsx +20 -0
  56. package/src/components/PrototypeSheet.tsx +358 -0
  57. package/src/components/ReviewPanel.stories.tsx +86 -0
  58. package/src/components/ReviewPanel.tsx +101 -0
  59. package/src/components/ReviewRail.stories.tsx +218 -0
  60. package/src/components/ReviewRail.tsx +317 -0
  61. package/src/components/SettingsDialog.stories.tsx +111 -0
  62. package/src/components/SettingsDialog.tsx +141 -0
  63. package/src/components/SettingsRows.stories.tsx +18 -0
  64. package/src/components/SettingsRows.tsx +108 -0
  65. package/src/components/Toast.stories.tsx +12 -0
  66. package/src/components/Toast.tsx +44 -0
  67. package/src/components/WalkWizard.stories.tsx +100 -0
  68. package/src/components/WalkWizard.tsx +152 -0
  69. package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
  70. package/src/components/agent-launcher.stories.tsx +26 -0
  71. package/src/components/agent-launcher.test.tsx +49 -0
  72. package/src/components/agent-launcher.tsx +147 -0
  73. package/src/components/diff-sheet-layout.test.ts +127 -0
  74. package/src/components/diff-sheet-layout.ts +167 -0
  75. package/src/components/primitives/Button.stories.tsx +32 -0
  76. package/src/components/primitives/Button.tsx +51 -0
  77. package/src/components/primitives/Card.stories.tsx +25 -0
  78. package/src/components/primitives/Card.tsx +59 -0
  79. package/src/components/primitives/Dialog.stories.tsx +24 -0
  80. package/src/components/primitives/Dialog.tsx +64 -0
  81. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  82. package/src/components/primitives/StatusBar.tsx +22 -0
  83. package/src/components/primitives/Tabs.stories.tsx +29 -0
  84. package/src/components/primitives/Tabs.tsx +98 -0
  85. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  86. package/src/components/primitives/Toolbar.tsx +14 -0
  87. package/src/components/primitives/frame.test.ts +12 -0
  88. package/src/components/primitives/frame.ts +14 -0
  89. package/src/components/quick-actions-editor.stories.tsx +54 -0
  90. package/src/components/quick-actions-editor.tsx +136 -0
  91. package/src/components/stories-app.tsx +123 -0
  92. package/src/components/stories.test.tsx +62 -0
  93. package/src/components/story-fixtures.ts +98 -0
  94. package/src/components/story.ts +69 -0
  95. package/src/components/syntax-highlight.ts +92 -0
  96. package/src/components/terminal-pane.test.tsx +40 -0
  97. package/src/components/terminal-pane.ts +181 -0
  98. package/src/components/theme-context.stories.tsx +46 -0
  99. package/src/components/theme-context.tsx +36 -0
  100. package/src/components/truncate-text.ts +6 -0
  101. package/src/config.test.ts +430 -31
  102. package/src/config.ts +304 -26
  103. package/src/diff-hunk-curate.test.ts +315 -0
  104. package/src/diff-hunk-curate.ts +233 -0
  105. package/src/diff-intraline.test.ts +164 -0
  106. package/src/diff-intraline.ts +132 -0
  107. package/src/diff-syntax.test.ts +96 -0
  108. package/src/diff-syntax.ts +154 -0
  109. package/src/editor.test.ts +143 -0
  110. package/src/editor.ts +123 -9
  111. package/src/ghostty-terminal.test.ts +82 -0
  112. package/src/ghostty-terminal.ts +168 -0
  113. package/src/herdr.test.ts +41 -0
  114. package/src/herdr.ts +27 -0
  115. package/src/index.ts +10 -0
  116. package/src/integrations.test.ts +41 -0
  117. package/src/integrations.ts +30 -0
  118. package/src/intent-dispatch.test.ts +493 -0
  119. package/src/intent-dispatch.ts +428 -0
  120. package/src/key-bindings.test.ts +152 -0
  121. package/src/key-bindings.ts +430 -0
  122. package/src/keymap.test.ts +622 -0
  123. package/src/keymap.ts +339 -0
  124. package/src/kitty-image.test.ts +20 -0
  125. package/src/kitty-image.ts +75 -0
  126. package/src/prototype-browser.test.ts +55 -0
  127. package/src/prototype-browser.ts +243 -0
  128. package/src/pty.test.ts +78 -0
  129. package/src/pty.ts +222 -0
  130. package/src/review-panel.test.ts +113 -0
  131. package/src/review-panel.ts +83 -0
  132. package/src/run.ts +9 -5
  133. package/src/serve.test.ts +24 -9
  134. package/src/serve.ts +7 -7
  135. package/src/session-controller.curate.test.ts +253 -0
  136. package/src/session-controller.share.test.ts +309 -0
  137. package/src/session-controller.ts +914 -0
  138. package/src/share.ts +113 -0
  139. package/src/test-support.test.ts +55 -0
  140. package/src/test-support.ts +187 -0
  141. package/src/theme-presets.ts +168 -0
  142. package/src/theme.ts +38 -17
  143. package/src/version.ts +5 -0
  144. package/src/view-diff.test.ts +19 -8
  145. package/src/view-diff.ts +34 -23
  146. package/src/view-plan.regression.test.ts +60 -0
  147. package/src/view-plan.test.ts +399 -0
  148. package/src/view-plan.ts +495 -0
  149. package/src/walk.test.ts +121 -0
  150. package/src/walk.ts +70 -0
  151. package/src/view.test.ts +0 -157
  152. package/src/view.ts +0 -0
@@ -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,233 @@
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 { parseDiffFromFile, diffAcceptRejectHunk, type FileDiffMetadata } from "@pierre/diffs";
17
+ import { unifiedDiffText, type DiffFileContents, type DiffFileStatus } from "@cueloop/schema";
18
+ import type { DiffRow } from "./view-diff";
19
+
20
+ /** One reject decision; `changeIndex` absent rejects the whole hunk. */
21
+ export interface HunkRejection {
22
+ path: string;
23
+ hunkIndex: number;
24
+ changeIndex?: number;
25
+ }
26
+
27
+ /** Which side of the diff a line number addresses. */
28
+ type DiffSide = "addition" | "deletion";
29
+
30
+ /** A line resolved onto its owning hunk, and its change block when it is one. */
31
+ export interface LocatedLine {
32
+ hunkIndex: number;
33
+ /** Present only when the line sits inside a change block (an add/del line). */
34
+ changeIndex?: number;
35
+ }
36
+
37
+ /**
38
+ * Context lines around each change. Matching git's default (3) makes the model
39
+ * split hunks like the rendered working-tree patch does; git's coalescing can
40
+ * still merge adjacent hunks differently, but the row is mapped by absolute line
41
+ * number (not by counting hunks), so a whole-hunk reject can only ever span the
42
+ * model hunk it lands in - never the wrong file or a crash.
43
+ */
44
+ const DIFF_CONTEXT = 3;
45
+
46
+ /** Parse one file's full contents into a non-partial diff model. */
47
+ export function parseFileDiff(file: DiffFileContents): FileDiffMetadata {
48
+ return parseDiffFromFile(
49
+ { name: file.path, contents: file.oldContents },
50
+ { name: file.path, contents: file.newContents },
51
+ { context: DIFF_CONTEXT },
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Resolve a 1-based file line on one side to its hunk and, when the line is
57
+ * part of a change block, that block's `hunkContent` index.
58
+ */
59
+ export function locateLine(
60
+ model: FileDiffMetadata,
61
+ side: DiffSide,
62
+ lineNumber: number,
63
+ ): LocatedLine | null {
64
+ const index = lineNumber - 1;
65
+ for (const [hunkIndex, hunk] of model.hunks.entries()) {
66
+ for (const [contentIndex, content] of hunk.hunkContent.entries()) {
67
+ const base = side === "addition" ? content.additionLineIndex : content.deletionLineIndex;
68
+ const count =
69
+ content.type === "context"
70
+ ? content.lines
71
+ : side === "addition"
72
+ ? content.additions
73
+ : content.deletions;
74
+ if (count > 0 && index >= base && index < base + count) {
75
+ return { hunkIndex, changeIndex: content.type === "change" ? contentIndex : undefined };
76
+ }
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /** The side and line number a change row addresses; null for headers/context. */
83
+ function changeSideLine(
84
+ row: Pick<DiffRow, "kind" | "oldLine" | "newLine">,
85
+ ): { side: DiffSide; lineNumber: number } | null {
86
+ if (row.kind === "add" && row.newLine !== undefined)
87
+ return { side: "addition", lineNumber: row.newLine };
88
+ if (row.kind === "del" && row.oldLine !== undefined)
89
+ return { side: "deletion", lineNumber: row.oldLine };
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * The whole-hunk rejection for the row under the cursor, or null when the row
95
+ * is not inside a hunk. Context rows resolve through their new-file line.
96
+ */
97
+ export function hunkRejectionForRow(
98
+ path: string,
99
+ model: FileDiffMetadata,
100
+ row: Pick<DiffRow, "kind" | "oldLine" | "newLine">,
101
+ ): HunkRejection | null {
102
+ const change = changeSideLine(row);
103
+ const located = change
104
+ ? locateLine(model, change.side, change.lineNumber)
105
+ : row.newLine !== undefined
106
+ ? locateLine(model, "addition", row.newLine)
107
+ : row.oldLine !== undefined
108
+ ? locateLine(model, "deletion", row.oldLine)
109
+ : null;
110
+ return located ? { path, hunkIndex: located.hunkIndex } : null;
111
+ }
112
+
113
+ /**
114
+ * The change-level rejection for the row under the cursor, or null when the row
115
+ * carries no change (a header or context line).
116
+ */
117
+ export function changeRejectionForRow(
118
+ path: string,
119
+ model: FileDiffMetadata,
120
+ row: Pick<DiffRow, "kind" | "oldLine" | "newLine">,
121
+ ): HunkRejection | null {
122
+ const change = changeSideLine(row);
123
+ if (!change) return null;
124
+ const located = locateLine(model, change.side, change.lineNumber);
125
+ if (!located || located.changeIndex === undefined) return null;
126
+ return { path, hunkIndex: located.hunkIndex, changeIndex: located.changeIndex };
127
+ }
128
+
129
+ /** Whether two rejections address the same target. */
130
+ export function sameRejection(left: HunkRejection, right: HunkRejection): boolean {
131
+ return (
132
+ left.path === right.path &&
133
+ left.hunkIndex === right.hunkIndex &&
134
+ left.changeIndex === right.changeIndex
135
+ );
136
+ }
137
+
138
+ /** Whether `rejection` drops the whole hunk that `target` sits in. */
139
+ export function rejectsWholeHunk(rejection: HunkRejection, target: HunkRejection): boolean {
140
+ return (
141
+ rejection.path === target.path &&
142
+ rejection.hunkIndex === target.hunkIndex &&
143
+ rejection.changeIndex === undefined
144
+ );
145
+ }
146
+
147
+ /** Whether a change row is dropped by the current decisions (for dimming). */
148
+ export function isRowRejected(
149
+ path: string,
150
+ model: FileDiffMetadata,
151
+ row: Pick<DiffRow, "kind" | "oldLine" | "newLine">,
152
+ rejections: HunkRejection[],
153
+ ): boolean {
154
+ const change = changeSideLine(row);
155
+ if (!change) return false;
156
+ const located = locateLine(model, change.side, change.lineNumber);
157
+ if (!located || located.changeIndex === undefined) return false;
158
+ return rejections.some(
159
+ (rejection) =>
160
+ rejection.path === path &&
161
+ rejection.hunkIndex === located.hunkIndex &&
162
+ (rejection.changeIndex === undefined || rejection.changeIndex === located.changeIndex),
163
+ );
164
+ }
165
+
166
+ /**
167
+ * Apply one file's rejections and return its curated patch, or null when the
168
+ * curated content equals the old content (every change rejected - no patch).
169
+ * Whole-hunk rejections supersede change-level ones for the same hunk. Because
170
+ * `diffAcceptRejectHunk` returns a model with the same hunk/content shape, the
171
+ * indices stay valid across calls; they are applied high-to-low regardless, so
172
+ * the order is deterministic.
173
+ */
174
+ function curateFilePatch(file: DiffFileContents, rejections: HunkRejection[]): string | null {
175
+ let model = parseFileDiff(file);
176
+ const wholeHunks = new Set(
177
+ rejections
178
+ .filter((rejection) => rejection.changeIndex === undefined)
179
+ .map((rejection) => rejection.hunkIndex),
180
+ );
181
+ for (const hunkIndex of [...wholeHunks].sort((a, b) => b - a)) {
182
+ model = diffAcceptRejectHunk(model, hunkIndex, "reject");
183
+ }
184
+ const changeRejections = rejections
185
+ .filter(
186
+ (rejection) => rejection.changeIndex !== undefined && !wholeHunks.has(rejection.hunkIndex),
187
+ )
188
+ .sort((a, b) => b.hunkIndex - a.hunkIndex || b.changeIndex! - a.changeIndex!);
189
+ for (const rejection of changeRejections) {
190
+ model = diffAcceptRejectHunk(model, rejection.hunkIndex, {
191
+ type: "reject",
192
+ changeIndex: rejection.changeIndex!,
193
+ });
194
+ }
195
+ const curatedNew = model.additionLines.join("");
196
+ if (curatedNew === file.oldContents) return null;
197
+ const patch = unifiedDiffText(file.oldContents, curatedNew, file.path);
198
+ if (patch === null) return null;
199
+ return withFileStateHeaders(patch, file.path, file.status, curatedNew);
200
+ }
201
+
202
+ /**
203
+ * Point the diff headers at /dev/null for a created or fully-deleted file so the
204
+ * curated patch applies. git's status - not empty contents - decides existence,
205
+ * so editing an existing empty file stays a modify; a deletion whose removals
206
+ * were partly restored also falls back to a modify.
207
+ */
208
+ function withFileStateHeaders(
209
+ patch: string,
210
+ path: string,
211
+ status: DiffFileStatus,
212
+ curatedNew: string,
213
+ ): string {
214
+ if (status === "added") return patch.replace(`--- a/${path}`, "--- /dev/null");
215
+ if (status === "deleted" && curatedNew === "") {
216
+ return patch.replace(`+++ b/${path}`, "+++ /dev/null");
217
+ }
218
+ return patch;
219
+ }
220
+
221
+ /**
222
+ * The curated unified-diff patch of the accepted changes across every file.
223
+ * Files with no net change (unchanged, or with all changes rejected) drop out.
224
+ */
225
+ export function curateDiff(files: DiffFileContents[], rejections: HunkRejection[]): string {
226
+ const parts: string[] = [];
227
+ for (const file of files) {
228
+ const fileRejections = rejections.filter((rejection) => rejection.path === file.path);
229
+ const patch = curateFilePatch(file, fileRejections);
230
+ if (patch) parts.push(patch);
231
+ }
232
+ return parts.join("\n");
233
+ }