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,174 @@
1
+ import { useState } from 'react';
2
+ import { GENERAL_THREAD_FILE_PATH, isThreadResolved } from '../types/comment';
3
+ import type { CommentAuthor, CommentThread as CommentThreadType } from '../types/comment';
4
+ import type { CommentActions } from '../hooks/use-comment-actions';
5
+ import { CommentBubble } from './comment-bubble';
6
+ import { CommentForm } from './comment-form';
7
+ import { CommentIcon } from './icons/comment-icon';
8
+ import { TrashIcon } from './icons/trash-icon';
9
+ import { ThreadBadge } from './ui/thread-badge';
10
+
11
+ const DEFAULT_AUTHOR: CommentAuthor = { name: 'You', type: 'user' };
12
+
13
+ interface GeneralCommentsProps {
14
+ threads: CommentThreadType[];
15
+ commentActions: CommentActions;
16
+ }
17
+
18
+ export function GeneralComments(props: GeneralCommentsProps) {
19
+ const { threads: allThreads, commentActions } = props;
20
+
21
+ const threads = allThreads.filter(t => t.filePath === GENERAL_THREAD_FILE_PATH);
22
+ const [isExpanded, setIsExpanded] = useState(threads.length > 0);
23
+ const [showForm, setShowForm] = useState(false);
24
+
25
+ return (
26
+ <div className={`border rounded-lg mx-4 mt-4 overflow-hidden ${threads.length > 0 ? 'border-accent/40' : 'border-border'}`}>
27
+ <div className={`flex items-center gap-2 px-3 py-2 text-sm select-none ${threads.length > 0 ? 'bg-accent/5' : 'bg-bg-secondary'}`}>
28
+ <button
29
+ onClick={() => setIsExpanded(!isExpanded)}
30
+ className="text-[10px] w-5 h-5 shrink-0 flex items-center justify-center text-text-muted cursor-pointer"
31
+ >
32
+ {isExpanded ? '\u25bc' : '\u25b6'}
33
+ </button>
34
+ <button
35
+ onClick={() => setIsExpanded(!isExpanded)}
36
+ className="flex items-center gap-2 cursor-pointer"
37
+ >
38
+ <CommentIcon className="w-3.5 h-3.5 text-text-muted" />
39
+ <span className="text-text-secondary">General comments</span>
40
+ {threads.length > 0 && (
41
+ <span className="text-xs font-medium bg-accent/15 text-accent px-1.5 py-0.5 rounded-full">{threads.length}</span>
42
+ )}
43
+ </button>
44
+ <div className="flex-1" />
45
+ <button
46
+ onClick={(e) => {
47
+ e.stopPropagation();
48
+ setIsExpanded(true);
49
+ setShowForm(true);
50
+ }}
51
+ className="text-xs text-accent hover:text-accent-hover transition-colors cursor-pointer"
52
+ >
53
+ Add comment
54
+ </button>
55
+ </div>
56
+ {isExpanded && (
57
+ <div className="border-t border-border bg-bg">
58
+ {showForm && (
59
+ <div className="p-3">
60
+ <CommentForm
61
+ onSubmit={(body) => {
62
+ commentActions.addThread(GENERAL_THREAD_FILE_PATH, 'new', 0, 0, body, DEFAULT_AUTHOR);
63
+ setShowForm(false);
64
+ }}
65
+ onCancel={() => setShowForm(false)}
66
+ placeholder="Leave a general comment..."
67
+ submitLabel="Comment"
68
+ />
69
+ </div>
70
+ )}
71
+ {threads.length > 0 ? (
72
+ <div className="p-3 space-y-3">
73
+ {threads.map((thread) => (
74
+ <GeneralThreadCard
75
+ key={thread.id}
76
+ thread={thread}
77
+ onReply={(body) => commentActions.addReply(thread.id, body, DEFAULT_AUTHOR)}
78
+ onResolve={() => commentActions.resolveThread(thread.id)}
79
+ onUnresolve={() => commentActions.unresolveThread(thread.id)}
80
+ onDeleteComment={(commentId) => commentActions.deleteComment(thread.id, commentId)}
81
+ onDeleteThread={() => commentActions.deleteThread(thread.id)}
82
+ />
83
+ ))}
84
+ </div>
85
+ ) : !showForm && (
86
+ <div className="px-3 py-4 text-center text-xs text-text-muted">
87
+ No general comments yet
88
+ </div>
89
+ )}
90
+ </div>
91
+ )}
92
+ </div>
93
+ );
94
+ }
95
+
96
+ interface GeneralThreadCardProps {
97
+ thread: CommentThreadType;
98
+ onReply: (body: string) => void;
99
+ onResolve: () => void;
100
+ onUnresolve: () => void;
101
+ onDeleteComment: (commentId: string) => void;
102
+ onDeleteThread: () => void;
103
+ }
104
+
105
+ function GeneralThreadCard(props: GeneralThreadCardProps) {
106
+ const { thread, onReply, onResolve, onUnresolve, onDeleteComment, onDeleteThread } = props;
107
+ const [showReply, setShowReply] = useState(false);
108
+ const resolved = isThreadResolved(thread);
109
+
110
+ return (
111
+ <div className="border border-border rounded-lg overflow-hidden">
112
+ <div className="flex items-center justify-between px-3 py-1.5 bg-bg-secondary border-b border-border">
113
+ <div className="flex items-center gap-2">
114
+ {resolved && <ThreadBadge variant="resolved" />}
115
+ </div>
116
+ <div className="flex items-center gap-1">
117
+ {resolved ? (
118
+ <button
119
+ onClick={onUnresolve}
120
+ className="text-[11px] text-text-muted hover:text-text-secondary transition-colors cursor-pointer"
121
+ >
122
+ Reopen
123
+ </button>
124
+ ) : (
125
+ <button
126
+ onClick={onResolve}
127
+ className="text-[11px] text-text-muted hover:text-text-secondary transition-colors cursor-pointer"
128
+ >
129
+ Resolve
130
+ </button>
131
+ )}
132
+ <button
133
+ onClick={onDeleteThread}
134
+ className="text-text-muted hover:text-deleted transition-colors cursor-pointer ml-1"
135
+ title="Delete thread"
136
+ >
137
+ <TrashIcon className="w-3.5 h-3.5" />
138
+ </button>
139
+ </div>
140
+ </div>
141
+ <div>
142
+ {thread.comments.map((comment) => (
143
+ <CommentBubble
144
+ key={comment.id}
145
+ comment={comment}
146
+ onDelete={() => onDeleteComment(comment.id)}
147
+ />
148
+ ))}
149
+ </div>
150
+ {showReply ? (
151
+ <div className="px-3 py-2 border-t border-border">
152
+ <CommentForm
153
+ onSubmit={(body) => {
154
+ onReply(body);
155
+ setShowReply(false);
156
+ }}
157
+ onCancel={() => setShowReply(false)}
158
+ placeholder="Reply..."
159
+ submitLabel="Reply"
160
+ />
161
+ </div>
162
+ ) : (
163
+ <div className="px-3 py-2 border-t border-border">
164
+ <button
165
+ onClick={() => setShowReply(true)}
166
+ className="text-xs text-accent hover:text-accent-hover transition-colors cursor-pointer"
167
+ >
168
+ Reply
169
+ </button>
170
+ </div>
171
+ )}
172
+ </div>
173
+ );
174
+ }
@@ -0,0 +1,357 @@
1
+ import { useState, useMemo } from 'react';
2
+ import type { DiffHunk, DiffLine as DiffLineType } from '@diffity/parser';
3
+ import { cn } from '../lib/cn';
4
+ import { getLineBg, getChangeGroups } from '../lib/diff-utils';
5
+ import { renderContent } from '../lib/render-content';
6
+ import type { SyntaxToken } from '../lib/syntax-token';
7
+ import type { CommentThread as CommentThreadType, CommentAuthor, CommentSide, LineSelection, LineRenderProps } from '../types/comment';
8
+ import { HunkHeader, type ExpandControls } from './hunk-header';
9
+ import { CommentLineNumber } from './comment-line-number';
10
+ import { CommentThread } from './comment-thread';
11
+ import { CommentFormRow } from './comment-form-row';
12
+ import { UndoIcon } from './icons/undo-icon';
13
+
14
+ interface HunkBlockSplitProps {
15
+ hunk: DiffHunk;
16
+ syntaxMap?: Map<string, SyntaxToken[]>;
17
+ expandControls?: ExpandControls;
18
+ topExpansionLines?: DiffLineType[];
19
+ bottomExpansionLines?: DiffLineType[];
20
+ expansionSyntaxMap?: Map<string, SyntaxToken[]>;
21
+ threads?: CommentThreadType[];
22
+ pendingSelection?: LineSelection | null;
23
+ currentAuthor?: CommentAuthor;
24
+ isLineSelected?: (line: number, side: CommentSide) => boolean;
25
+ onLineMouseDown?: (line: number, side: CommentSide) => void;
26
+ onLineMouseEnter?: (line: number, side: CommentSide) => void;
27
+ onCommentClick?: (line: number, side: CommentSide) => void;
28
+ onAddThread?: (filePath: string, side: CommentSide, startLine: number, endLine: number, body: string, author: CommentAuthor) => void;
29
+ onReply?: (threadId: string, body: string, author: CommentAuthor) => void;
30
+ onResolve?: (threadId: string) => void;
31
+ onUnresolve?: (threadId: string) => void;
32
+ onDeleteComment?: (threadId: string, commentId: string) => void;
33
+ onDeleteThread?: (threadId: string) => void;
34
+ onCancelPending?: () => void;
35
+ filePath?: string;
36
+ onRevertChange?: (hunk: DiffHunk, startIndex: number, endIndex: number) => void;
37
+ getOriginalCode?: (side: CommentSide, startLine: number, endLine: number) => string;
38
+ }
39
+
40
+ interface SplitRow {
41
+ left: DiffLineType | null;
42
+ right: DiffLineType | null;
43
+ }
44
+
45
+ function buildSplitRows(lines: DiffLineType[]): SplitRow[] {
46
+ const rows: SplitRow[] = [];
47
+ let i = 0;
48
+
49
+ while (i < lines.length) {
50
+ const line = lines[i];
51
+
52
+ if (line.type === 'context') {
53
+ rows.push({ left: line, right: line });
54
+ i++;
55
+ continue;
56
+ }
57
+
58
+ if (line.type === 'delete') {
59
+ const deleteLines: DiffLineType[] = [];
60
+ while (i < lines.length && lines[i].type === 'delete') {
61
+ deleteLines.push(lines[i]);
62
+ i++;
63
+ }
64
+
65
+ const addLines: DiffLineType[] = [];
66
+ while (i < lines.length && lines[i].type === 'add') {
67
+ addLines.push(lines[i]);
68
+ i++;
69
+ }
70
+
71
+ const maxLen = Math.max(deleteLines.length, addLines.length);
72
+ for (let j = 0; j < maxLen; j++) {
73
+ rows.push({
74
+ left: j < deleteLines.length ? deleteLines[j] : null,
75
+ right: j < addLines.length ? addLines[j] : null,
76
+ });
77
+ }
78
+ continue;
79
+ }
80
+
81
+ if (line.type === 'add') {
82
+ rows.push({ left: null, right: line });
83
+ i++;
84
+ continue;
85
+ }
86
+
87
+ i++;
88
+ }
89
+
90
+ return rows;
91
+ }
92
+
93
+ function getCellBg(line: DiffLineType | null): string {
94
+ if (!line) {
95
+ return 'bg-bg-secondary';
96
+ }
97
+ return getLineBg(line.type);
98
+ }
99
+
100
+ function getSyntaxKey(line: DiffLineType): string {
101
+ const num = line.type === 'delete' ? line.oldLineNumber : line.newLineNumber;
102
+ return `${line.type}-${num}`;
103
+ }
104
+
105
+ function SplitCell(props: {
106
+ line: DiffLineType | null;
107
+ side: 'left' | 'right';
108
+ syntaxMap?: Map<string, SyntaxToken[]>;
109
+ expanded?: boolean;
110
+ isSelected?: boolean;
111
+ onMouseDown?: () => void;
112
+ onMouseEnter?: () => void;
113
+ onCommentClick?: () => void;
114
+ onUndo?: () => void;
115
+ }) {
116
+ const { line, side, syntaxMap, expanded, isSelected, onMouseDown, onMouseEnter, onCommentClick, onUndo } = props;
117
+ const [contentHovered, setContentHovered] = useState(false);
118
+
119
+ if (!line) {
120
+ return (
121
+ <>
122
+ <CommentLineNumber lineNumber={null} className="diff-empty-cell" />
123
+ <td className="px-3 whitespace-pre border-r border-border-muted align-top diff-empty-cell"></td>
124
+ </>
125
+ );
126
+ }
127
+
128
+ const bgClass = expanded ? 'bg-diff-expanded-gutter' : getCellBg(line);
129
+ const contentBgClass = expanded ? 'bg-diff-expanded-bg' : getCellBg(line);
130
+ const lineNum = side === 'left' ? line.oldLineNumber : line.newLineNumber;
131
+ const syntaxKey = getSyntaxKey(line);
132
+ const tokens = syntaxMap?.get(syntaxKey);
133
+
134
+ return (
135
+ <>
136
+ <CommentLineNumber
137
+ lineNumber={lineNum}
138
+ className={bgClass}
139
+ isSelected={isSelected}
140
+ showCommentButton={!!onCommentClick && lineNum !== null}
141
+ forceShowButton={contentHovered}
142
+ onMouseDown={onMouseDown}
143
+ onMouseEnter={onMouseEnter}
144
+ onCommentClick={onCommentClick}
145
+ />
146
+ <td
147
+ className={cn('px-3 whitespace-pre-wrap break-all border-r border-border-muted align-top relative', isSelected ? 'bg-diff-comment-bg' : contentBgClass)}
148
+ onMouseEnter={() => setContentHovered(true)}
149
+ onMouseLeave={() => setContentHovered(false)}
150
+ >
151
+ <span className="inline">{renderContent(line, tokens)}</span>
152
+ {onUndo && (
153
+ <button
154
+ onClick={onUndo}
155
+ className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-deleted/15 text-deleted hover:bg-deleted/25 transition-colors cursor-pointer opacity-0 group-hover/split-row:opacity-100"
156
+ title="Undo this change"
157
+ >
158
+ <UndoIcon className="w-3 h-3" />
159
+ Undo
160
+ </button>
161
+ )}
162
+ </td>
163
+ </>
164
+ );
165
+ }
166
+
167
+ export function renderSplitRows(
168
+ lines: DiffLineType[],
169
+ expanded: boolean,
170
+ syntaxMap: Map<string, SyntaxToken[]> | undefined,
171
+ keyPrefix: string,
172
+ props: LineRenderProps,
173
+ undoForRow?: Map<number, () => void>,
174
+ ): React.ReactNode[] {
175
+ const splitRows = buildSplitRows(lines);
176
+ const result: React.ReactNode[] = [];
177
+
178
+ for (let i = 0; i < splitRows.length; i++) {
179
+ const row = splitRows[i];
180
+ const leftLine = row.left;
181
+ const rightLine = row.right;
182
+ const leftNum = leftLine?.oldLineNumber ?? null;
183
+ const rightNum = rightLine?.newLineNumber ?? null;
184
+ const onUndo = undoForRow?.get(i);
185
+
186
+ result.push(
187
+ <tr key={`${keyPrefix}-${i}`} className="group/split-row font-mono text-sm leading-6">
188
+ <SplitCell
189
+ line={leftLine}
190
+ side="left"
191
+ syntaxMap={syntaxMap}
192
+ expanded={expanded}
193
+ isSelected={leftNum !== null ? props.isLineSelected?.(leftNum, 'old') : false}
194
+ onMouseDown={leftNum !== null ? () => props.onLineMouseDown?.(leftNum, 'old') : undefined}
195
+ onMouseEnter={leftNum !== null ? () => props.onLineMouseEnter?.(leftNum, 'old') : undefined}
196
+ onCommentClick={leftNum !== null && props.onCommentClick ? () => props.onCommentClick!(leftNum, 'old') : undefined}
197
+ onUndo={onUndo && !rightLine ? onUndo : undefined}
198
+ />
199
+ <SplitCell
200
+ line={rightLine}
201
+ side="right"
202
+ syntaxMap={syntaxMap}
203
+ expanded={expanded}
204
+ isSelected={rightNum !== null ? props.isLineSelected?.(rightNum, 'new') : false}
205
+ onMouseDown={rightNum !== null ? () => props.onLineMouseDown?.(rightNum, 'new') : undefined}
206
+ onMouseEnter={rightNum !== null ? () => props.onLineMouseEnter?.(rightNum, 'new') : undefined}
207
+ onCommentClick={rightNum !== null && props.onCommentClick ? () => props.onCommentClick!(rightNum, 'new') : undefined}
208
+ onUndo={onUndo && rightLine ? onUndo : undefined}
209
+ />
210
+ </tr>
211
+ );
212
+
213
+ const threadRows: React.ReactNode[] = [];
214
+
215
+ if (leftNum !== null && props.threads) {
216
+ const leftThreads = props.threads.filter(t => t.endLine === leftNum && t.side === 'old');
217
+ for (const thread of leftThreads) {
218
+ threadRows.push(
219
+ <CommentThread
220
+ key={`thread-${thread.id}`}
221
+ thread={thread}
222
+ onReply={props.onReply!}
223
+ onResolve={props.onResolve!}
224
+ onUnresolve={props.onUnresolve!}
225
+ onDeleteComment={props.onDeleteComment!}
226
+ onDeleteThread={props.onDeleteThread!}
227
+ currentAuthor={props.currentAuthor!}
228
+ colSpan={2}
229
+ viewMode="split"
230
+ side="old"
231
+ currentCode={props.getOriginalCode?.(thread.side, thread.startLine, thread.endLine)}
232
+ />
233
+ );
234
+ }
235
+ }
236
+
237
+ if (rightNum !== null && props.threads) {
238
+ const rightThreads = props.threads.filter(t => t.endLine === rightNum && t.side === 'new');
239
+ for (const thread of rightThreads) {
240
+ threadRows.push(
241
+ <CommentThread
242
+ key={`thread-${thread.id}`}
243
+ thread={thread}
244
+ onReply={props.onReply!}
245
+ onResolve={props.onResolve!}
246
+ onUnresolve={props.onUnresolve!}
247
+ onDeleteComment={props.onDeleteComment!}
248
+ onDeleteThread={props.onDeleteThread!}
249
+ currentAuthor={props.currentAuthor!}
250
+ colSpan={2}
251
+ viewMode="split"
252
+ side="new"
253
+ currentCode={props.getOriginalCode?.(thread.side, thread.startLine, thread.endLine)}
254
+ />
255
+ );
256
+ }
257
+ }
258
+
259
+ if (props.pendingSelection && props.filePath && props.currentAuthor && props.onAddThread && props.onCancelPending) {
260
+ const showForLeft = leftNum !== null && props.pendingSelection.endLine === leftNum && props.pendingSelection.side === 'old';
261
+ const showForRight = rightNum !== null && props.pendingSelection.endLine === rightNum && props.pendingSelection.side === 'new';
262
+ if (showForLeft || showForRight) {
263
+ threadRows.push(
264
+ <CommentFormRow
265
+ key="pending-comment"
266
+ colSpan={2}
267
+ filePath={props.filePath}
268
+ side={props.pendingSelection.side}
269
+ startLine={props.pendingSelection.startLine}
270
+ endLine={props.pendingSelection.endLine}
271
+ currentAuthor={props.currentAuthor}
272
+ onSubmit={props.onAddThread}
273
+ onCancel={props.onCancelPending}
274
+ viewMode="split"
275
+ />
276
+ );
277
+ }
278
+ }
279
+
280
+ if (threadRows.length > 0) {
281
+ result.push(...threadRows);
282
+ }
283
+ }
284
+
285
+ return result;
286
+ }
287
+
288
+ export function HunkBlockSplit(props: HunkBlockSplitProps) {
289
+ const {
290
+ hunk, syntaxMap, expandControls, topExpansionLines, bottomExpansionLines, expansionSyntaxMap,
291
+ threads, pendingSelection, currentAuthor, isLineSelected,
292
+ onLineMouseDown, onLineMouseEnter, onCommentClick,
293
+ onAddThread, onReply, onResolve, onUnresolve, onDeleteComment, onDeleteThread,
294
+ onCancelPending, filePath, onRevertChange, getOriginalCode,
295
+ } = props;
296
+
297
+ const commentProps = {
298
+ isLineSelected, onLineMouseDown, onLineMouseEnter, onCommentClick,
299
+ threads, pendingSelection, currentAuthor,
300
+ onAddThread, onReply, onResolve, onUnresolve, onDeleteComment, onDeleteThread,
301
+ onCancelPending, filePath, getOriginalCode,
302
+ };
303
+
304
+ const undoForRow = useMemo(() => {
305
+ if (!onRevertChange) {
306
+ return undefined;
307
+ }
308
+ const groups = getChangeGroups(hunk.lines);
309
+ const splitRows = buildSplitRows(hunk.lines);
310
+ const map = new Map<number, () => void>();
311
+
312
+ for (const group of groups) {
313
+ let lastSplitRowIdx = -1;
314
+ for (let ri = 0; ri < splitRows.length; ri++) {
315
+ const row = splitRows[ri];
316
+ const leftIsChange = row.left && row.left.type !== 'context';
317
+ const rightIsChange = row.right && row.right.type !== 'context';
318
+ if (leftIsChange || rightIsChange) {
319
+ const nextRow = splitRows[ri + 1];
320
+ const nextIsContext = !nextRow || ((!nextRow.left || nextRow.left.type === 'context') && (!nextRow.right || nextRow.right.type === 'context'));
321
+ if (nextIsContext) {
322
+ const leftInGroup = row.left && row.left.type !== 'context' && hunk.lines.indexOf(row.left) >= group.startIndex && hunk.lines.indexOf(row.left) <= group.endIndex;
323
+ const rightInGroup = row.right && row.right.type !== 'context' && hunk.lines.indexOf(row.right) >= group.startIndex && hunk.lines.indexOf(row.right) <= group.endIndex;
324
+ if (leftInGroup || rightInGroup) {
325
+ lastSplitRowIdx = ri;
326
+ }
327
+ }
328
+ }
329
+ }
330
+ if (lastSplitRowIdx >= 0) {
331
+ const g = group;
332
+ map.set(lastSplitRowIdx, () => onRevertChange(hunk, g.startIndex, g.endIndex));
333
+ }
334
+ }
335
+
336
+ return map;
337
+ }, [hunk, onRevertChange]);
338
+
339
+ const rows: React.ReactNode[] = [];
340
+
341
+ if (topExpansionLines && topExpansionLines.length > 0) {
342
+ rows.push(...renderSplitRows(topExpansionLines, true, expansionSyntaxMap, 'top-exp', commentProps));
343
+ }
344
+
345
+ if (bottomExpansionLines && bottomExpansionLines.length > 0) {
346
+ rows.push(...renderSplitRows(bottomExpansionLines, true, expansionSyntaxMap, 'bot-exp', commentProps));
347
+ }
348
+
349
+ rows.push(...renderSplitRows(hunk.lines, false, syntaxMap, 'hunk', commentProps, undoForRow));
350
+
351
+ return (
352
+ <tbody className={expandControls?.wasExpanded && expandControls.remainingLines <= 0 ? '' : 'border-t border-border-muted'}>
353
+ <HunkHeader hunk={hunk} expandControls={expandControls} />
354
+ {rows}
355
+ </tbody>
356
+ );
357
+ }
@@ -0,0 +1,161 @@
1
+ import { useMemo } from 'react';
2
+ import type { DiffHunk, DiffLine as DiffLineType } from '@diffity/parser';
3
+ import type { SyntaxToken } from '../lib/syntax-token';
4
+ import type { CommentThread as CommentThreadType, CommentAuthor, CommentSide, LineSelection, LineRenderProps } from '../types/comment';
5
+ import { getChangeGroups } from '../lib/diff-utils';
6
+ import { DiffLine } from './diff-line';
7
+ import { HunkHeader, type ExpandControls } from './hunk-header';
8
+ import { CommentThread } from './comment-thread';
9
+ import { CommentFormRow } from './comment-form-row';
10
+
11
+ interface HunkBlockProps {
12
+ hunk: DiffHunk;
13
+ syntaxMap?: Map<string, SyntaxToken[]>;
14
+ expandControls?: ExpandControls;
15
+ topExpansionLines?: DiffLineType[];
16
+ bottomExpansionLines?: DiffLineType[];
17
+ expansionSyntaxMap?: Map<string, SyntaxToken[]>;
18
+ threads?: CommentThreadType[];
19
+ pendingSelection?: LineSelection | null;
20
+ currentAuthor?: CommentAuthor;
21
+ isLineSelected?: (line: number, side: CommentSide) => boolean;
22
+ onLineMouseDown?: (line: number, side: CommentSide) => void;
23
+ onLineMouseEnter?: (line: number, side: CommentSide) => void;
24
+ onCommentClick?: (line: number, side: CommentSide) => void;
25
+ onAddThread?: (filePath: string, side: CommentSide, startLine: number, endLine: number, body: string, author: CommentAuthor) => void;
26
+ onReply?: (threadId: string, body: string, author: CommentAuthor) => void;
27
+ onResolve?: (threadId: string) => void;
28
+ onUnresolve?: (threadId: string) => void;
29
+ onDeleteComment?: (threadId: string, commentId: string) => void;
30
+ onDeleteThread?: (threadId: string) => void;
31
+ onCancelPending?: () => void;
32
+ filePath?: string;
33
+ onRevertChange?: (hunk: DiffHunk, startIndex: number, endIndex: number) => void;
34
+ getOriginalCode?: (side: CommentSide, startLine: number, endLine: number) => string;
35
+ }
36
+
37
+ export function renderLineWithComments(
38
+ line: DiffLineType,
39
+ index: number,
40
+ expanded: boolean,
41
+ syntaxMap: Map<string, SyntaxToken[]> | undefined,
42
+ props: LineRenderProps,
43
+ onUndo?: () => void,
44
+ ): React.ReactNode[] {
45
+ const side: CommentSide = line.type === 'delete' ? 'old' : 'new';
46
+ const activeLine = side === 'old' ? line.oldLineNumber : line.newLineNumber;
47
+ const num = activeLine;
48
+ const key = num !== null ? `${expanded ? 'exp-' : ''}${line.type}-${num}` : `line-${index}`;
49
+ const syntaxKey = num !== null ? `${line.type}-${num}` : '';
50
+ const tokens = syntaxMap?.get(syntaxKey);
51
+
52
+ const result: React.ReactNode[] = [];
53
+
54
+ result.push(
55
+ <DiffLine
56
+ key={key}
57
+ line={line}
58
+ syntaxTokens={tokens}
59
+ expanded={expanded}
60
+ isSelected={activeLine !== null ? props.isLineSelected?.(activeLine, side) : false}
61
+ onLineMouseDown={props.onLineMouseDown}
62
+ onLineMouseEnter={props.onLineMouseEnter}
63
+ onCommentClick={props.onCommentClick}
64
+ onUndo={onUndo}
65
+ />
66
+ );
67
+
68
+ if (activeLine !== null && props.threads) {
69
+ const lineThreads = props.threads.filter(t => t.endLine === activeLine && t.side === side);
70
+ for (const thread of lineThreads) {
71
+ result.push(
72
+ <CommentThread
73
+ key={`thread-${thread.id}`}
74
+ thread={thread}
75
+ onReply={props.onReply!}
76
+ onResolve={props.onResolve!}
77
+ onUnresolve={props.onUnresolve!}
78
+ onDeleteComment={props.onDeleteComment!}
79
+ onDeleteThread={props.onDeleteThread!}
80
+ currentAuthor={props.currentAuthor!}
81
+ colSpan={4}
82
+ currentCode={props.getOriginalCode?.(thread.side, thread.startLine, thread.endLine)}
83
+ />
84
+ );
85
+ }
86
+ }
87
+
88
+ if (activeLine !== null && props.pendingSelection && props.pendingSelection.endLine === activeLine && props.pendingSelection.side === side && props.filePath && props.currentAuthor && props.onAddThread && props.onCancelPending) {
89
+ result.push(
90
+ <CommentFormRow
91
+ key="pending-comment"
92
+ colSpan={4}
93
+ filePath={props.filePath}
94
+ side={props.pendingSelection.side}
95
+ startLine={props.pendingSelection.startLine}
96
+ endLine={props.pendingSelection.endLine}
97
+ currentAuthor={props.currentAuthor}
98
+ onSubmit={props.onAddThread}
99
+ onCancel={props.onCancelPending}
100
+ />
101
+ );
102
+ }
103
+
104
+ return result;
105
+ }
106
+
107
+ export function HunkBlock(props: HunkBlockProps) {
108
+ const {
109
+ hunk, syntaxMap, expandControls, topExpansionLines, bottomExpansionLines, expansionSyntaxMap,
110
+ threads, pendingSelection, currentAuthor, isLineSelected,
111
+ onLineMouseDown, onLineMouseEnter, onCommentClick,
112
+ onAddThread, onReply, onResolve, onUnresolve, onDeleteComment, onDeleteThread,
113
+ onCancelPending, filePath, onRevertChange, getOriginalCode,
114
+ } = props;
115
+
116
+ const commentProps = {
117
+ isLineSelected, onLineMouseDown, onLineMouseEnter, onCommentClick,
118
+ threads, pendingSelection, currentAuthor,
119
+ onAddThread, onReply, onResolve, onUnresolve, onDeleteComment, onDeleteThread,
120
+ onCancelPending, filePath, getOriginalCode,
121
+ };
122
+
123
+ const changeGroupEnds = useMemo(() => {
124
+ if (!onRevertChange) {
125
+ return new Map<number, { startIndex: number; endIndex: number }>();
126
+ }
127
+ const groups = getChangeGroups(hunk.lines);
128
+ const map = new Map<number, { startIndex: number; endIndex: number }>();
129
+ for (const group of groups) {
130
+ map.set(group.endIndex, group);
131
+ }
132
+ return map;
133
+ }, [hunk.lines, onRevertChange]);
134
+
135
+ const rows: React.ReactNode[] = [];
136
+
137
+ if (topExpansionLines) {
138
+ for (let i = 0; i < topExpansionLines.length; i++) {
139
+ rows.push(...renderLineWithComments(topExpansionLines[i], i, true, expansionSyntaxMap, commentProps));
140
+ }
141
+ }
142
+
143
+ if (bottomExpansionLines) {
144
+ for (let i = 0; i < bottomExpansionLines.length; i++) {
145
+ rows.push(...renderLineWithComments(bottomExpansionLines[i], i, true, expansionSyntaxMap, commentProps));
146
+ }
147
+ }
148
+
149
+ for (let i = 0; i < hunk.lines.length; i++) {
150
+ const group = changeGroupEnds.get(i);
151
+ const onUndo = group ? () => onRevertChange!(hunk, group.startIndex, group.endIndex) : undefined;
152
+ rows.push(...renderLineWithComments(hunk.lines[i], i, false, syntaxMap, commentProps, onUndo));
153
+ }
154
+
155
+ return (
156
+ <tbody className={expandControls?.wasExpanded && expandControls.remainingLines <= 0 ? '' : 'border-t border-border-muted'}>
157
+ <HunkHeader hunk={hunk} expandControls={expandControls} />
158
+ {rows}
159
+ </tbody>
160
+ );
161
+ }