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,536 @@
1
+ import { useState, useMemo, useCallback } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import type { DiffHunk } from '@diffity/parser';
4
+ import type { DiffFile, DiffLine as DiffLineType } from '@diffity/parser';
5
+ import type { SyntaxToken } from '../lib/syntax-token';
6
+ import type { HighlightedTokens } from '../hooks/use-highlighter';
7
+ import type { CommentSide, LineSelection } from '../types/comment';
8
+ import { type ViewMode, getFilePath, buildChangeGroupPatch, extractLinesFromDiff } from '../lib/diff-utils';
9
+ import { revertHunk as apiRevertHunk } from '../lib/api';
10
+ import { ConfirmDialog } from './ui/confirm-dialog';
11
+ import { computeGaps, createContextLines, getExpandRange, type ExpandableGap } from '../lib/context-expansion';
12
+ import { fileContentOptions } from '../queries/file';
13
+ import type { CommentActions } from '../hooks/use-comment-actions';
14
+ import type { CommentThread } from '../types/comment';
15
+ import { GENERAL_THREAD_FILE_PATH } from '../types/comment';
16
+ import { useLineSelection } from '../hooks/use-line-selection';
17
+ import { useCopy } from '../hooks/use-copy';
18
+ import { CopyIcon } from './icons/copy-icon';
19
+ import { CheckIcon } from './icons/check-icon';
20
+ import { CommentIcon } from './icons/comment-icon';
21
+ import { DiffStats } from './diff-stats';
22
+ import { Badge } from './ui/badge';
23
+ import { IconButton } from './ui/icon-button';
24
+ import { StatusBadge } from './ui/status-badge';
25
+ import { HunkWithGap } from './hunk-with-gap';
26
+ import { OrphanedThreads } from './orphaned-threads';
27
+ import { ThreadBadge } from './ui/thread-badge';
28
+ import { buildExpansionSyntaxMap, renderExpansionRows } from './render-expansion-rows';
29
+ import { ExpandRow } from './expand-row';
30
+
31
+ export const LARGE_DIFF_LINE_THRESHOLD = 200;
32
+
33
+ const DEFAULT_AUTHOR = { name: 'You', type: 'user' as const };
34
+
35
+ function getTotalLineCount(file: DiffFile): number {
36
+ let count = 0;
37
+ for (const hunk of file.hunks) {
38
+ count += hunk.lines.length;
39
+ }
40
+ return count;
41
+ }
42
+
43
+ interface FileBlockProps {
44
+ file: DiffFile;
45
+ viewMode: ViewMode;
46
+ collapsed: boolean;
47
+ onToggleCollapse: (path: string) => void;
48
+ reviewed: boolean;
49
+ onReviewedChange: (path: string, reviewed: boolean) => void;
50
+ highlightLine?: (code: string) => HighlightedTokens[] | null;
51
+ baseRef?: string;
52
+ canRevert?: boolean;
53
+ onRevert?: () => void;
54
+ threads: CommentThread[];
55
+ commentsEnabled: boolean;
56
+ commentActions: CommentActions;
57
+ onAddThread: CommentActions['addThread'];
58
+ pendingSelection: LineSelection | null;
59
+ onPendingSelectionChange: (selection: LineSelection | null) => void;
60
+ highlighted?: boolean;
61
+ onHighlightEnd?: () => void;
62
+ }
63
+
64
+ interface GapExpansion {
65
+ fromTop: number;
66
+ fromBottom: number;
67
+ linesFromTop: DiffLineType[];
68
+ linesFromBottom: DiffLineType[];
69
+ }
70
+
71
+ export function FileBlock(props: FileBlockProps) {
72
+ const {
73
+ file, viewMode, collapsed, onToggleCollapse, reviewed, onReviewedChange, highlightLine, baseRef, canRevert, onRevert,
74
+ threads: allThreads, commentsEnabled, commentActions, onAddThread: rawAddThread, pendingSelection, onPendingSelectionChange,
75
+ highlighted, onHighlightEnd,
76
+ } = props;
77
+
78
+ const totalLines = getTotalLineCount(file);
79
+ const isLargeDiff = totalLines >= LARGE_DIFF_LINE_THRESHOLD;
80
+
81
+ const [largeDiffExpanded, setLargeDiffExpanded] = useState(false);
82
+ const [expansions, setExpansions] = useState<Map<string, GapExpansion>>(new Map());
83
+ const [loadingGap, setLoadingGap] = useState<string | null>(null);
84
+
85
+ const filePath = getFilePath(file);
86
+ const showRename = file.status === 'renamed' && file.oldPath !== file.newPath;
87
+ const isNewFile = file.status === 'added';
88
+
89
+ const queryClient = useQueryClient();
90
+ const fileContentPath = file.oldPath || filePath;
91
+ const fileLineCount = file.oldFileLineCount ?? null;
92
+
93
+ const { copied: pathCopied, copy: copyPath } = useCopy();
94
+
95
+ const [confirmRevertChange, setConfirmRevertChange] = useState<{ hunk: DiffHunk; startIndex: number; endIndex: number } | null>(null);
96
+
97
+ const handleRevertChange = useCallback(async (info: { hunk: DiffHunk; startIndex: number; endIndex: number }) => {
98
+ setConfirmRevertChange(null);
99
+ const patch = buildChangeGroupPatch(file, info.hunk, info.startIndex, info.endIndex);
100
+ await apiRevertHunk(patch);
101
+ onRevert?.();
102
+ }, [file, onRevert]);
103
+
104
+
105
+ const { addReply, resolveThread, unresolveThread, dismissThread, deleteComment, deleteThread } = commentActions;
106
+
107
+ const getOriginalCode = useCallback((side: CommentSide, startLine: number, endLine: number) => {
108
+ return extractLinesFromDiff(file.hunks, side, startLine, endLine);
109
+ }, [file.hunks]);
110
+
111
+ const addThread = useCallback((fp: string, side: CommentSide, startLine: number, endLine: number, body: string, author: import('../types/comment').CommentAuthor) => {
112
+ const anchorContent = extractLinesFromDiff(file.hunks, side, startLine, endLine);
113
+ rawAddThread(fp, side, startLine, endLine, body, author, anchorContent || undefined);
114
+ }, [rawAddThread, file.hunks]);
115
+
116
+ const allFileThreads = useMemo(() => {
117
+ return allThreads.filter(t => t.filePath === filePath && t.filePath !== GENERAL_THREAD_FILE_PATH);
118
+ }, [allThreads, filePath]);
119
+
120
+ const { anchoredThreads: fileThreads, orphanedThreads } = useMemo(() => {
121
+ const diffLineNumbers = new Set<string>();
122
+ for (const hunk of file.hunks) {
123
+ for (const line of hunk.lines) {
124
+ if (line.oldLineNumber !== null) {
125
+ diffLineNumbers.add(`old:${line.oldLineNumber}`);
126
+ }
127
+ if (line.newLineNumber !== null) {
128
+ diffLineNumbers.add(`new:${line.newLineNumber}`);
129
+ }
130
+ }
131
+ }
132
+
133
+ const anchored: typeof allFileThreads = [];
134
+ const orphaned: typeof allFileThreads = [];
135
+ for (const thread of allFileThreads) {
136
+ let isInDiff = false;
137
+ for (let line = thread.startLine; line <= thread.endLine; line++) {
138
+ if (diffLineNumbers.has(`${thread.side}:${line}`)) {
139
+ isInDiff = true;
140
+ break;
141
+ }
142
+ }
143
+
144
+ if (isInDiff) {
145
+ anchored.push(thread);
146
+ } else {
147
+ orphaned.push(thread);
148
+ }
149
+ }
150
+
151
+ return { anchoredThreads: anchored, orphanedThreads: orphaned };
152
+ }, [allFileThreads, file.hunks]);
153
+
154
+ const handleSelectionComplete = useCallback((selection: LineSelection) => {
155
+ if (!commentsEnabled) {
156
+ return;
157
+ }
158
+ onPendingSelectionChange(selection);
159
+ }, [onPendingSelectionChange, commentsEnabled]);
160
+
161
+ const { isLineInSelection, handleLineMouseDown, handleLineMouseEnter } = useLineSelection({
162
+ filePath,
163
+ onSelectionComplete: handleSelectionComplete,
164
+ });
165
+
166
+ const handleCommentClickFn = useCallback((line: number, side: CommentSide) => {
167
+ onPendingSelectionChange({
168
+ filePath,
169
+ side,
170
+ startLine: line,
171
+ endLine: line,
172
+ });
173
+ }, [filePath, onPendingSelectionChange]);
174
+
175
+ const handleCommentClick = commentsEnabled ? handleCommentClickFn : undefined;
176
+
177
+ const handleCancelPending = useCallback(() => {
178
+ onPendingSelectionChange(null);
179
+ }, [onPendingSelectionChange]);
180
+
181
+ const isLineSelected = useCallback((line: number, side: CommentSide) => {
182
+ if (isLineInSelection(line, side)) {
183
+ return true;
184
+ }
185
+ if (pendingSelection && pendingSelection.filePath === filePath && pendingSelection.side === side) {
186
+ return line >= pendingSelection.startLine && line <= pendingSelection.endLine;
187
+ }
188
+ for (const thread of fileThreads) {
189
+ if (thread.side === side && line >= thread.startLine && line <= thread.endLine && thread.status === 'open') {
190
+ return true;
191
+ }
192
+ }
193
+ return false;
194
+ }, [isLineInSelection, pendingSelection, filePath, fileThreads]);
195
+
196
+
197
+ const syntaxMap = useMemo(() => {
198
+ if (!highlightLine) {
199
+ return undefined;
200
+ }
201
+
202
+ const map = new Map<string, SyntaxToken[]>();
203
+
204
+ for (const hunk of file.hunks) {
205
+ for (const line of hunk.lines) {
206
+ const highlighted = highlightLine(line.content);
207
+ if (highlighted && highlighted.length > 0) {
208
+ const num = line.type === 'delete' ? line.oldLineNumber : line.newLineNumber;
209
+ const key = `${line.type}-${num}`;
210
+ map.set(key, highlighted[0].tokens);
211
+ }
212
+ }
213
+ }
214
+
215
+ return map;
216
+ }, [file, highlightLine]);
217
+
218
+ const gaps = useMemo(() => {
219
+ if (isNewFile) {
220
+ return [];
221
+ }
222
+ return computeGaps(file.hunks, fileLineCount);
223
+ }, [file.hunks, fileLineCount, isNewFile]);
224
+
225
+ const gapMap = useMemo(() => {
226
+ const map = new Map<string, ExpandableGap>();
227
+ for (const gap of gaps) {
228
+ map.set(gap.id, gap);
229
+ }
230
+ return map;
231
+ }, [gaps]);
232
+
233
+ const handleExpand = useCallback(async (gap: ExpandableGap, direction: 'up' | 'down' | 'all') => {
234
+ setLoadingGap(gap.id);
235
+
236
+ const lines = await queryClient.ensureQueryData(
237
+ fileContentOptions(fileContentPath, true, baseRef)
238
+ );
239
+
240
+ setExpansions(prev => {
241
+ const next = new Map(prev);
242
+ const existing = next.get(gap.id) || { fromTop: 0, fromBottom: 0, linesFromTop: [], linesFromBottom: [] };
243
+ const newOffset = gap.newStart - gap.oldStart;
244
+ const range = getExpandRange(gap, direction, existing);
245
+
246
+ if (!range) {
247
+ return prev;
248
+ }
249
+
250
+ const contextLines = createContextLines(lines, range.oldStart, range.oldEnd, newOffset);
251
+
252
+ if (direction === 'all') {
253
+ next.set(gap.id, {
254
+ fromTop: gap.oldEnd - gap.oldStart + 1,
255
+ fromBottom: 0,
256
+ linesFromTop: contextLines,
257
+ linesFromBottom: [],
258
+ });
259
+ } else if (direction === 'down') {
260
+ const newExpansion = {
261
+ ...existing,
262
+ fromTop: existing.fromTop + (range.oldEnd - range.oldStart + 1),
263
+ linesFromTop: [...existing.linesFromTop, ...contextLines],
264
+ };
265
+ next.set(gap.id, newExpansion);
266
+ } else {
267
+ const newExpansion = {
268
+ ...existing,
269
+ fromBottom: existing.fromBottom + (range.oldEnd - range.oldStart + 1),
270
+ linesFromBottom: [...contextLines, ...existing.linesFromBottom],
271
+ };
272
+ next.set(gap.id, newExpansion);
273
+ }
274
+
275
+ return next;
276
+ });
277
+
278
+ setLoadingGap(null);
279
+ }, [fileContentPath, queryClient, baseRef]);
280
+
281
+ const getGapRemaining = useCallback((gap: ExpandableGap): { total: number; up: number; down: number } => {
282
+ const expansion = expansions.get(gap.id);
283
+ if (!expansion) {
284
+ return { total: gap.totalLines, up: gap.totalLines, down: gap.totalLines };
285
+ }
286
+ const total = Math.max(0, gap.totalLines - expansion.fromTop - expansion.fromBottom);
287
+ const up = Math.max(0, gap.totalLines - expansion.fromTop);
288
+ const down = Math.max(0, gap.totalLines - expansion.fromBottom);
289
+ return { total, up, down };
290
+ }, [expansions]);
291
+
292
+ const getExpandControlsForHunk = useCallback((hunkIndex: number) => {
293
+ let gap: ExpandableGap | undefined;
294
+ let position: 'top' | 'between' = 'between';
295
+
296
+ if (hunkIndex === 0) {
297
+ gap = gapMap.get('top');
298
+ position = 'top';
299
+ } else {
300
+ gap = gapMap.get(`between-${hunkIndex - 1}`);
301
+ position = 'between';
302
+ }
303
+
304
+ if (!gap) {
305
+ return undefined;
306
+ }
307
+
308
+ const remaining = getGapRemaining(gap);
309
+ const wasExpanded = expansions.has(gap.id);
310
+
311
+ return {
312
+ position,
313
+ remainingLines: remaining.total,
314
+ remainingUp: remaining.up,
315
+ remainingDown: remaining.down,
316
+ loading: loadingGap === gap.id,
317
+ wasExpanded,
318
+ onExpand: (dir: 'up' | 'down' | 'all') => handleExpand(gap, dir),
319
+ };
320
+ }, [gapMap, getGapRemaining, loadingGap, handleExpand, expansions]);
321
+
322
+ const total = file.additions + file.deletions;
323
+ const addBlocks = total > 0 ? Math.round((file.additions / total) * Math.min(5, total)) : 0;
324
+ const delBlocks = total > 0 ? Math.min(5, total) - addBlocks : 0;
325
+ const neutralBlocks = 5 - addBlocks - delBlocks;
326
+
327
+ const bottomGap = gapMap.get('bottom');
328
+ const bottomRemaining = bottomGap ? getGapRemaining(bottomGap).total : 0;
329
+
330
+ const filePendingSelection = pendingSelection && pendingSelection.filePath === filePath ? pendingSelection : null;
331
+
332
+ return (
333
+ <div
334
+ className={`border rounded-lg mx-4 my-3 overflow-hidden ${highlighted ? 'animate-flash-highlight-border' : 'border-border'}`}
335
+ id={`file-${encodeURIComponent(filePath)}`}
336
+ onAnimationEnd={onHighlightEnd}
337
+ >
338
+ <div
339
+ className={`group flex items-center gap-2 px-3 py-1.5 border-border text-xs sticky top-0 z-10 shadow-sticky ${highlighted ? 'animate-flash-highlight' : 'bg-bg-secondary'}`}
340
+ >
341
+ <IconButton
342
+ className="text-[10px] w-4 h-4 shrink-0"
343
+ onClick={() => onToggleCollapse(filePath)}
344
+ title={collapsed ? 'Expand' : 'Collapse'}
345
+ >
346
+ {collapsed ? '\u25b6' : '\u25bc'}
347
+ </IconButton>
348
+ <button
349
+ className="font-mono text-xs truncate text-left cursor-pointer hover:text-accent transition-colors"
350
+ onClick={() => onToggleCollapse(filePath)}
351
+ >
352
+ {showRename ? (
353
+ <>
354
+ <span className="line-through text-text-muted">{file.oldPath}</span>
355
+ <span className="text-text-muted"> → </span>
356
+ <span>{file.newPath}</span>
357
+ </>
358
+ ) : (
359
+ filePath
360
+ )}
361
+ </button>
362
+ <button
363
+ onClick={() => copyPath(filePath)}
364
+ className="shrink-0 text-text-muted hover:text-text transition-colors cursor-pointer"
365
+ title="Copy file path"
366
+ >
367
+ {pathCopied ? (
368
+ <CheckIcon className="w-3 h-3 text-added" />
369
+ ) : (
370
+ <CopyIcon className="w-3 h-3" />
371
+ )}
372
+ </button>
373
+ {file.status !== 'modified' && <StatusBadge status={file.status} />}
374
+ {file.isBinary && <Badge className="bg-bg-tertiary text-text-muted">Binary</Badge>}
375
+ <div className="ml-auto flex items-center gap-2.5 shrink-0">
376
+ {(fileThreads.length + orphanedThreads.length) > 0 && (
377
+ <span className="text-[11px] text-text-muted flex items-center gap-1">
378
+ <CommentIcon className="w-3 h-3" />
379
+ {fileThreads.length + orphanedThreads.length}
380
+ {orphanedThreads.length > 0 && (
381
+ <ThreadBadge variant="outdated" size="sm">
382
+ {orphanedThreads.length} outdated
383
+ </ThreadBadge>
384
+ )}
385
+ </span>
386
+ )}
387
+ <div className="flex items-center gap-1.5">
388
+ <DiffStats additions={file.additions} deletions={file.deletions} />
389
+ <div className="flex gap-px">
390
+ {Array.from({ length: addBlocks }).map((_, i) => (
391
+ <span key={`a${i}`} className="w-1.5 h-1.5 rounded-sm bg-added" />
392
+ ))}
393
+ {Array.from({ length: delBlocks }).map((_, i) => (
394
+ <span key={`d${i}`} className="w-1.5 h-1.5 rounded-sm bg-deleted" />
395
+ ))}
396
+ {Array.from({ length: neutralBlocks }).map((_, i) => (
397
+ <span key={`n${i}`} className="w-1.5 h-1.5 rounded-sm bg-border" />
398
+ ))}
399
+ </div>
400
+ </div>
401
+ <label className="flex items-center gap-1.5 text-[11px] text-text-muted cursor-pointer select-none hover:text-text transition-colors">
402
+ <input
403
+ type="checkbox"
404
+ checked={reviewed}
405
+ onChange={() => onReviewedChange(filePath, !reviewed)}
406
+ className="accent-added cursor-pointer w-3 h-3"
407
+ />
408
+ Viewed
409
+ </label>
410
+ </div>
411
+ </div>
412
+ {!collapsed && (
413
+ <div>
414
+ {file.isBinary ? (
415
+ <div className="p-4 text-center text-text-muted italic">Binary file not shown</div>
416
+ ) : file.hunks.length === 0 ? (
417
+ <div className="p-4 text-center text-text-muted italic">
418
+ {file.oldMode && file.newMode
419
+ ? `File mode changed from ${file.oldMode} to ${file.newMode}`
420
+ : 'No content changes'}
421
+ </div>
422
+ ) : isLargeDiff && !largeDiffExpanded ? (
423
+ <div className="flex items-center justify-center gap-3 py-6 px-4 text-sm text-text-muted">
424
+ <span>Large diff not rendered — {totalLines} lines</span>
425
+ <button
426
+ className="text-accent hover:underline cursor-pointer font-medium"
427
+ onClick={() => setLargeDiffExpanded(true)}
428
+ >
429
+ Load diff
430
+ </button>
431
+ </div>
432
+ ) : (
433
+ <>
434
+ <OrphanedThreads
435
+ threads={orphanedThreads}
436
+ onDeleteComment={deleteComment}
437
+ onDeleteThread={deleteThread}
438
+ />
439
+ <table className="w-full border-collapse table-fixed">
440
+ {viewMode === 'split' ? (
441
+ <colgroup>
442
+ <col className="w-12.5" />
443
+ <col className="w-[calc(50%-50px)]" />
444
+ <col className="w-12.5" />
445
+ <col />
446
+ </colgroup>
447
+ ) : (
448
+ <colgroup>
449
+ <col className="w-12.5" />
450
+ <col className="w-12.5" />
451
+ <col className="w-5" />
452
+ <col />
453
+ </colgroup>
454
+ )}
455
+ {file.hunks.map((hunk, i) => {
456
+ const betweenGap = i > 0 ? gapMap.get(`between-${i - 1}`) : undefined;
457
+ const betweenExpansion = betweenGap ? expansions.get(betweenGap.id) : undefined;
458
+ const topExpansion = i === 0 ? expansions.get('top') : undefined;
459
+
460
+ return (
461
+ <HunkWithGap
462
+ key={i}
463
+ hunk={hunk}
464
+ viewMode={viewMode}
465
+ syntaxMap={syntaxMap}
466
+ expandControls={getExpandControlsForHunk(i)}
467
+ topExpansionLines={i === 0 ? [...(topExpansion?.linesFromTop ?? []), ...(topExpansion?.linesFromBottom ?? [])] : undefined}
468
+ gapExpansion={betweenExpansion}
469
+ gapId={betweenGap?.id}
470
+ highlightLine={highlightLine}
471
+ threads={fileThreads}
472
+ pendingSelection={filePendingSelection}
473
+ currentAuthor={DEFAULT_AUTHOR}
474
+ isLineSelected={isLineSelected}
475
+ onLineMouseDown={handleLineMouseDown}
476
+ onLineMouseEnter={handleLineMouseEnter}
477
+ onCommentClick={handleCommentClick}
478
+ onAddThread={addThread}
479
+ onReply={addReply}
480
+ onResolve={resolveThread}
481
+ onUnresolve={unresolveThread}
482
+ onDeleteComment={deleteComment}
483
+ onDeleteThread={deleteThread}
484
+ onCancelPending={handleCancelPending}
485
+ filePath={filePath}
486
+ onRevertChange={canRevert ? (h: DiffHunk, startIndex: number, endIndex: number) => setConfirmRevertChange({ hunk: h, startIndex, endIndex }) : undefined}
487
+ getOriginalCode={getOriginalCode}
488
+ />
489
+ );
490
+ })}
491
+ {bottomGap && (() => {
492
+ const bottomExpansion = expansions.get('bottom');
493
+ const bottomLines = [
494
+ ...(bottomExpansion?.linesFromTop ?? []),
495
+ ...(bottomExpansion?.linesFromBottom ?? []),
496
+ ];
497
+ const bottomSyntaxMap = buildExpansionSyntaxMap(bottomLines, highlightLine);
498
+ const bottomCommentProps = {
499
+ isLineSelected, onLineMouseDown: handleLineMouseDown, onLineMouseEnter: handleLineMouseEnter,
500
+ onCommentClick: handleCommentClick, threads: fileThreads, pendingSelection: filePendingSelection,
501
+ currentAuthor: DEFAULT_AUTHOR, onAddThread: addThread, onReply: addReply,
502
+ onResolve: resolveThread, onUnresolve: unresolveThread, onDeleteComment: deleteComment,
503
+ onDeleteThread: deleteThread, onCancelPending: handleCancelPending, filePath,
504
+ getOriginalCode,
505
+ };
506
+ return (
507
+ <tbody>
508
+ {bottomExpansion?.linesFromTop && bottomExpansion.linesFromTop.length > 0 &&
509
+ renderExpansionRows(bottomExpansion.linesFromTop, viewMode, 'bottom-top', bottomSyntaxMap, bottomCommentProps)}
510
+ <ExpandRow
511
+ position="bottom"
512
+ remainingLines={bottomRemaining}
513
+ loading={loadingGap === 'bottom'}
514
+ onExpand={(dir) => handleExpand(bottomGap, dir)}
515
+ />
516
+ {bottomExpansion?.linesFromBottom && bottomExpansion.linesFromBottom.length > 0 &&
517
+ renderExpansionRows(bottomExpansion.linesFromBottom, viewMode, 'bottom-bot', bottomSyntaxMap, bottomCommentProps)}
518
+ </tbody>
519
+ );
520
+ })()}
521
+ </table>
522
+ </>
523
+ )}
524
+ </div>
525
+ )}
526
+ {confirmRevertChange && (
527
+ <ConfirmDialog
528
+ title="Undo change"
529
+ message="This will undo the selected change. This cannot be undone."
530
+ onConfirm={() => handleRevertChange(confirmRevertChange)}
531
+ onCancel={() => setConfirmRevertChange(null)}
532
+ />
533
+ )}
534
+ </div>
535
+ );
536
+ }
@@ -0,0 +1,84 @@
1
+ import type { TreeNode } from '../lib/file-tree';
2
+ import { cn } from '../lib/cn';
3
+ import { DiffStats } from './diff-stats';
4
+ import { StatusBadge } from './ui/status-badge';
5
+ import { ChevronIcon } from './icons/chevron-icon';
6
+ import { FolderIcon } from './icons/folder-icon';
7
+ import { CommentIcon } from './icons/comment-icon';
8
+
9
+ interface FileTreeItemProps {
10
+ node: TreeNode;
11
+ depth: number;
12
+ activeFile: string | null;
13
+ reviewedFiles: Set<string>;
14
+ filesWithComments: Set<string>;
15
+ expandedDirs: Set<string>;
16
+ onToggleDir: (path: string) => void;
17
+ onFileClick: (path: string) => void;
18
+ }
19
+
20
+ export function FileTreeItem(props: FileTreeItemProps) {
21
+ const { node, depth, activeFile, reviewedFiles, filesWithComments, expandedDirs, onToggleDir, onFileClick } = props;
22
+ const paddingLeft = depth * 12 + 8;
23
+
24
+ if (node.type === 'dir') {
25
+ const isExpanded = expandedDirs.has(node.path);
26
+ return (
27
+ <>
28
+ <button
29
+ className="flex items-center gap-1.5 w-full py-1 pr-2 text-left text-[13px] hover:bg-hover cursor-pointer"
30
+ style={{ paddingLeft: `${paddingLeft}px` }}
31
+ onClick={() => onToggleDir(node.path)}
32
+ >
33
+ <ChevronIcon expanded={isExpanded} />
34
+ <FolderIcon open={isExpanded} />
35
+ <span className="truncate font-medium text-text-secondary">{node.name}</span>
36
+ </button>
37
+ {isExpanded && node.children.map(child => (
38
+ <FileTreeItem
39
+ key={child.path}
40
+ node={child}
41
+ depth={depth + 1}
42
+ activeFile={activeFile}
43
+ reviewedFiles={reviewedFiles}
44
+ filesWithComments={filesWithComments}
45
+ expandedDirs={expandedDirs}
46
+ onToggleDir={onToggleDir}
47
+ onFileClick={onFileClick}
48
+ />
49
+ ))}
50
+ </>
51
+ );
52
+ }
53
+
54
+ const isActive = activeFile === node.path;
55
+ const isReviewed = reviewedFiles.has(node.path);
56
+ const hasComments = filesWithComments.has(node.path);
57
+
58
+ return (
59
+ <button
60
+ className={cn(
61
+ 'flex items-center gap-1.5 w-full py-1 pr-2 text-left text-[13px] cursor-pointer border-l-2',
62
+ isActive
63
+ ? 'bg-active border-l-accent'
64
+ : 'border-l-transparent hover:bg-hover',
65
+ isReviewed && 'opacity-50'
66
+ )}
67
+ style={{ paddingLeft: `${paddingLeft + 15}px` }}
68
+ onClick={() => onFileClick(node.path)}
69
+ >
70
+ <StatusBadge status={node.file.status} compact />
71
+ <span className={cn('flex-1 min-w-0 truncate', isReviewed && 'line-through')}>
72
+ {node.name}
73
+ </span>
74
+ {hasComments && (
75
+ <CommentIcon className="w-3 h-3 text-accent shrink-0" />
76
+ )}
77
+ {isReviewed ? (
78
+ <span className="text-added text-[10px] shrink-0" title="Viewed">&#10003;</span>
79
+ ) : (
80
+ <DiffStats additions={node.file.additions} deletions={node.file.deletions} />
81
+ )}
82
+ </button>
83
+ );
84
+ }
@@ -0,0 +1,72 @@
1
+ import { useMemo, useState, useCallback, useRef } from 'react';
2
+ import type { DiffFile } from '@diffity/parser';
3
+ import {
4
+ buildFileTree,
5
+ collapseSingleChildDirs,
6
+ sortTree,
7
+ filterTree,
8
+ collectAllDirPaths,
9
+ } from '../lib/file-tree';
10
+ import { FileTreeItem } from './file-tree-item';
11
+
12
+ interface FileTreeProps {
13
+ files: DiffFile[];
14
+ search: string;
15
+ activeFile: string | null;
16
+ reviewedFiles: Set<string>;
17
+ filesWithComments: Set<string>;
18
+ onFileClick: (path: string) => void;
19
+ }
20
+
21
+ export function FileTree(props: FileTreeProps) {
22
+ const { files, search, activeFile, reviewedFiles, filesWithComments, onFileClick } = props;
23
+
24
+ const tree = useMemo(() => {
25
+ return sortTree(collapseSingleChildDirs(buildFileTree(files)));
26
+ }, [files]);
27
+
28
+ const prevTreeRef = useRef(tree);
29
+ const [expandedDirs, setExpandedDirs] = useState<Set<string>>(() => new Set(collectAllDirPaths(tree)));
30
+
31
+ if (prevTreeRef.current !== tree) {
32
+ prevTreeRef.current = tree;
33
+ setExpandedDirs(new Set(collectAllDirPaths(tree)));
34
+ }
35
+
36
+ const displayTree = useMemo(() => {
37
+ if (!search) {
38
+ return tree;
39
+ }
40
+ return filterTree(tree, search);
41
+ }, [tree, search]);
42
+
43
+ const handleToggleDir = useCallback((path: string) => {
44
+ setExpandedDirs(prev => {
45
+ const next = new Set(prev);
46
+ if (next.has(path)) {
47
+ next.delete(path);
48
+ } else {
49
+ next.add(path);
50
+ }
51
+ return next;
52
+ });
53
+ }, []);
54
+
55
+ return (
56
+ <div className="flex-1 overflow-y-auto py-1">
57
+ {displayTree.map(node => (
58
+ <FileTreeItem
59
+ key={node.path}
60
+ node={node}
61
+ depth={0}
62
+ activeFile={activeFile}
63
+ reviewedFiles={reviewedFiles}
64
+ filesWithComments={filesWithComments}
65
+ expandedDirs={expandedDirs}
66
+ onToggleDir={handleToggleDir}
67
+ onFileClick={onFileClick}
68
+ />
69
+ ))}
70
+ </div>
71
+ );
72
+ }