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,84 @@
1
+ import { useOverview } from '../hooks/use-overview';
2
+ import { useCommits } from '../hooks/use-commits';
3
+ import { useInfo } from '../hooks/use-info';
4
+ import { OverviewFileList } from './overview-file-list';
5
+ import { CommitList } from './commit-list';
6
+ import { CheckCircleIcon } from './icons/check-circle-icon';
7
+ import { PageLoader } from './skeleton';
8
+
9
+ interface DashboardProps {
10
+ onNavigate: (ref: string) => void;
11
+ }
12
+
13
+ export function Dashboard(props: DashboardProps) {
14
+ const { onNavigate } = props;
15
+ const { data: overview, loading: overviewLoading, error } = useOverview();
16
+ const { data: commitsPage, loading: commitsLoading } = useCommits();
17
+ const { data: info, loading: infoLoading } = useInfo();
18
+
19
+ const anyLoading = overviewLoading || commitsLoading || infoLoading;
20
+
21
+ if (error) {
22
+ return (
23
+ <div className="flex flex-col min-h-screen bg-bg text-text font-sans">
24
+ <div className="flex flex-col items-center justify-center p-12 text-deleted text-center">
25
+ <h2 className="text-xl mb-2">Failed to load overview</h2>
26
+ <p className="text-text-secondary">{error}</p>
27
+ </div>
28
+ </div>
29
+ );
30
+ }
31
+
32
+ if (anyLoading || !overview) {
33
+ return <PageLoader />;
34
+ }
35
+
36
+ const isClean = overview.files.length === 0;
37
+
38
+ return (
39
+ <div className="flex flex-col min-h-screen bg-bg text-text font-sans">
40
+ <div className="border-b border-border bg-bg-secondary px-6 py-4">
41
+ <div className="max-w-4xl mx-auto flex items-center gap-3">
42
+ {info?.name && (
43
+ <span className="font-semibold text-lg text-accent">{info.name}</span>
44
+ )}
45
+ {info?.branch && (
46
+ <span className="px-2 py-0.5 bg-diff-hunk-bg text-diff-hunk-text rounded-md font-mono text-xs">
47
+ {info.branch}
48
+ </span>
49
+ )}
50
+ </div>
51
+ </div>
52
+
53
+ <div className="flex-1 overflow-y-auto">
54
+ <div className="max-w-4xl mx-auto px-6 py-6 space-y-6">
55
+ {isClean ? (
56
+ <div className="flex flex-col items-center justify-center py-16 text-text-muted text-center gap-3">
57
+ <div className="text-added opacity-50 mb-2">
58
+ <CheckCircleIcon />
59
+ </div>
60
+ <h2 className="text-xl text-text-secondary">Working tree is clean</h2>
61
+ <p>No changes to display.</p>
62
+ </div>
63
+ ) : (
64
+ <OverviewFileList
65
+ files={overview.files}
66
+ onViewAll={() => onNavigate('work')}
67
+ />
68
+ )}
69
+
70
+ <div className="border border-border rounded-lg bg-bg-secondary overflow-hidden">
71
+ <div className="px-4 py-3 border-b border-border">
72
+ <h3 className="font-medium text-text">Recent commits</h3>
73
+ </div>
74
+ <CommitList
75
+ initialCommits={commitsPage?.commits ?? []}
76
+ initialHasMore={commitsPage?.hasMore ?? false}
77
+ onCommitClick={(hash) => onNavigate(hash)}
78
+ />
79
+ </div>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ );
84
+ }
@@ -0,0 +1,90 @@
1
+ import type { DiffLine as DiffLineType } from '@diffity/parser';
2
+ import { cn } from '../lib/cn';
3
+ import { getLineBg } from '../lib/diff-utils';
4
+ import { renderContent } from '../lib/render-content';
5
+ import type { SyntaxToken } from '../lib/syntax-token';
6
+ import { CommentLineNumber } from './comment-line-number';
7
+ import { UndoIcon } from './icons/undo-icon';
8
+ import type { CommentSide } from '../types/comment';
9
+
10
+ export type { SyntaxToken };
11
+
12
+ interface DiffLineProps {
13
+ line: DiffLineType;
14
+ syntaxTokens?: SyntaxToken[];
15
+ expanded?: boolean;
16
+ isSelected?: boolean;
17
+ onLineMouseDown?: (line: number, side: CommentSide) => void;
18
+ onLineMouseEnter?: (line: number, side: CommentSide) => void;
19
+ onCommentClick?: (line: number, side: CommentSide) => void;
20
+ onUndo?: () => void;
21
+ }
22
+
23
+ function getPrefix(type: string): string {
24
+ switch (type) {
25
+ case 'add':
26
+ return '+';
27
+ case 'delete':
28
+ return '-';
29
+ default:
30
+ return ' ';
31
+ }
32
+ }
33
+
34
+ function getPrefixColor(type: string): string {
35
+ switch (type) {
36
+ case 'add':
37
+ return 'text-added';
38
+ case 'delete':
39
+ return 'text-deleted';
40
+ default:
41
+ return 'text-text-muted';
42
+ }
43
+ }
44
+
45
+ export function DiffLine(props: DiffLineProps) {
46
+ const { line, syntaxTokens, expanded, isSelected, onLineMouseDown, onLineMouseEnter, onCommentClick, onUndo } = props;
47
+
48
+ const side: CommentSide = line.type === 'delete' ? 'old' : 'new';
49
+ const activeLine = side === 'old' ? line.oldLineNumber : line.newLineNumber;
50
+ const gutterBg = expanded ? 'bg-diff-expanded-gutter' : '';
51
+
52
+ return (
53
+ <tr className={cn('group/row font-mono text-sm leading-6 hover:brightness-[0.97]', expanded ? 'bg-diff-expanded-bg' : getLineBg(line.type))}>
54
+ <CommentLineNumber
55
+ lineNumber={line.oldLineNumber}
56
+ className={cn('border-r border-border-muted', gutterBg)}
57
+ showCommentButton={!!onCommentClick && line.type === 'delete' && line.oldLineNumber !== null}
58
+ isSelected={isSelected && side === 'old'}
59
+ onMouseDown={line.oldLineNumber !== null ? () => onLineMouseDown?.(line.oldLineNumber!, line.type === 'delete' ? 'old' : 'new') : undefined}
60
+ onMouseEnter={line.oldLineNumber !== null ? () => onLineMouseEnter?.(line.oldLineNumber!, line.type === 'delete' ? 'old' : 'new') : undefined}
61
+ onCommentClick={line.oldLineNumber !== null && line.type === 'delete' ? () => onCommentClick?.(line.oldLineNumber!, 'old') : undefined}
62
+ />
63
+ <CommentLineNumber
64
+ lineNumber={line.newLineNumber}
65
+ className={cn('border-r border-border-muted', gutterBg)}
66
+ showCommentButton={!!onCommentClick && line.type !== 'delete' && line.newLineNumber !== null}
67
+ isSelected={isSelected && side === 'new'}
68
+ onMouseDown={line.newLineNumber !== null ? () => onLineMouseDown?.(line.newLineNumber!, line.type === 'delete' ? 'old' : 'new') : undefined}
69
+ onMouseEnter={line.newLineNumber !== null ? () => onLineMouseEnter?.(line.newLineNumber!, line.type === 'delete' ? 'old' : 'new') : undefined}
70
+ onCommentClick={line.newLineNumber !== null && line.type !== 'delete' ? () => onCommentClick?.(line.newLineNumber!, 'new') : undefined}
71
+ />
72
+ <td className={cn('w-5 min-w-5 px-1 text-center select-none align-top', getPrefixColor(line.type), isSelected && 'bg-diff-comment-bg')}>
73
+ {getPrefix(line.type)}
74
+ </td>
75
+ <td className={cn('px-3 whitespace-pre-wrap break-all relative', isSelected && 'bg-diff-comment-bg')}>
76
+ <span className="inline">{renderContent(line, syntaxTokens)}</span>
77
+ {onUndo && (
78
+ <button
79
+ onClick={onUndo}
80
+ 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/row:opacity-100"
81
+ title="Undo this change"
82
+ >
83
+ <UndoIcon className="w-3 h-3" />
84
+ Undo
85
+ </button>
86
+ )}
87
+ </td>
88
+ </tr>
89
+ );
90
+ }
@@ -0,0 +1,332 @@
1
+ import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import { useDiff } from '../hooks/use-diff';
4
+ import { useInfo } from '../hooks/use-info';
5
+ import { useTheme } from '../hooks/use-theme';
6
+ import { useKeyboard } from '../hooks/use-keyboard';
7
+ import { useReviewThreads } from '../hooks/use-review-threads';
8
+ import { useCommentActions } from '../hooks/use-comment-actions';
9
+ import { SummaryBar } from './summary-bar';
10
+ import { Toolbar } from './toolbar';
11
+ import { DiffView, type DiffViewHandle } from './diff-view';
12
+ import { Sidebar } from './sidebar';
13
+ import { ShortcutModal } from './shortcut-modal';
14
+ import { StaleDiffBanner } from './stale-diff-banner';
15
+ import { CheckCircleIcon } from './icons/check-circle-icon';
16
+ import { PageLoader } from './skeleton';
17
+ import { useDiffStaleness } from '../hooks/use-diff-staleness';
18
+ import { type ViewMode, getFilePath, getAutoCollapsedPaths, isWorkingTreeRef } from '../lib/diff-utils';
19
+ import { getHunkHeaders, scrollToElement } from '../lib/dom-utils';
20
+ import type { LineSelection } from '../types/comment';
21
+ import { isThreadResolved } from '../types/comment';
22
+
23
+ interface DiffPageProps {
24
+ refParam?: string;
25
+ initialTheme?: 'light' | 'dark' | null;
26
+ initialViewMode?: 'split' | 'unified' | null;
27
+ }
28
+
29
+ export function DiffPage(props: DiffPageProps) {
30
+ const { refParam, initialTheme, initialViewMode } = props;
31
+
32
+ const [viewMode, setViewMode] = useState<ViewMode>(initialViewMode || 'split');
33
+ const [hideWhitespace, setHideWhitespace] = useState(false);
34
+ const [showHelp, setShowHelp] = useState(false);
35
+ const { theme, toggleTheme } = useTheme(initialTheme);
36
+ const { data: diff, loading: diffLoading, error } = useDiff(hideWhitespace, refParam);
37
+ const { data: info, loading: infoLoading } = useInfo(refParam);
38
+ const [activeFile, setActiveFile] = useState<string | null>(null);
39
+ const [reviewedFiles, setReviewedFiles] = useState<Set<string>>(new Set());
40
+ const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
41
+ const [pendingSelection, setPendingSelection] = useState<LineSelection | null>(null);
42
+ const mainRef = useRef<HTMLElement | null>(null);
43
+ const diffViewRef = useRef<DiffViewHandle>(null);
44
+ const currentFileIdx = useRef(0);
45
+ const initializedDiffRef = useRef<typeof diff>(null);
46
+
47
+ const reviewsEnabled = !!info?.capabilities?.reviews;
48
+ const sessionId = info?.sessionId ?? null;
49
+ const isWorkingTree = isWorkingTreeRef(refParam);
50
+ const { isStale, resetStaleness } = useDiffStaleness(refParam, isWorkingTree);
51
+
52
+ const { data: serverThreads, isFetched: threadsFetched } = useReviewThreads(reviewsEnabled ? sessionId : null);
53
+ const threads = reviewsEnabled && serverThreads ? serverThreads : [];
54
+ const commentActions = useCommentActions(sessionId, reviewsEnabled);
55
+
56
+ const filesWithComments = useMemo(() => {
57
+ const paths = new Set<string>();
58
+ for (const thread of threads) {
59
+ if (!isThreadResolved(thread)) {
60
+ paths.add(thread.filePath);
61
+ }
62
+ }
63
+ return paths;
64
+ }, [threads]);
65
+
66
+ const handleAddThread = useCallback((...args: Parameters<typeof commentActions.addThread>) => {
67
+ commentActions.addThread(...args);
68
+ setPendingSelection(null);
69
+ }, [commentActions]);
70
+
71
+ useEffect(() => {
72
+ if (!diff || diff === initializedDiffRef.current) {
73
+ return;
74
+ }
75
+ initializedDiffRef.current = diff;
76
+
77
+ const autoCollapsed = getAutoCollapsedPaths(diff.files);
78
+ if (autoCollapsed.size > 0) {
79
+ setCollapsedFiles(autoCollapsed);
80
+ }
81
+ }, [diff]);
82
+
83
+ const handleToggleCollapse = useCallback((path: string) => {
84
+ setCollapsedFiles((prev) => {
85
+ const next = new Set(prev);
86
+ if (next.has(path)) {
87
+ next.delete(path);
88
+ } else {
89
+ next.add(path);
90
+ }
91
+ return next;
92
+ });
93
+ }, []);
94
+
95
+ const handleReviewedChange = useCallback((path: string, reviewed: boolean) => {
96
+ setReviewedFiles((prev) => {
97
+ const next = new Set(prev);
98
+ if (reviewed) {
99
+ next.add(path);
100
+ } else {
101
+ next.delete(path);
102
+ }
103
+ return next;
104
+ });
105
+ if (reviewed) {
106
+ setCollapsedFiles((prev) => {
107
+ const next = new Set(prev);
108
+ next.add(path);
109
+ return next;
110
+ });
111
+ } else {
112
+ setCollapsedFiles((prev) => {
113
+ const next = new Set(prev);
114
+ next.delete(path);
115
+ return next;
116
+ });
117
+ }
118
+ }, []);
119
+
120
+ const getCurrentFilePath = useCallback((): string | null => {
121
+ if (!diff) {
122
+ return null;
123
+ }
124
+ return getFilePath(diff.files[currentFileIdx.current]);
125
+ }, [diff]);
126
+
127
+ const navigateFile = useCallback((direction: number) => {
128
+ if (!diff) {
129
+ return;
130
+ }
131
+ const nextIdx = Math.max(0, Math.min(diff.files.length - 1, currentFileIdx.current + direction));
132
+ currentFileIdx.current = nextIdx;
133
+ const path = getFilePath(diff.files[nextIdx]);
134
+ diffViewRef.current?.scrollToFile(path);
135
+ }, [diff]);
136
+
137
+ const navigateHunk = useCallback((direction: number) => {
138
+ const hunks = getHunkHeaders();
139
+ if (hunks.length === 0) {
140
+ return;
141
+ }
142
+ let target = direction > 0 ? hunks[0] : hunks[hunks.length - 1];
143
+
144
+ for (let i = 0; i < hunks.length; i++) {
145
+ const rect = hunks[i].getBoundingClientRect();
146
+ if (direction > 0 && rect.top > 100) {
147
+ target = hunks[i];
148
+ break;
149
+ }
150
+ if (direction < 0 && rect.top < -10) {
151
+ target = hunks[i];
152
+ }
153
+ }
154
+
155
+ scrollToElement(target);
156
+ }, []);
157
+
158
+ useKeyboard({
159
+ onNextFile: () => navigateFile(1),
160
+ onPrevFile: () => navigateFile(-1),
161
+ onNextHunk: () => navigateHunk(1),
162
+ onPrevHunk: () => navigateHunk(-1),
163
+ onToggleCollapse: () => {
164
+ const path = getCurrentFilePath();
165
+ if (path) {
166
+ handleToggleCollapse(path);
167
+ }
168
+ },
169
+ onCollapseAll: () => {
170
+ if (!diff) {
171
+ return;
172
+ }
173
+ const allPaths = diff.files.map((f) => getFilePath(f));
174
+ const anyExpanded = allPaths.some((p) => !collapsedFiles.has(p));
175
+ if (anyExpanded) {
176
+ setCollapsedFiles(new Set(allPaths));
177
+ } else {
178
+ setCollapsedFiles(new Set());
179
+ }
180
+ },
181
+ onToggleReviewed: () => {
182
+ const path = getCurrentFilePath();
183
+ if (!path) {
184
+ return;
185
+ }
186
+ const wasReviewed = reviewedFiles.has(path);
187
+ handleReviewedChange(path, !wasReviewed);
188
+ if (!wasReviewed) {
189
+ navigateFile(1);
190
+ }
191
+ },
192
+ onUnifiedView: () => setViewMode('unified'),
193
+ onSplitView: () => setViewMode('split'),
194
+ onShowHelp: () => setShowHelp(true),
195
+ onFocusSearch: () => {
196
+ const input = document.querySelector(
197
+ 'input[placeholder="Filter files..."]',
198
+ ) as HTMLInputElement;
199
+ if (input) {
200
+ input.focus();
201
+ }
202
+ },
203
+ onEscape: () => setShowHelp(false),
204
+ });
205
+
206
+ const queryClient = useQueryClient();
207
+ const canRevert = useMemo(() => isWorkingTreeRef(refParam), [refParam]);
208
+
209
+ const handleRevert = useCallback(() => {
210
+ queryClient.invalidateQueries({ queryKey: ['diff'] });
211
+ }, [queryClient]);
212
+
213
+ const handleRefreshDiff = useCallback(() => {
214
+ queryClient.invalidateQueries({ queryKey: ['diff'] });
215
+ resetStaleness();
216
+ }, [queryClient, resetStaleness]);
217
+
218
+ const handleSidebarFileClick = useCallback((path: string) => {
219
+ setActiveFile(path);
220
+ diffViewRef.current?.scrollToFile(path);
221
+ }, []);
222
+
223
+ const handleScrollToThread = useCallback((threadId: string, filePath: string) => {
224
+ diffViewRef.current?.scrollToThread(threadId, filePath);
225
+ }, []);
226
+
227
+ const handleActiveFileFromScroll = useCallback((path: string) => {
228
+ setActiveFile(path);
229
+ }, []);
230
+
231
+ if (error) {
232
+ return (
233
+ <div className="flex flex-col min-h-screen bg-bg text-text font-sans">
234
+ <div className="flex flex-col items-center justify-center p-12 text-deleted text-center">
235
+ <h2 className="text-xl mb-2">Failed to load diff</h2>
236
+ <p className="text-text-secondary">{error}</p>
237
+ </div>
238
+ </div>
239
+ );
240
+ }
241
+
242
+ const threadsLoading = reviewsEnabled && !threadsFetched;
243
+ if ((diffLoading || infoLoading || threadsLoading) && !diff) {
244
+ return <PageLoader />;
245
+ }
246
+
247
+ if (!info || threadsLoading) {
248
+ return <PageLoader />;
249
+ }
250
+
251
+ if (diff && diff.files.length === 0 && !diffLoading) {
252
+ return (
253
+ <div className="flex flex-col items-center justify-center min-h-screen bg-bg text-text font-sans gap-2">
254
+ <div className="text-added opacity-40 mb-1">
255
+ <CheckCircleIcon />
256
+ </div>
257
+ <h2 className="text-base font-medium text-text-secondary">No changes found</h2>
258
+ <p className="text-xs text-text-muted">There are no differences to display.</p>
259
+ <div className="mt-4 flex flex-col gap-1.5 items-center">
260
+ <p className="text-xs text-text-muted mb-1">Try one of these</p>
261
+ <code className="inline-block px-3 py-1 bg-bg-secondary border border-border rounded-md font-mono text-xs text-text">
262
+ diffity HEAD~1
263
+ </code>
264
+ <code className="inline-block px-3 py-1 bg-bg-secondary border border-border rounded-md font-mono text-xs text-text">
265
+ diffity main..feature
266
+ </code>
267
+ </div>
268
+ </div>
269
+ );
270
+ }
271
+
272
+ return (
273
+ <div className="flex flex-col h-screen bg-bg text-text font-sans">
274
+ <SummaryBar
275
+ diff={diff}
276
+ repoName={info?.name || null}
277
+ branch={info?.branch || null}
278
+ description={info?.description || null}
279
+ />
280
+ <Toolbar
281
+ viewMode={viewMode}
282
+ onViewModeChange={setViewMode}
283
+ hideWhitespace={hideWhitespace}
284
+ onHideWhitespaceChange={setHideWhitespace}
285
+ theme={theme}
286
+ onToggleTheme={toggleTheme}
287
+ onShowHelp={() => setShowHelp(true)}
288
+ diff={diff || undefined}
289
+ diffRef={refParam}
290
+ threads={threads}
291
+ onDeleteAllComments={commentActions.deleteAllThreads}
292
+ onScrollToThread={handleScrollToThread}
293
+ />
294
+ {isStale && <StaleDiffBanner onRefresh={handleRefreshDiff} />}
295
+ <div className="flex flex-1 overflow-hidden">
296
+ <Sidebar
297
+ files={diff?.files || []}
298
+ activeFile={activeFile}
299
+ reviewedFiles={reviewedFiles}
300
+ filesWithComments={filesWithComments}
301
+ onFileClick={handleSidebarFileClick}
302
+ />
303
+ {diff ? (
304
+ <DiffView
305
+ diff={diff}
306
+ viewMode={viewMode}
307
+ theme={theme}
308
+ collapsedFiles={collapsedFiles}
309
+ onToggleCollapse={handleToggleCollapse}
310
+ reviewedFiles={reviewedFiles}
311
+ onReviewedChange={handleReviewedChange}
312
+ onActiveFileChange={handleActiveFileFromScroll}
313
+ handle={diffViewRef}
314
+ baseRef={refParam}
315
+ canRevert={canRevert}
316
+ onRevert={handleRevert}
317
+ scrollRef={(node) => {
318
+ mainRef.current = node;
319
+ }}
320
+ threads={threads}
321
+ commentsEnabled={reviewsEnabled}
322
+ commentActions={commentActions}
323
+ onAddThread={handleAddThread}
324
+ pendingSelection={pendingSelection}
325
+ onPendingSelectionChange={setPendingSelection}
326
+ />
327
+ ) : null}
328
+ </div>
329
+ {showHelp && <ShortcutModal onClose={() => setShowHelp(false)} />}
330
+ </div>
331
+ );
332
+ }
@@ -0,0 +1,20 @@
1
+ interface DiffStatsProps {
2
+ additions: number;
3
+ deletions: number;
4
+ className?: string;
5
+ }
6
+
7
+ export function DiffStats(props: DiffStatsProps) {
8
+ const { additions, deletions, className = '' } = props;
9
+
10
+ return (
11
+ <span className={`flex gap-1 shrink-0 font-mono text-xs ${className}`}>
12
+ {additions > 0 && (
13
+ <span className="text-added font-semibold">+{additions}</span>
14
+ )}
15
+ {deletions > 0 && (
16
+ <span className="text-deleted font-semibold">-{deletions}</span>
17
+ )}
18
+ </span>
19
+ );
20
+ }