diffity 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/.claude/settings.local.json +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +71 -0
  4. package/development.md +156 -0
  5. package/package.json +32 -0
  6. package/packages/cli/build.js +38 -0
  7. package/packages/cli/package.json +51 -0
  8. package/packages/cli/src/agent.ts +187 -0
  9. package/packages/cli/src/db.ts +58 -0
  10. package/packages/cli/src/index.ts +196 -0
  11. package/packages/cli/src/review-routes.ts +150 -0
  12. package/packages/cli/src/server.ts +370 -0
  13. package/packages/cli/src/session.ts +48 -0
  14. package/packages/cli/src/threads.ts +238 -0
  15. package/packages/cli/tsconfig.json +13 -0
  16. package/packages/git/package.json +24 -0
  17. package/packages/git/src/commits.ts +28 -0
  18. package/packages/git/src/diff.ts +97 -0
  19. package/packages/git/src/exec.ts +35 -0
  20. package/packages/git/src/index.ts +5 -0
  21. package/packages/git/src/repo.ts +63 -0
  22. package/packages/git/src/status.ts +9 -0
  23. package/packages/git/src/types.ts +12 -0
  24. package/packages/git/tsconfig.json +9 -0
  25. package/packages/parser/package.json +26 -0
  26. package/packages/parser/src/index.ts +12 -0
  27. package/packages/parser/src/parse.ts +299 -0
  28. package/packages/parser/src/types.ts +52 -0
  29. package/packages/parser/src/word-diff.ts +155 -0
  30. package/packages/parser/tests/fixtures/binary-deleted.diff +4 -0
  31. package/packages/parser/tests/fixtures/binary-file.diff +4 -0
  32. package/packages/parser/tests/fixtures/binary-modified.diff +3 -0
  33. package/packages/parser/tests/fixtures/copied-file.diff +12 -0
  34. package/packages/parser/tests/fixtures/deleted-file.diff +9 -0
  35. package/packages/parser/tests/fixtures/empty.diff +0 -0
  36. package/packages/parser/tests/fixtures/hunk-with-context.diff +12 -0
  37. package/packages/parser/tests/fixtures/mode-change-with-content.diff +10 -0
  38. package/packages/parser/tests/fixtures/mode-change.diff +3 -0
  39. package/packages/parser/tests/fixtures/multi-file.diff +22 -0
  40. package/packages/parser/tests/fixtures/new-file.diff +9 -0
  41. package/packages/parser/tests/fixtures/no-newline.diff +10 -0
  42. package/packages/parser/tests/fixtures/renamed-file.diff +12 -0
  43. package/packages/parser/tests/fixtures/single-file-additions.diff +11 -0
  44. package/packages/parser/tests/fixtures/single-file-deletions.diff +11 -0
  45. package/packages/parser/tests/fixtures/single-file-mixed.diff +15 -0
  46. package/packages/parser/tests/fixtures/single-file-multi-hunk.diff +22 -0
  47. package/packages/parser/tests/fixtures/spaces-in-path.diff +9 -0
  48. package/packages/parser/tests/fixtures/submodule.diff +7 -0
  49. package/packages/parser/tests/fixtures/unicode-content.diff +11 -0
  50. package/packages/parser/tests/parse.test.ts +312 -0
  51. package/packages/parser/tests/word-diff-integration.test.ts +52 -0
  52. package/packages/parser/tests/word-diff.test.ts +121 -0
  53. package/packages/parser/tsconfig.json +10 -0
  54. package/packages/skills/diffity-resolve/SKILL.md +55 -0
  55. package/packages/skills/diffity-review/SKILL.md +74 -0
  56. package/packages/skills/diffity-start/SKILL.md +25 -0
  57. package/packages/ui/index.html +13 -0
  58. package/packages/ui/package.json +35 -0
  59. package/packages/ui/public/brand.svg +12 -0
  60. package/packages/ui/public/favicon.svg +15 -0
  61. package/packages/ui/src/app.tsx +14 -0
  62. package/packages/ui/src/components/comment-bubble.tsx +78 -0
  63. package/packages/ui/src/components/comment-form-row.tsx +58 -0
  64. package/packages/ui/src/components/comment-form.tsx +78 -0
  65. package/packages/ui/src/components/comment-line-number.tsx +60 -0
  66. package/packages/ui/src/components/comment-thread.tsx +209 -0
  67. package/packages/ui/src/components/commit-list.tsx +100 -0
  68. package/packages/ui/src/components/dashboard.tsx +84 -0
  69. package/packages/ui/src/components/diff-line.tsx +90 -0
  70. package/packages/ui/src/components/diff-page.tsx +332 -0
  71. package/packages/ui/src/components/diff-stats.tsx +20 -0
  72. package/packages/ui/src/components/diff-view.tsx +278 -0
  73. package/packages/ui/src/components/expand-row.tsx +45 -0
  74. package/packages/ui/src/components/file-block.tsx +536 -0
  75. package/packages/ui/src/components/file-tree-item.tsx +84 -0
  76. package/packages/ui/src/components/file-tree.tsx +72 -0
  77. package/packages/ui/src/components/general-comments.tsx +174 -0
  78. package/packages/ui/src/components/hunk-block-split.tsx +357 -0
  79. package/packages/ui/src/components/hunk-block.tsx +161 -0
  80. package/packages/ui/src/components/hunk-header.tsx +144 -0
  81. package/packages/ui/src/components/hunk-with-gap.tsx +113 -0
  82. package/packages/ui/src/components/icons/arrow-down-icon.tsx +7 -0
  83. package/packages/ui/src/components/icons/arrow-up-icon.tsx +7 -0
  84. package/packages/ui/src/components/icons/check-circle-icon.tsx +8 -0
  85. package/packages/ui/src/components/icons/check-icon.tsx +9 -0
  86. package/packages/ui/src/components/icons/chevron-down-icon.tsx +11 -0
  87. package/packages/ui/src/components/icons/chevron-icon.tsx +20 -0
  88. package/packages/ui/src/components/icons/chevron-up-down-icon.tsx +7 -0
  89. package/packages/ui/src/components/icons/chevron-up-icon.tsx +11 -0
  90. package/packages/ui/src/components/icons/comment-icon.tsx +9 -0
  91. package/packages/ui/src/components/icons/copy-icon.tsx +10 -0
  92. package/packages/ui/src/components/icons/eye-icon.tsx +10 -0
  93. package/packages/ui/src/components/icons/eye-off-icon.tsx +12 -0
  94. package/packages/ui/src/components/icons/file-icon.tsx +7 -0
  95. package/packages/ui/src/components/icons/folder-icon.tsx +19 -0
  96. package/packages/ui/src/components/icons/git-branch-icon.tsx +13 -0
  97. package/packages/ui/src/components/icons/keyboard-icon.tsx +13 -0
  98. package/packages/ui/src/components/icons/moon-icon.tsx +9 -0
  99. package/packages/ui/src/components/icons/plus-icon.tsx +9 -0
  100. package/packages/ui/src/components/icons/search-icon.tsx +10 -0
  101. package/packages/ui/src/components/icons/sidebar-icon.tsx +10 -0
  102. package/packages/ui/src/components/icons/spinner.tsx +7 -0
  103. package/packages/ui/src/components/icons/split-view-icon.tsx +10 -0
  104. package/packages/ui/src/components/icons/sun-icon.tsx +17 -0
  105. package/packages/ui/src/components/icons/trash-icon.tsx +11 -0
  106. package/packages/ui/src/components/icons/undo-icon.tsx +9 -0
  107. package/packages/ui/src/components/icons/unified-view-icon.tsx +12 -0
  108. package/packages/ui/src/components/icons/x-icon.tsx +10 -0
  109. package/packages/ui/src/components/line-number-cell.tsx +18 -0
  110. package/packages/ui/src/components/markdown-content.tsx +139 -0
  111. package/packages/ui/src/components/orphaned-threads.tsx +80 -0
  112. package/packages/ui/src/components/overview-file-list.tsx +57 -0
  113. package/packages/ui/src/components/render-expansion-rows.tsx +47 -0
  114. package/packages/ui/src/components/shortcut-modal.tsx +93 -0
  115. package/packages/ui/src/components/sidebar.tsx +80 -0
  116. package/packages/ui/src/components/skeleton.tsx +9 -0
  117. package/packages/ui/src/components/stale-diff-banner.tsx +21 -0
  118. package/packages/ui/src/components/summary-bar.tsx +39 -0
  119. package/packages/ui/src/components/toolbar.tsx +246 -0
  120. package/packages/ui/src/components/ui/badge.tsx +17 -0
  121. package/packages/ui/src/components/ui/confirm-dialog.tsx +52 -0
  122. package/packages/ui/src/components/ui/icon-button.tsx +23 -0
  123. package/packages/ui/src/components/ui/status-badge.tsx +57 -0
  124. package/packages/ui/src/components/ui/thread-badge.tsx +35 -0
  125. package/packages/ui/src/components/word-diff.tsx +126 -0
  126. package/packages/ui/src/hooks/use-comment-actions.ts +97 -0
  127. package/packages/ui/src/hooks/use-commits.ts +12 -0
  128. package/packages/ui/src/hooks/use-copy.ts +18 -0
  129. package/packages/ui/src/hooks/use-diff-staleness.ts +58 -0
  130. package/packages/ui/src/hooks/use-diff.ts +12 -0
  131. package/packages/ui/src/hooks/use-highlighter.ts +190 -0
  132. package/packages/ui/src/hooks/use-info.ts +12 -0
  133. package/packages/ui/src/hooks/use-keyboard.ts +55 -0
  134. package/packages/ui/src/hooks/use-line-selection.ts +157 -0
  135. package/packages/ui/src/hooks/use-overview.ts +12 -0
  136. package/packages/ui/src/hooks/use-review-threads.ts +12 -0
  137. package/packages/ui/src/hooks/use-search-params.ts +26 -0
  138. package/packages/ui/src/hooks/use-theme.ts +34 -0
  139. package/packages/ui/src/hooks/use-thread-navigation.ts +43 -0
  140. package/packages/ui/src/lib/api.ts +232 -0
  141. package/packages/ui/src/lib/cn.ts +6 -0
  142. package/packages/ui/src/lib/context-expansion.ts +122 -0
  143. package/packages/ui/src/lib/diff-utils.ts +268 -0
  144. package/packages/ui/src/lib/dom-utils.ts +13 -0
  145. package/packages/ui/src/lib/file-tree.ts +122 -0
  146. package/packages/ui/src/lib/query-client.ts +10 -0
  147. package/packages/ui/src/lib/render-content.tsx +23 -0
  148. package/packages/ui/src/lib/syntax-token.ts +4 -0
  149. package/packages/ui/src/main.tsx +14 -0
  150. package/packages/ui/src/queries/commits.ts +9 -0
  151. package/packages/ui/src/queries/diff.ts +9 -0
  152. package/packages/ui/src/queries/file.ts +10 -0
  153. package/packages/ui/src/queries/info.ts +9 -0
  154. package/packages/ui/src/queries/overview.ts +9 -0
  155. package/packages/ui/src/styles/app.css +178 -0
  156. package/packages/ui/src/types/comment.ts +61 -0
  157. package/packages/ui/src/vite-env.d.ts +1 -0
  158. package/packages/ui/tests/context-expansion.test.ts +279 -0
  159. package/packages/ui/tests/diff-utils.test.ts +409 -0
  160. package/packages/ui/tsconfig.json +14 -0
  161. package/packages/ui/vite.config.ts +23 -0
  162. package/scripts/build-skills.ts +26 -0
  163. package/scripts/build.ts +15 -0
  164. package/scripts/dev.ts +32 -0
  165. package/scripts/lib/transformers/claude-code.ts +11 -0
  166. package/scripts/lib/transformers/codex.ts +17 -0
  167. package/scripts/lib/transformers/cursor.ts +17 -0
  168. package/scripts/lib/transformers/index.ts +3 -0
  169. package/scripts/lib/utils.ts +70 -0
  170. package/scripts/link-dev.ts +54 -0
  171. package/skills/diffity-resolve/SKILL.md +55 -0
  172. package/skills/diffity-review/SKILL.md +74 -0
  173. package/skills/diffity-start/SKILL.md +27 -0
  174. package/tsconfig.json +22 -0
@@ -0,0 +1,279 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { DiffHunk } from '@diffity/parser';
3
+ import {
4
+ computeGaps,
5
+ createContextLines,
6
+ getExpandRange,
7
+ type ExpandableGap,
8
+ } from '../src/lib/context-expansion';
9
+
10
+ function makeHunk(oldStart: number, oldCount: number, newStart: number, newCount: number): DiffHunk {
11
+ return {
12
+ oldStart,
13
+ oldCount,
14
+ newStart,
15
+ newCount,
16
+ lines: [],
17
+ };
18
+ }
19
+
20
+ describe('computeGaps', () => {
21
+ it('returns empty for no hunks', () => {
22
+ expect(computeGaps([], null)).toEqual([]);
23
+ });
24
+
25
+ it('creates a top gap when first hunk does not start at line 1', () => {
26
+ const hunks = [makeHunk(10, 5, 10, 5)];
27
+ const gaps = computeGaps(hunks, null);
28
+
29
+ expect(gaps).toHaveLength(1);
30
+ expect(gaps[0]).toMatchObject({
31
+ id: 'top',
32
+ position: 'top',
33
+ oldStart: 1,
34
+ oldEnd: 9,
35
+ totalLines: 9,
36
+ });
37
+ });
38
+
39
+ it('does not create a top gap when first hunk starts at line 1', () => {
40
+ const hunks = [makeHunk(1, 5, 1, 5)];
41
+ const gaps = computeGaps(hunks, null);
42
+
43
+ expect(gaps).toHaveLength(0);
44
+ });
45
+
46
+ it('creates a between gap for non-adjacent hunks', () => {
47
+ const hunks = [makeHunk(1, 10, 1, 10), makeHunk(50, 5, 50, 5)];
48
+ const gaps = computeGaps(hunks, null);
49
+
50
+ expect(gaps).toHaveLength(1);
51
+ expect(gaps[0]).toMatchObject({
52
+ id: 'between-0',
53
+ position: 'between',
54
+ oldStart: 11,
55
+ oldEnd: 49,
56
+ totalLines: 39,
57
+ });
58
+ });
59
+
60
+ it('does not create a between gap for adjacent hunks', () => {
61
+ const hunks = [makeHunk(1, 10, 1, 10), makeHunk(11, 5, 11, 5)];
62
+ const gaps = computeGaps(hunks, null);
63
+
64
+ expect(gaps).toHaveLength(0);
65
+ });
66
+
67
+ it('creates a bottom gap when file has lines after last hunk', () => {
68
+ const hunks = [makeHunk(1, 10, 1, 10)];
69
+ const gaps = computeGaps(hunks, 50);
70
+
71
+ expect(gaps).toHaveLength(1);
72
+ expect(gaps[0]).toMatchObject({
73
+ id: 'bottom',
74
+ position: 'bottom',
75
+ oldStart: 11,
76
+ oldEnd: 50,
77
+ totalLines: 40,
78
+ });
79
+ });
80
+
81
+ it('does not create a bottom gap when fileLineCount is null', () => {
82
+ const hunks = [makeHunk(1, 10, 1, 10)];
83
+ const gaps = computeGaps(hunks, null);
84
+
85
+ expect(gaps).toHaveLength(0);
86
+ });
87
+
88
+ it('creates top, between, and bottom gaps together', () => {
89
+ const hunks = [makeHunk(20, 5, 20, 5), makeHunk(50, 5, 50, 5)];
90
+ const gaps = computeGaps(hunks, 100);
91
+
92
+ expect(gaps).toHaveLength(3);
93
+ expect(gaps[0].position).toBe('top');
94
+ expect(gaps[1].position).toBe('between');
95
+ expect(gaps[2].position).toBe('bottom');
96
+ });
97
+
98
+ it('creates multiple between gaps for multiple non-adjacent hunks', () => {
99
+ const hunks = [
100
+ makeHunk(1, 5, 1, 5),
101
+ makeHunk(20, 5, 20, 5),
102
+ makeHunk(50, 5, 50, 5),
103
+ ];
104
+ const gaps = computeGaps(hunks, null);
105
+
106
+ expect(gaps).toHaveLength(2);
107
+ expect(gaps[0].id).toBe('between-0');
108
+ expect(gaps[0].oldStart).toBe(6);
109
+ expect(gaps[0].oldEnd).toBe(19);
110
+ expect(gaps[1].id).toBe('between-1');
111
+ expect(gaps[1].oldStart).toBe(25);
112
+ expect(gaps[1].oldEnd).toBe(49);
113
+ });
114
+ });
115
+
116
+ describe('createContextLines', () => {
117
+ const fileLines = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`);
118
+
119
+ it('creates context lines for a given range', () => {
120
+ const lines = createContextLines(fileLines, 5, 8, 0);
121
+
122
+ expect(lines).toHaveLength(4);
123
+ expect(lines[0]).toMatchObject({ type: 'context', content: 'line 5', oldLineNumber: 5, newLineNumber: 5 });
124
+ expect(lines[3]).toMatchObject({ type: 'context', content: 'line 8', oldLineNumber: 8, newLineNumber: 8 });
125
+ });
126
+
127
+ it('applies new line number offset', () => {
128
+ const lines = createContextLines(fileLines, 5, 7, 3);
129
+
130
+ expect(lines[0]).toMatchObject({ oldLineNumber: 5, newLineNumber: 8 });
131
+ expect(lines[2]).toMatchObject({ oldLineNumber: 7, newLineNumber: 10 });
132
+ });
133
+
134
+ it('handles single line range', () => {
135
+ const lines = createContextLines(fileLines, 10, 10, 0);
136
+
137
+ expect(lines).toHaveLength(1);
138
+ expect(lines[0].content).toBe('line 10');
139
+ });
140
+
141
+ it('returns empty string for out-of-bounds lines', () => {
142
+ const lines = createContextLines(['a', 'b'], 3, 3, 0);
143
+
144
+ expect(lines).toHaveLength(1);
145
+ expect(lines[0].content).toBe('');
146
+ });
147
+ });
148
+
149
+ describe('getExpandRange', () => {
150
+ const gap: ExpandableGap = {
151
+ id: 'between-0',
152
+ position: 'between',
153
+ oldStart: 20,
154
+ oldEnd: 100,
155
+ newStart: 20,
156
+ newEnd: 100,
157
+ totalLines: 81,
158
+ };
159
+
160
+ const noExpansion = { fromTop: 0, fromBottom: 0 };
161
+
162
+ describe('direction: all', () => {
163
+ it('returns the full remaining range', () => {
164
+ const range = getExpandRange(gap, 'all', noExpansion);
165
+
166
+ expect(range).toEqual({ oldStart: 20, oldEnd: 100 });
167
+ });
168
+
169
+ it('returns the remaining range after partial expansion', () => {
170
+ const range = getExpandRange(gap, 'all', { fromTop: 10, fromBottom: 5 });
171
+
172
+ expect(range).toEqual({ oldStart: 30, oldEnd: 95 });
173
+ });
174
+ });
175
+
176
+ describe('direction: down (expand from top of gap)', () => {
177
+ it('returns a chunk from the start of the gap', () => {
178
+ const range = getExpandRange(gap, 'down', noExpansion);
179
+
180
+ expect(range).toEqual({ oldStart: 20, oldEnd: 39 });
181
+ });
182
+
183
+ it('continues from where previous expansion left off', () => {
184
+ const range = getExpandRange(gap, 'down', { fromTop: 20, fromBottom: 0 });
185
+
186
+ expect(range).toEqual({ oldStart: 40, oldEnd: 59 });
187
+ });
188
+
189
+ it('clamps to remaining end', () => {
190
+ const range = getExpandRange(gap, 'down', { fromTop: 70, fromBottom: 0 });
191
+
192
+ expect(range).toEqual({ oldStart: 90, oldEnd: 100 });
193
+ });
194
+ });
195
+
196
+ describe('direction: up (expand from bottom of gap)', () => {
197
+ it('returns a chunk from the end of the gap', () => {
198
+ const range = getExpandRange(gap, 'up', noExpansion);
199
+
200
+ expect(range).toEqual({ oldStart: 81, oldEnd: 100 });
201
+ });
202
+
203
+ it('continues from where previous expansion left off', () => {
204
+ const range = getExpandRange(gap, 'up', { fromTop: 0, fromBottom: 20 });
205
+
206
+ expect(range).toEqual({ oldStart: 61, oldEnd: 80 });
207
+ });
208
+
209
+ it('clamps to remaining start', () => {
210
+ const range = getExpandRange(gap, 'up', { fromTop: 0, fromBottom: 70 });
211
+
212
+ expect(range).toEqual({ oldStart: 20, oldEnd: 30 });
213
+ });
214
+ });
215
+
216
+ describe('edge cases', () => {
217
+ it('returns null when fully expanded', () => {
218
+ const range = getExpandRange(gap, 'down', { fromTop: 81, fromBottom: 0 });
219
+
220
+ expect(range).toBeNull();
221
+ });
222
+
223
+ it('returns null when top and bottom overlap', () => {
224
+ const range = getExpandRange(gap, 'up', { fromTop: 50, fromBottom: 40 });
225
+
226
+ expect(range).toBeNull();
227
+ });
228
+
229
+ it('expands all when remaining fits in one chunk', () => {
230
+ const smallGap: ExpandableGap = { ...gap, oldStart: 20, oldEnd: 30, totalLines: 11 };
231
+
232
+ const rangeDown = getExpandRange(smallGap, 'down', noExpansion);
233
+ expect(rangeDown).toEqual({ oldStart: 20, oldEnd: 30 });
234
+
235
+ const rangeUp = getExpandRange(smallGap, 'up', noExpansion);
236
+ expect(rangeUp).toEqual({ oldStart: 20, oldEnd: 30 });
237
+ });
238
+
239
+ it('handles top gap expanding upward', () => {
240
+ const topGap: ExpandableGap = {
241
+ id: 'top',
242
+ position: 'top',
243
+ oldStart: 1,
244
+ oldEnd: 50,
245
+ newStart: 1,
246
+ newEnd: 50,
247
+ totalLines: 50,
248
+ };
249
+
250
+ const range = getExpandRange(topGap, 'up', noExpansion);
251
+
252
+ expect(range).toEqual({ oldStart: 31, oldEnd: 50 });
253
+ });
254
+
255
+ it('handles bottom gap expanding downward', () => {
256
+ const bottomGap: ExpandableGap = {
257
+ id: 'bottom',
258
+ position: 'bottom',
259
+ oldStart: 80,
260
+ oldEnd: 120,
261
+ newStart: 80,
262
+ newEnd: 120,
263
+ totalLines: 41,
264
+ };
265
+
266
+ const range = getExpandRange(bottomGap, 'down', noExpansion);
267
+
268
+ expect(range).toEqual({ oldStart: 80, oldEnd: 99 });
269
+ });
270
+
271
+ it('handles simultaneous expansion from both directions', () => {
272
+ const rangeDown = getExpandRange(gap, 'down', { fromTop: 20, fromBottom: 20 });
273
+ expect(rangeDown).toEqual({ oldStart: 40, oldEnd: 59 });
274
+
275
+ const rangeUp = getExpandRange(gap, 'up', { fromTop: 20, fromBottom: 20 });
276
+ expect(rangeUp).toEqual({ oldStart: 61, oldEnd: 80 });
277
+ });
278
+ });
279
+ });
@@ -0,0 +1,409 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { DiffFile, DiffHunk, DiffLine } from '@diffity/parser';
3
+ import { getAutoCollapsedPaths, getChangeGroups, buildChangeGroupPatch } from '../src/lib/diff-utils';
4
+
5
+ function makeFile(path: string, status: DiffFile['status'] = 'modified'): DiffFile {
6
+ return {
7
+ oldPath: status === 'added' ? '' : path,
8
+ newPath: status === 'deleted' ? '' : path,
9
+ status,
10
+ additions: 1,
11
+ deletions: 0,
12
+ hunks: [],
13
+ };
14
+ }
15
+
16
+ describe('getAutoCollapsedPaths', () => {
17
+ it('collapses deleted files', () => {
18
+ const files = [makeFile('src/old.ts', 'deleted'), makeFile('src/new.ts')];
19
+ const collapsed = getAutoCollapsedPaths(files);
20
+
21
+ expect(collapsed.has('src/old.ts')).toBe(true);
22
+ expect(collapsed.has('src/new.ts')).toBe(false);
23
+ });
24
+
25
+ describe('lock files', () => {
26
+ const lockFiles = [
27
+ 'package-lock.json',
28
+ 'pnpm-lock.yaml',
29
+ 'pnpm-lock.yml',
30
+ 'bun.lock',
31
+ 'bun.lockb',
32
+ 'yarn.lock',
33
+ 'Cargo.lock',
34
+ 'Gemfile.lock',
35
+ 'composer.lock',
36
+ 'poetry.lock',
37
+ 'Pipfile.lock',
38
+ 'go.sum',
39
+ 'pubspec.lock',
40
+ 'Podfile.lock',
41
+ ];
42
+
43
+ for (const name of lockFiles) {
44
+ it(`collapses ${name}`, () => {
45
+ const files = [makeFile(name)];
46
+ const collapsed = getAutoCollapsedPaths(files);
47
+
48
+ expect(collapsed.has(name)).toBe(true);
49
+ });
50
+ }
51
+
52
+ it('collapses lock files in subdirectories', () => {
53
+ const files = [makeFile('packages/app/package-lock.json')];
54
+ const collapsed = getAutoCollapsedPaths(files);
55
+
56
+ expect(collapsed.has('packages/app/package-lock.json')).toBe(true);
57
+ });
58
+ });
59
+
60
+ describe('minified files', () => {
61
+ it('collapses .min.js files', () => {
62
+ const files = [makeFile('dist/app.min.js')];
63
+ const collapsed = getAutoCollapsedPaths(files);
64
+
65
+ expect(collapsed.has('dist/app.min.js')).toBe(true);
66
+ });
67
+
68
+ it('collapses .min.css files', () => {
69
+ const files = [makeFile('styles/main.min.css')];
70
+ const collapsed = getAutoCollapsedPaths(files);
71
+
72
+ expect(collapsed.has('styles/main.min.css')).toBe(true);
73
+ });
74
+
75
+ it('does not collapse regular .js files', () => {
76
+ const files = [makeFile('src/app.js')];
77
+ const collapsed = getAutoCollapsedPaths(files);
78
+
79
+ expect(collapsed.size).toBe(0);
80
+ });
81
+ });
82
+
83
+ describe('generated files', () => {
84
+ it('collapses .d.ts files', () => {
85
+ const files = [makeFile('types/index.d.ts')];
86
+ const collapsed = getAutoCollapsedPaths(files);
87
+
88
+ expect(collapsed.has('types/index.d.ts')).toBe(true);
89
+ });
90
+
91
+ it('collapses .map files', () => {
92
+ const files = [makeFile('dist/app.js.map')];
93
+ const collapsed = getAutoCollapsedPaths(files);
94
+
95
+ expect(collapsed.has('dist/app.js.map')).toBe(true);
96
+ });
97
+
98
+ it('collapses .snap files', () => {
99
+ const files = [makeFile('tests/__snapshots__/app.test.snap')];
100
+ const collapsed = getAutoCollapsedPaths(files);
101
+
102
+ expect(collapsed.has('tests/__snapshots__/app.test.snap')).toBe(true);
103
+ });
104
+
105
+ it('collapses files in dist/', () => {
106
+ const files = [makeFile('dist/index.js')];
107
+ const collapsed = getAutoCollapsedPaths(files);
108
+
109
+ expect(collapsed.has('dist/index.js')).toBe(true);
110
+ });
111
+
112
+ it('collapses files in build/', () => {
113
+ const files = [makeFile('build/output.js')];
114
+ const collapsed = getAutoCollapsedPaths(files);
115
+
116
+ expect(collapsed.has('build/output.js')).toBe(true);
117
+ });
118
+
119
+ it('collapses .generated.ts files', () => {
120
+ const files = [makeFile('src/api.generated.ts')];
121
+ const collapsed = getAutoCollapsedPaths(files);
122
+
123
+ expect(collapsed.has('src/api.generated.ts')).toBe(true);
124
+ });
125
+
126
+ it('collapses protobuf generated files', () => {
127
+ const files = [makeFile('proto/message.pb.go')];
128
+ const collapsed = getAutoCollapsedPaths(files);
129
+
130
+ expect(collapsed.has('proto/message.pb.go')).toBe(true);
131
+ });
132
+
133
+ it('collapses .lock extension files', () => {
134
+ const files = [makeFile('some-tool.lock')];
135
+ const collapsed = getAutoCollapsedPaths(files);
136
+
137
+ expect(collapsed.has('some-tool.lock')).toBe(true);
138
+ });
139
+ });
140
+
141
+ it('does not collapse regular source files', () => {
142
+ const files = [
143
+ makeFile('src/app.tsx'),
144
+ makeFile('lib/utils.ts'),
145
+ makeFile('README.md'),
146
+ makeFile('package.json'),
147
+ ];
148
+ const collapsed = getAutoCollapsedPaths(files);
149
+
150
+ expect(collapsed.size).toBe(0);
151
+ });
152
+
153
+ it('handles mix of collapsible and non-collapsible files', () => {
154
+ const files = [
155
+ makeFile('src/app.tsx'),
156
+ makeFile('package-lock.json'),
157
+ makeFile('dist/bundle.min.js'),
158
+ makeFile('src/old.ts', 'deleted'),
159
+ makeFile('lib/utils.ts'),
160
+ ];
161
+ const collapsed = getAutoCollapsedPaths(files);
162
+
163
+ expect(collapsed.size).toBe(3);
164
+ expect(collapsed.has('src/app.tsx')).toBe(false);
165
+ expect(collapsed.has('package-lock.json')).toBe(true);
166
+ expect(collapsed.has('dist/bundle.min.js')).toBe(true);
167
+ expect(collapsed.has('src/old.ts')).toBe(true);
168
+ expect(collapsed.has('lib/utils.ts')).toBe(false);
169
+ });
170
+ });
171
+
172
+ function makeLine(type: DiffLine['type'], content: string, oldNum: number | null, newNum: number | null): DiffLine {
173
+ return { type, content, oldLineNumber: oldNum, newLineNumber: newNum };
174
+ }
175
+
176
+ function makeHunk(lines: DiffLine[], oldStart = 1, newStart = 1): DiffHunk {
177
+ let oldCount = 0;
178
+ let newCount = 0;
179
+ for (const line of lines) {
180
+ if (line.type === 'context' || line.type === 'delete') {
181
+ oldCount++;
182
+ }
183
+ if (line.type === 'context' || line.type === 'add') {
184
+ newCount++;
185
+ }
186
+ }
187
+ return {
188
+ header: `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`,
189
+ oldStart,
190
+ oldCount,
191
+ newStart,
192
+ newCount,
193
+ lines,
194
+ };
195
+ }
196
+
197
+ function makeDiffFile(hunks: DiffHunk[], status: DiffFile['status'] = 'modified'): DiffFile {
198
+ return {
199
+ oldPath: 'src/file.ts',
200
+ newPath: 'src/file.ts',
201
+ status,
202
+ additions: 0,
203
+ deletions: 0,
204
+ hunks,
205
+ isBinary: false,
206
+ };
207
+ }
208
+
209
+ describe('getChangeGroups', () => {
210
+ it('returns empty array for all context lines', () => {
211
+ const lines = [
212
+ makeLine('context', 'a', 1, 1),
213
+ makeLine('context', 'b', 2, 2),
214
+ ];
215
+
216
+ expect(getChangeGroups(lines)).toEqual([]);
217
+ });
218
+
219
+ it('identifies a single change group', () => {
220
+ const lines = [
221
+ makeLine('context', 'a', 1, 1),
222
+ makeLine('delete', 'b', 2, null),
223
+ makeLine('add', 'c', null, 2),
224
+ makeLine('context', 'd', 3, 3),
225
+ ];
226
+ const groups = getChangeGroups(lines);
227
+
228
+ expect(groups).toEqual([{ startIndex: 1, endIndex: 2 }]);
229
+ });
230
+
231
+ it('identifies multiple change groups', () => {
232
+ const lines = [
233
+ makeLine('context', 'a', 1, 1),
234
+ makeLine('delete', 'b', 2, null),
235
+ makeLine('context', 'c', 3, 2),
236
+ makeLine('add', 'd', null, 3),
237
+ makeLine('add', 'e', null, 4),
238
+ makeLine('context', 'f', 4, 5),
239
+ ];
240
+ const groups = getChangeGroups(lines);
241
+
242
+ expect(groups).toEqual([
243
+ { startIndex: 1, endIndex: 1 },
244
+ { startIndex: 3, endIndex: 4 },
245
+ ]);
246
+ });
247
+
248
+ it('handles change group at start of hunk', () => {
249
+ const lines = [
250
+ makeLine('add', 'new', null, 1),
251
+ makeLine('context', 'a', 1, 2),
252
+ ];
253
+ const groups = getChangeGroups(lines);
254
+
255
+ expect(groups).toEqual([{ startIndex: 0, endIndex: 0 }]);
256
+ });
257
+
258
+ it('handles change group at end of hunk', () => {
259
+ const lines = [
260
+ makeLine('context', 'a', 1, 1),
261
+ makeLine('delete', 'old', 2, null),
262
+ ];
263
+ const groups = getChangeGroups(lines);
264
+
265
+ expect(groups).toEqual([{ startIndex: 1, endIndex: 1 }]);
266
+ });
267
+
268
+ it('handles all changed lines as one group', () => {
269
+ const lines = [
270
+ makeLine('delete', 'a', 1, null),
271
+ makeLine('delete', 'b', 2, null),
272
+ makeLine('add', 'c', null, 1),
273
+ ];
274
+ const groups = getChangeGroups(lines);
275
+
276
+ expect(groups).toEqual([{ startIndex: 0, endIndex: 2 }]);
277
+ });
278
+ });
279
+
280
+ describe('buildChangeGroupPatch', () => {
281
+ it('builds a patch for a single change group with context', () => {
282
+ const lines = [
283
+ makeLine('context', 'before', 1, 1),
284
+ makeLine('delete', 'old', 2, null),
285
+ makeLine('add', 'new', null, 2),
286
+ makeLine('context', 'after', 3, 3),
287
+ ];
288
+ const hunk = makeHunk(lines);
289
+ const file = makeDiffFile([hunk]);
290
+ const patch = buildChangeGroupPatch(file, hunk, 1, 2);
291
+
292
+ expect(patch).toContain('--- a/src/file.ts');
293
+ expect(patch).toContain('+++ b/src/file.ts');
294
+ expect(patch).toContain('@@ -1,3 +1,3 @@');
295
+ expect(patch).toContain(' before');
296
+ expect(patch).toContain('-old');
297
+ expect(patch).toContain('+new');
298
+ expect(patch).toContain(' after');
299
+ });
300
+
301
+ it('builds a patch for a change group without preceding context', () => {
302
+ const lines = [
303
+ makeLine('add', 'new line', null, 1),
304
+ makeLine('context', 'existing', 1, 2),
305
+ ];
306
+ const hunk = makeHunk(lines, 1, 1);
307
+ const file = makeDiffFile([hunk]);
308
+ const patch = buildChangeGroupPatch(file, hunk, 0, 0);
309
+
310
+ expect(patch).toContain('+new line');
311
+ expect(patch).toContain(' existing');
312
+ const patchLines = patch.split('\n');
313
+ const diffLines = patchLines.filter(l => !l.startsWith('---') && !l.startsWith('+++') && !l.startsWith('@@'));
314
+ expect(diffLines.some(l => l.startsWith('-'))).toBe(false);
315
+ });
316
+
317
+ it('includes up to 3 context lines before and after', () => {
318
+ const lines = [
319
+ makeLine('context', 'c1', 1, 1),
320
+ makeLine('context', 'c2', 2, 2),
321
+ makeLine('context', 'c3', 3, 3),
322
+ makeLine('context', 'c4', 4, 4),
323
+ makeLine('delete', 'old', 5, null),
324
+ makeLine('context', 'c5', 6, 5),
325
+ makeLine('context', 'c6', 7, 6),
326
+ makeLine('context', 'c7', 8, 7),
327
+ makeLine('context', 'c8', 9, 8),
328
+ ];
329
+ const hunk = makeHunk(lines);
330
+ const file = makeDiffFile([hunk]);
331
+ const patch = buildChangeGroupPatch(file, hunk, 4, 4);
332
+
333
+ expect(patch).toContain(' c2');
334
+ expect(patch).toContain(' c3');
335
+ expect(patch).toContain(' c4');
336
+ expect(patch).toContain('-old');
337
+ expect(patch).toContain(' c5');
338
+ expect(patch).toContain(' c6');
339
+ expect(patch).toContain(' c7');
340
+ expect(patch).not.toContain(' c1');
341
+ expect(patch).not.toContain(' c8');
342
+ });
343
+
344
+ it('selects only one change group from a hunk with multiple', () => {
345
+ const lines = [
346
+ makeLine('context', 'a', 1, 1),
347
+ makeLine('delete', 'first-del', 2, null),
348
+ makeLine('add', 'first-add', null, 2),
349
+ makeLine('context', 'b', 3, 3),
350
+ makeLine('delete', 'second-del', 4, null),
351
+ makeLine('context', 'c', 5, 4),
352
+ ];
353
+ const hunk = makeHunk(lines);
354
+ const file = makeDiffFile([hunk]);
355
+
356
+ const patch = buildChangeGroupPatch(file, hunk, 4, 4);
357
+
358
+ expect(patch).toContain('-second-del');
359
+ expect(patch).toContain(' b');
360
+ expect(patch).toContain(' c');
361
+ expect(patch).not.toContain('first');
362
+ });
363
+
364
+ it('builds a correct patch for delete-only changes', () => {
365
+ const lines = [
366
+ makeLine('context', 'keep-before', 1, 1),
367
+ makeLine('delete', 'removed-a', 2, null),
368
+ makeLine('delete', 'removed-b', 3, null),
369
+ makeLine('context', 'keep-after', 4, 2),
370
+ ];
371
+ const hunk = makeHunk(lines);
372
+ const file = makeDiffFile([hunk]);
373
+ const patch = buildChangeGroupPatch(file, hunk, 1, 2);
374
+
375
+ expect(patch).toContain('@@ -1,4 +1,2 @@');
376
+ expect(patch).toContain(' keep-before');
377
+ expect(patch).toContain('-removed-a');
378
+ expect(patch).toContain('-removed-b');
379
+ expect(patch).toContain(' keep-after');
380
+ const patchLines = patch.split('\n');
381
+ const diffLines = patchLines.filter(l => !l.startsWith('---') && !l.startsWith('+++') && !l.startsWith('@@'));
382
+ expect(diffLines.some(l => l.startsWith('+'))).toBe(false);
383
+ });
384
+
385
+ it('builds a correct patch for delete-only with no surrounding context', () => {
386
+ const lines = [
387
+ makeLine('delete', 'only-line', 1, null),
388
+ ];
389
+ const hunk = makeHunk(lines, 1, 1);
390
+ const file = makeDiffFile([hunk]);
391
+ const patch = buildChangeGroupPatch(file, hunk, 0, 0);
392
+
393
+ expect(patch).toContain('@@ -1,1 +1,0 @@');
394
+ expect(patch).toContain('-only-line');
395
+ });
396
+
397
+ it('handles added file paths', () => {
398
+ const lines = [
399
+ makeLine('add', 'new content', null, 1),
400
+ ];
401
+ const hunk = makeHunk(lines, 0, 1);
402
+ const file = makeDiffFile([hunk], 'added');
403
+ file.oldPath = '/dev/null';
404
+ const patch = buildChangeGroupPatch(file, hunk, 0, 0);
405
+
406
+ expect(patch).toContain('--- /dev/null');
407
+ expect(patch).toContain('+++ b/src/file.ts');
408
+ });
409
+ });
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "composite": true,
7
+ "jsx": "react-jsx",
8
+ "lib": ["ES2022", "DOM", "DOM.Iterable"]
9
+ },
10
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
11
+ "references": [
12
+ { "path": "../parser" }
13
+ ]
14
+ }