@cueloop/client 0.1.0-alpha.4 → 0.1.0-alpha.40
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.
- package/package.json +32 -24
- package/src/App.autoclose.test.tsx +137 -0
- package/src/App.diff.test.tsx +158 -22
- package/src/App.export.test.tsx +44 -37
- package/src/App.plan-review.test.tsx +632 -0
- package/src/App.readonly.test.tsx +60 -36
- package/src/App.rendering.test.tsx +132 -0
- package/src/App.review-panel.test.tsx +136 -0
- package/src/App.submit-confirm.test.tsx +220 -0
- package/src/App.test.tsx +128 -69
- package/src/App.toast.test.tsx +75 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +288 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +38 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +30 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +228 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +44 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +83 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +59 -0
- package/src/components/ConfirmCard.stories.tsx +56 -0
- package/src/components/ConfirmCard.tsx +135 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +355 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +62 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +58 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +74 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +389 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +78 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +101 -0
- package/src/components/ReviewRail.stories.tsx +220 -0
- package/src/components/ReviewRail.tsx +316 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +141 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +108 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +44 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +21 -0
- package/src/components/agent-launcher.tsx +123 -0
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +48 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +64 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +98 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +136 -0
- package/src/components/stories-app.tsx +123 -0
- package/src/components/stories.test.tsx +62 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +69 -0
- package/src/components/syntax-highlight.ts +92 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +36 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +430 -31
- package/src/config.ts +304 -26
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +143 -0
- package/src/editor.ts +123 -9
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +27 -0
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +30 -0
- package/src/intent-dispatch.test.ts +492 -0
- package/src/intent-dispatch.ts +428 -0
- package/src/key-bindings.test.ts +152 -0
- package/src/key-bindings.ts +424 -0
- package/src/keymap.test.ts +622 -0
- package/src/keymap.ts +339 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +24 -9
- package/src/serve.ts +7 -7
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +309 -0
- package/src/session-controller.ts +900 -0
- package/src/share.ts +113 -0
- package/src/test-support.test.ts +55 -0
- package/src/test-support.ts +187 -0
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +36 -15
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +34 -23
- package/src/view-plan.test.ts +294 -0
- package/src/view-plan.ts +408 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +70 -0
- package/src/view.test.ts +0 -157
- 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
|
+
}
|