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.
- package/.claude/settings.local.json +11 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/development.md +156 -0
- package/package.json +32 -0
- package/packages/cli/build.js +38 -0
- package/packages/cli/package.json +51 -0
- package/packages/cli/src/agent.ts +187 -0
- package/packages/cli/src/db.ts +58 -0
- package/packages/cli/src/index.ts +196 -0
- package/packages/cli/src/review-routes.ts +150 -0
- package/packages/cli/src/server.ts +370 -0
- package/packages/cli/src/session.ts +48 -0
- package/packages/cli/src/threads.ts +238 -0
- package/packages/cli/tsconfig.json +13 -0
- package/packages/git/package.json +24 -0
- package/packages/git/src/commits.ts +28 -0
- package/packages/git/src/diff.ts +97 -0
- package/packages/git/src/exec.ts +35 -0
- package/packages/git/src/index.ts +5 -0
- package/packages/git/src/repo.ts +63 -0
- package/packages/git/src/status.ts +9 -0
- package/packages/git/src/types.ts +12 -0
- package/packages/git/tsconfig.json +9 -0
- package/packages/parser/package.json +26 -0
- package/packages/parser/src/index.ts +12 -0
- package/packages/parser/src/parse.ts +299 -0
- package/packages/parser/src/types.ts +52 -0
- package/packages/parser/src/word-diff.ts +155 -0
- package/packages/parser/tests/fixtures/binary-deleted.diff +4 -0
- package/packages/parser/tests/fixtures/binary-file.diff +4 -0
- package/packages/parser/tests/fixtures/binary-modified.diff +3 -0
- package/packages/parser/tests/fixtures/copied-file.diff +12 -0
- package/packages/parser/tests/fixtures/deleted-file.diff +9 -0
- package/packages/parser/tests/fixtures/empty.diff +0 -0
- package/packages/parser/tests/fixtures/hunk-with-context.diff +12 -0
- package/packages/parser/tests/fixtures/mode-change-with-content.diff +10 -0
- package/packages/parser/tests/fixtures/mode-change.diff +3 -0
- package/packages/parser/tests/fixtures/multi-file.diff +22 -0
- package/packages/parser/tests/fixtures/new-file.diff +9 -0
- package/packages/parser/tests/fixtures/no-newline.diff +10 -0
- package/packages/parser/tests/fixtures/renamed-file.diff +12 -0
- package/packages/parser/tests/fixtures/single-file-additions.diff +11 -0
- package/packages/parser/tests/fixtures/single-file-deletions.diff +11 -0
- package/packages/parser/tests/fixtures/single-file-mixed.diff +15 -0
- package/packages/parser/tests/fixtures/single-file-multi-hunk.diff +22 -0
- package/packages/parser/tests/fixtures/spaces-in-path.diff +9 -0
- package/packages/parser/tests/fixtures/submodule.diff +7 -0
- package/packages/parser/tests/fixtures/unicode-content.diff +11 -0
- package/packages/parser/tests/parse.test.ts +312 -0
- package/packages/parser/tests/word-diff-integration.test.ts +52 -0
- package/packages/parser/tests/word-diff.test.ts +121 -0
- package/packages/parser/tsconfig.json +10 -0
- package/packages/skills/diffity-resolve/SKILL.md +55 -0
- package/packages/skills/diffity-review/SKILL.md +74 -0
- package/packages/skills/diffity-start/SKILL.md +25 -0
- package/packages/ui/index.html +13 -0
- package/packages/ui/package.json +35 -0
- package/packages/ui/public/brand.svg +12 -0
- package/packages/ui/public/favicon.svg +15 -0
- package/packages/ui/src/app.tsx +14 -0
- package/packages/ui/src/components/comment-bubble.tsx +78 -0
- package/packages/ui/src/components/comment-form-row.tsx +58 -0
- package/packages/ui/src/components/comment-form.tsx +78 -0
- package/packages/ui/src/components/comment-line-number.tsx +60 -0
- package/packages/ui/src/components/comment-thread.tsx +209 -0
- package/packages/ui/src/components/commit-list.tsx +100 -0
- package/packages/ui/src/components/dashboard.tsx +84 -0
- package/packages/ui/src/components/diff-line.tsx +90 -0
- package/packages/ui/src/components/diff-page.tsx +332 -0
- package/packages/ui/src/components/diff-stats.tsx +20 -0
- package/packages/ui/src/components/diff-view.tsx +278 -0
- package/packages/ui/src/components/expand-row.tsx +45 -0
- package/packages/ui/src/components/file-block.tsx +536 -0
- package/packages/ui/src/components/file-tree-item.tsx +84 -0
- package/packages/ui/src/components/file-tree.tsx +72 -0
- package/packages/ui/src/components/general-comments.tsx +174 -0
- package/packages/ui/src/components/hunk-block-split.tsx +357 -0
- package/packages/ui/src/components/hunk-block.tsx +161 -0
- package/packages/ui/src/components/hunk-header.tsx +144 -0
- package/packages/ui/src/components/hunk-with-gap.tsx +113 -0
- package/packages/ui/src/components/icons/arrow-down-icon.tsx +7 -0
- package/packages/ui/src/components/icons/arrow-up-icon.tsx +7 -0
- package/packages/ui/src/components/icons/check-circle-icon.tsx +8 -0
- package/packages/ui/src/components/icons/check-icon.tsx +9 -0
- package/packages/ui/src/components/icons/chevron-down-icon.tsx +11 -0
- package/packages/ui/src/components/icons/chevron-icon.tsx +20 -0
- package/packages/ui/src/components/icons/chevron-up-down-icon.tsx +7 -0
- package/packages/ui/src/components/icons/chevron-up-icon.tsx +11 -0
- package/packages/ui/src/components/icons/comment-icon.tsx +9 -0
- package/packages/ui/src/components/icons/copy-icon.tsx +10 -0
- package/packages/ui/src/components/icons/eye-icon.tsx +10 -0
- package/packages/ui/src/components/icons/eye-off-icon.tsx +12 -0
- package/packages/ui/src/components/icons/file-icon.tsx +7 -0
- package/packages/ui/src/components/icons/folder-icon.tsx +19 -0
- package/packages/ui/src/components/icons/git-branch-icon.tsx +13 -0
- package/packages/ui/src/components/icons/keyboard-icon.tsx +13 -0
- package/packages/ui/src/components/icons/moon-icon.tsx +9 -0
- package/packages/ui/src/components/icons/plus-icon.tsx +9 -0
- package/packages/ui/src/components/icons/search-icon.tsx +10 -0
- package/packages/ui/src/components/icons/sidebar-icon.tsx +10 -0
- package/packages/ui/src/components/icons/spinner.tsx +7 -0
- package/packages/ui/src/components/icons/split-view-icon.tsx +10 -0
- package/packages/ui/src/components/icons/sun-icon.tsx +17 -0
- package/packages/ui/src/components/icons/trash-icon.tsx +11 -0
- package/packages/ui/src/components/icons/undo-icon.tsx +9 -0
- package/packages/ui/src/components/icons/unified-view-icon.tsx +12 -0
- package/packages/ui/src/components/icons/x-icon.tsx +10 -0
- package/packages/ui/src/components/line-number-cell.tsx +18 -0
- package/packages/ui/src/components/markdown-content.tsx +139 -0
- package/packages/ui/src/components/orphaned-threads.tsx +80 -0
- package/packages/ui/src/components/overview-file-list.tsx +57 -0
- package/packages/ui/src/components/render-expansion-rows.tsx +47 -0
- package/packages/ui/src/components/shortcut-modal.tsx +93 -0
- package/packages/ui/src/components/sidebar.tsx +80 -0
- package/packages/ui/src/components/skeleton.tsx +9 -0
- package/packages/ui/src/components/stale-diff-banner.tsx +21 -0
- package/packages/ui/src/components/summary-bar.tsx +39 -0
- package/packages/ui/src/components/toolbar.tsx +246 -0
- package/packages/ui/src/components/ui/badge.tsx +17 -0
- package/packages/ui/src/components/ui/confirm-dialog.tsx +52 -0
- package/packages/ui/src/components/ui/icon-button.tsx +23 -0
- package/packages/ui/src/components/ui/status-badge.tsx +57 -0
- package/packages/ui/src/components/ui/thread-badge.tsx +35 -0
- package/packages/ui/src/components/word-diff.tsx +126 -0
- package/packages/ui/src/hooks/use-comment-actions.ts +97 -0
- package/packages/ui/src/hooks/use-commits.ts +12 -0
- package/packages/ui/src/hooks/use-copy.ts +18 -0
- package/packages/ui/src/hooks/use-diff-staleness.ts +58 -0
- package/packages/ui/src/hooks/use-diff.ts +12 -0
- package/packages/ui/src/hooks/use-highlighter.ts +190 -0
- package/packages/ui/src/hooks/use-info.ts +12 -0
- package/packages/ui/src/hooks/use-keyboard.ts +55 -0
- package/packages/ui/src/hooks/use-line-selection.ts +157 -0
- package/packages/ui/src/hooks/use-overview.ts +12 -0
- package/packages/ui/src/hooks/use-review-threads.ts +12 -0
- package/packages/ui/src/hooks/use-search-params.ts +26 -0
- package/packages/ui/src/hooks/use-theme.ts +34 -0
- package/packages/ui/src/hooks/use-thread-navigation.ts +43 -0
- package/packages/ui/src/lib/api.ts +232 -0
- package/packages/ui/src/lib/cn.ts +6 -0
- package/packages/ui/src/lib/context-expansion.ts +122 -0
- package/packages/ui/src/lib/diff-utils.ts +268 -0
- package/packages/ui/src/lib/dom-utils.ts +13 -0
- package/packages/ui/src/lib/file-tree.ts +122 -0
- package/packages/ui/src/lib/query-client.ts +10 -0
- package/packages/ui/src/lib/render-content.tsx +23 -0
- package/packages/ui/src/lib/syntax-token.ts +4 -0
- package/packages/ui/src/main.tsx +14 -0
- package/packages/ui/src/queries/commits.ts +9 -0
- package/packages/ui/src/queries/diff.ts +9 -0
- package/packages/ui/src/queries/file.ts +10 -0
- package/packages/ui/src/queries/info.ts +9 -0
- package/packages/ui/src/queries/overview.ts +9 -0
- package/packages/ui/src/styles/app.css +178 -0
- package/packages/ui/src/types/comment.ts +61 -0
- package/packages/ui/src/vite-env.d.ts +1 -0
- package/packages/ui/tests/context-expansion.test.ts +279 -0
- package/packages/ui/tests/diff-utils.test.ts +409 -0
- package/packages/ui/tsconfig.json +14 -0
- package/packages/ui/vite.config.ts +23 -0
- package/scripts/build-skills.ts +26 -0
- package/scripts/build.ts +15 -0
- package/scripts/dev.ts +32 -0
- package/scripts/lib/transformers/claude-code.ts +11 -0
- package/scripts/lib/transformers/codex.ts +17 -0
- package/scripts/lib/transformers/cursor.ts +17 -0
- package/scripts/lib/transformers/index.ts +3 -0
- package/scripts/lib/utils.ts +70 -0
- package/scripts/link-dev.ts +54 -0
- package/skills/diffity-resolve/SKILL.md +55 -0
- package/skills/diffity-review/SKILL.md +74 -0
- package/skills/diffity-start/SKILL.md +27 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { ParsedDiff } from '@diffity/parser';
|
|
3
|
+
import { cn } from '../lib/cn';
|
|
4
|
+
import { useCopy } from '../hooks/use-copy';
|
|
5
|
+
import { useThreadNavigation } from '../hooks/use-thread-navigation';
|
|
6
|
+
import { getFilePath } from '../lib/diff-utils';
|
|
7
|
+
import { CopyIcon } from './icons/copy-icon';
|
|
8
|
+
import { CheckIcon } from './icons/check-icon';
|
|
9
|
+
import { ChevronUpIcon } from './icons/chevron-up-icon';
|
|
10
|
+
import { ChevronDownIcon } from './icons/chevron-down-icon';
|
|
11
|
+
import { TrashIcon } from './icons/trash-icon';
|
|
12
|
+
import { UnifiedViewIcon } from './icons/unified-view-icon';
|
|
13
|
+
import { SplitViewIcon } from './icons/split-view-icon';
|
|
14
|
+
import { SunIcon } from './icons/sun-icon';
|
|
15
|
+
import { MoonIcon } from './icons/moon-icon';
|
|
16
|
+
import { EyeIcon } from './icons/eye-icon';
|
|
17
|
+
import { EyeOffIcon } from './icons/eye-off-icon';
|
|
18
|
+
import { KeyboardIcon } from './icons/keyboard-icon';
|
|
19
|
+
import { ConfirmDialog } from './ui/confirm-dialog';
|
|
20
|
+
import { GENERAL_THREAD_FILE_PATH } from '../types/comment';
|
|
21
|
+
import type { ViewMode } from '../lib/diff-utils';
|
|
22
|
+
import type { CommentThread } from '../types/comment';
|
|
23
|
+
import { isThreadResolved } from '../types/comment';
|
|
24
|
+
|
|
25
|
+
interface ToolbarProps {
|
|
26
|
+
viewMode: ViewMode;
|
|
27
|
+
onViewModeChange: (mode: ViewMode) => void;
|
|
28
|
+
hideWhitespace: boolean;
|
|
29
|
+
onHideWhitespaceChange: (hide: boolean) => void;
|
|
30
|
+
theme: 'light' | 'dark';
|
|
31
|
+
onToggleTheme: () => void;
|
|
32
|
+
onShowHelp: () => void;
|
|
33
|
+
diff?: ParsedDiff;
|
|
34
|
+
diffRef?: string;
|
|
35
|
+
threads: CommentThread[];
|
|
36
|
+
onDeleteAllComments: () => void;
|
|
37
|
+
onScrollToThread: (threadId: string, filePath: string) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extractCodeContext(diff: ParsedDiff | undefined, filePath: string, side: 'old' | 'new', startLine: number, endLine: number): string[] {
|
|
41
|
+
if (!diff) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const file = diff.files.find(f => getFilePath(f) === filePath);
|
|
46
|
+
if (!file) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const lines: string[] = [];
|
|
51
|
+
for (const hunk of file.hunks) {
|
|
52
|
+
for (const line of hunk.lines) {
|
|
53
|
+
const lineNum = side === 'old' ? line.oldLineNumber : line.newLineNumber;
|
|
54
|
+
if (lineNum !== null && lineNum >= startLine && lineNum <= endLine) {
|
|
55
|
+
const prefix = line.type === 'add' ? '+' : line.type === 'delete' ? '-' : ' ';
|
|
56
|
+
lines.push(`${prefix} ${line.content}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return lines;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatThreadsForCopy(threads: CommentThread[], diff?: ParsedDiff, diffRef?: string): string {
|
|
65
|
+
const unresolvedThreads = threads.filter(t => !isThreadResolved(t));
|
|
66
|
+
if (unresolvedThreads.length === 0) {
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const parts: string[] = [];
|
|
71
|
+
|
|
72
|
+
if (diffRef) {
|
|
73
|
+
parts.push(`Diff ref: ${diffRef}`);
|
|
74
|
+
parts.push('');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const thread of unresolvedThreads) {
|
|
78
|
+
if (thread.filePath === GENERAL_THREAD_FILE_PATH) {
|
|
79
|
+
parts.push('## General comment');
|
|
80
|
+
} else {
|
|
81
|
+
const lineRange = thread.startLine === thread.endLine
|
|
82
|
+
? `${thread.startLine}`
|
|
83
|
+
: `${thread.startLine}-${thread.endLine}`;
|
|
84
|
+
const sideDesc = thread.side === 'old' ? 'before change' : 'after change';
|
|
85
|
+
parts.push(`## ${thread.filePath}:${lineRange} (${sideDesc})`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const codeLines = extractCodeContext(diff, thread.filePath, thread.side, thread.startLine, thread.endLine);
|
|
89
|
+
if (codeLines.length > 0) {
|
|
90
|
+
parts.push('```diff');
|
|
91
|
+
parts.push(...codeLines);
|
|
92
|
+
parts.push('```');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const uniqueAuthors = new Set(thread.comments.map(c => c.author.name));
|
|
96
|
+
const singleAuthor = uniqueAuthors.size === 1;
|
|
97
|
+
|
|
98
|
+
for (const comment of thread.comments) {
|
|
99
|
+
if (singleAuthor) {
|
|
100
|
+
parts.push(comment.body);
|
|
101
|
+
} else {
|
|
102
|
+
const authorName = comment.author.name === 'You' ? 'User' : comment.author.name;
|
|
103
|
+
parts.push(`**${authorName}:** ${comment.body}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
parts.push('');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return parts.join('\n');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function Toolbar(props: ToolbarProps) {
|
|
113
|
+
const {
|
|
114
|
+
viewMode,
|
|
115
|
+
onViewModeChange,
|
|
116
|
+
hideWhitespace,
|
|
117
|
+
onHideWhitespaceChange,
|
|
118
|
+
theme,
|
|
119
|
+
onToggleTheme,
|
|
120
|
+
onShowHelp,
|
|
121
|
+
diff,
|
|
122
|
+
diffRef,
|
|
123
|
+
threads,
|
|
124
|
+
onDeleteAllComments,
|
|
125
|
+
onScrollToThread,
|
|
126
|
+
} = props;
|
|
127
|
+
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
128
|
+
const { copied, copy } = useCopy();
|
|
129
|
+
const { currentIndex, count: unresolvedCount, goToPrevious, goToNext } = useThreadNavigation(threads, onScrollToThread);
|
|
130
|
+
|
|
131
|
+
const baseBtn = 'px-2.5 py-1 text-xs text-text-secondary transition-colors duration-150 cursor-pointer border';
|
|
132
|
+
const activeBtn = 'bg-accent text-white border-accent';
|
|
133
|
+
const inactiveBtn = 'bg-bg hover:bg-hover hover:text-text border-border';
|
|
134
|
+
|
|
135
|
+
const iconBtn = 'p-1.5 rounded-md text-text-muted hover:text-text hover:bg-hover transition-colors cursor-pointer';
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className="flex items-center gap-3 px-4 py-1.5 bg-bg-secondary border-b border-border font-sans text-xs">
|
|
139
|
+
<div className="flex items-center">
|
|
140
|
+
<button
|
|
141
|
+
className={cn(baseBtn, 'flex items-center gap-1.5 rounded-l-md', viewMode === 'unified' ? `${activeBtn} z-10` : inactiveBtn)}
|
|
142
|
+
onClick={() => onViewModeChange('unified')}
|
|
143
|
+
title="Unified view (u)"
|
|
144
|
+
>
|
|
145
|
+
<UnifiedViewIcon className="w-3.5 h-3.5" />
|
|
146
|
+
Unified
|
|
147
|
+
</button>
|
|
148
|
+
<button
|
|
149
|
+
className={cn(baseBtn, 'flex items-center gap-1.5 rounded-r-md -ml-px', viewMode === 'split' ? `${activeBtn} z-10` : inactiveBtn)}
|
|
150
|
+
onClick={() => onViewModeChange('split')}
|
|
151
|
+
title="Split view (s)"
|
|
152
|
+
>
|
|
153
|
+
<SplitViewIcon className="w-3.5 h-3.5" />
|
|
154
|
+
Split
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
<button
|
|
158
|
+
className={cn(iconBtn, 'flex items-center gap-1.5 text-xs', hideWhitespace && 'text-accent')}
|
|
159
|
+
onClick={() => onHideWhitespaceChange(!hideWhitespace)}
|
|
160
|
+
title={hideWhitespace ? 'Show whitespace' : 'Hide whitespace'}
|
|
161
|
+
>
|
|
162
|
+
{hideWhitespace ? <EyeOffIcon className="w-3.5 h-3.5" /> : <EyeIcon className="w-3.5 h-3.5" />}
|
|
163
|
+
<span className="text-text-secondary">Whitespace</span>
|
|
164
|
+
</button>
|
|
165
|
+
<div className="flex items-center gap-0.5">
|
|
166
|
+
<button
|
|
167
|
+
className={iconBtn}
|
|
168
|
+
onClick={onToggleTheme}
|
|
169
|
+
title={theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}
|
|
170
|
+
>
|
|
171
|
+
{theme === 'light' ? <MoonIcon className="w-3.5 h-3.5" /> : <SunIcon className="w-3.5 h-3.5" />}
|
|
172
|
+
</button>
|
|
173
|
+
<button
|
|
174
|
+
className={iconBtn}
|
|
175
|
+
onClick={onShowHelp}
|
|
176
|
+
title="Keyboard shortcuts (?)"
|
|
177
|
+
>
|
|
178
|
+
<KeyboardIcon className="w-3.5 h-3.5" />
|
|
179
|
+
</button>
|
|
180
|
+
</div>
|
|
181
|
+
{unresolvedCount > 0 && (
|
|
182
|
+
<div className="flex items-center gap-2 ml-auto">
|
|
183
|
+
<div className="flex items-stretch border border-border rounded-md overflow-hidden bg-bg">
|
|
184
|
+
<span className="flex items-center text-xs text-text-muted px-2 py-1">
|
|
185
|
+
{currentIndex >= 0
|
|
186
|
+
? `${currentIndex + 1} of ${unresolvedCount} ${unresolvedCount === 1 ? 'comment' : 'comments'}`
|
|
187
|
+
: `${unresolvedCount} ${unresolvedCount === 1 ? 'comment' : 'comments'}`}
|
|
188
|
+
</span>
|
|
189
|
+
<button
|
|
190
|
+
onClick={goToPrevious}
|
|
191
|
+
className="flex items-center px-1.5 border-l border-border text-text-muted hover:bg-hover hover:text-text transition-colors cursor-pointer"
|
|
192
|
+
title="Previous comment"
|
|
193
|
+
>
|
|
194
|
+
<ChevronUpIcon className="w-3.5 h-3.5" />
|
|
195
|
+
</button>
|
|
196
|
+
<button
|
|
197
|
+
onClick={goToNext}
|
|
198
|
+
className="flex items-center px-1.5 border-l border-border text-text-muted hover:bg-hover hover:text-text transition-colors cursor-pointer"
|
|
199
|
+
title="Next comment"
|
|
200
|
+
>
|
|
201
|
+
<ChevronDownIcon className="w-3.5 h-3.5" />
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
<div className="flex items-stretch border border-border rounded-md overflow-hidden">
|
|
205
|
+
<button
|
|
206
|
+
onClick={() => copy(formatThreadsForCopy(threads, diff, diffRef))}
|
|
207
|
+
className="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-bg text-text-secondary hover:bg-hover hover:text-text transition-colors cursor-pointer"
|
|
208
|
+
title="Copy unresolved comments to clipboard"
|
|
209
|
+
>
|
|
210
|
+
{copied ? (
|
|
211
|
+
<>
|
|
212
|
+
<CheckIcon className="w-3 h-3 text-added" />
|
|
213
|
+
Copied
|
|
214
|
+
</>
|
|
215
|
+
) : (
|
|
216
|
+
<>
|
|
217
|
+
<CopyIcon className="w-3 h-3" />
|
|
218
|
+
Copy Comments
|
|
219
|
+
</>
|
|
220
|
+
)}
|
|
221
|
+
</button>
|
|
222
|
+
<button
|
|
223
|
+
onClick={() => setShowDeleteConfirm(true)}
|
|
224
|
+
className="flex items-center px-2 border-l border-border bg-bg text-text-muted hover:bg-hover hover:text-red-500 transition-colors cursor-pointer"
|
|
225
|
+
title="Delete all comments"
|
|
226
|
+
>
|
|
227
|
+
<TrashIcon className="w-3.5 h-3.5" />
|
|
228
|
+
</button>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
{showDeleteConfirm && (
|
|
233
|
+
<ConfirmDialog
|
|
234
|
+
title="Delete all comments"
|
|
235
|
+
message="Are you sure you want to delete all comments? This action cannot be undone."
|
|
236
|
+
confirmLabel="Delete all"
|
|
237
|
+
onConfirm={() => {
|
|
238
|
+
onDeleteAllComments();
|
|
239
|
+
setShowDeleteConfirm(false);
|
|
240
|
+
}}
|
|
241
|
+
onCancel={() => setShowDeleteConfirm(false)}
|
|
242
|
+
/>
|
|
243
|
+
)}
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { cn } from '../../lib/cn';
|
|
3
|
+
|
|
4
|
+
interface BadgeProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Badge(props: BadgeProps) {
|
|
10
|
+
const { children, className } = props;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<span className={cn('text-xs px-1.5 py-px rounded font-semibold shrink-0', className)}>
|
|
14
|
+
{children}
|
|
15
|
+
</span>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
interface ConfirmDialogProps {
|
|
4
|
+
title: string;
|
|
5
|
+
message: string;
|
|
6
|
+
confirmLabel?: string;
|
|
7
|
+
onConfirm: () => void;
|
|
8
|
+
onCancel: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ConfirmDialog(props: ConfirmDialogProps) {
|
|
12
|
+
const { title, message, confirmLabel = 'Undo', onConfirm, onCancel } = props;
|
|
13
|
+
const cancelRef = useRef<HTMLButtonElement>(null);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
cancelRef.current?.focus();
|
|
17
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
18
|
+
if (e.key === 'Escape') {
|
|
19
|
+
onCancel();
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
window.addEventListener('keydown', handleKey);
|
|
23
|
+
return () => window.removeEventListener('keydown', handleKey);
|
|
24
|
+
}, [onCancel]);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onCancel}>
|
|
28
|
+
<div
|
|
29
|
+
className="bg-bg border border-border rounded-xl shadow-lg p-5 max-w-sm w-full mx-4"
|
|
30
|
+
onClick={(e) => e.stopPropagation()}
|
|
31
|
+
>
|
|
32
|
+
<h3 className="text-sm font-semibold text-text mb-1.5">{title}</h3>
|
|
33
|
+
<p className="text-xs text-text-muted mb-4 leading-relaxed">{message}</p>
|
|
34
|
+
<div className="flex justify-end gap-2">
|
|
35
|
+
<button
|
|
36
|
+
ref={cancelRef}
|
|
37
|
+
onClick={onCancel}
|
|
38
|
+
className="px-3 py-1.5 text-xs rounded-md border border-border text-text hover:bg-hover cursor-pointer"
|
|
39
|
+
>
|
|
40
|
+
Cancel
|
|
41
|
+
</button>
|
|
42
|
+
<button
|
|
43
|
+
onClick={onConfirm}
|
|
44
|
+
className="px-3 py-1.5 text-xs rounded-md bg-deleted text-white hover:opacity-90 cursor-pointer"
|
|
45
|
+
>
|
|
46
|
+
{confirmLabel}
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReactNode, ButtonHTMLAttributes } from 'react';
|
|
2
|
+
import { cn } from '../../lib/cn';
|
|
3
|
+
|
|
4
|
+
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function IconButton(props: IconButtonProps) {
|
|
10
|
+
const { children, className, ...rest } = props;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<button
|
|
14
|
+
className={cn(
|
|
15
|
+
'flex items-center justify-center rounded text-text-muted hover:bg-hover hover:text-text cursor-pointer',
|
|
16
|
+
className
|
|
17
|
+
)}
|
|
18
|
+
{...rest}
|
|
19
|
+
>
|
|
20
|
+
{children}
|
|
21
|
+
</button>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { cn } from '../../lib/cn';
|
|
2
|
+
import { getStatusColor } from '../../lib/diff-utils';
|
|
3
|
+
import { Badge } from './badge';
|
|
4
|
+
|
|
5
|
+
interface StatusBadgeProps {
|
|
6
|
+
status: string;
|
|
7
|
+
compact?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function getCompactLabel(status: string): string {
|
|
11
|
+
switch (status) {
|
|
12
|
+
case 'added':
|
|
13
|
+
return 'A';
|
|
14
|
+
case 'deleted':
|
|
15
|
+
return 'D';
|
|
16
|
+
case 'renamed':
|
|
17
|
+
return 'R';
|
|
18
|
+
case 'copied':
|
|
19
|
+
return 'C';
|
|
20
|
+
default:
|
|
21
|
+
return 'M';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getFullLabel(status: string): string {
|
|
26
|
+
switch (status) {
|
|
27
|
+
case 'added':
|
|
28
|
+
return 'Added';
|
|
29
|
+
case 'deleted':
|
|
30
|
+
return 'Deleted';
|
|
31
|
+
case 'renamed':
|
|
32
|
+
return 'Renamed';
|
|
33
|
+
case 'copied':
|
|
34
|
+
return 'Copied';
|
|
35
|
+
default:
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function StatusBadge(props: StatusBadgeProps) {
|
|
41
|
+
const { status, compact } = props;
|
|
42
|
+
|
|
43
|
+
if (compact) {
|
|
44
|
+
return (
|
|
45
|
+
<span className={cn('shrink-0 w-[18px] h-[18px] inline-flex items-center justify-center rounded text-[11px] font-bold font-mono', getStatusColor(status))}>
|
|
46
|
+
{getCompactLabel(status)}
|
|
47
|
+
</span>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const label = getFullLabel(status);
|
|
52
|
+
if (!label) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return <Badge className={getStatusColor(status)}>{label}</Badge>;
|
|
57
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { cn } from '../../lib/cn';
|
|
2
|
+
|
|
3
|
+
type ThreadBadgeVariant = 'resolved' | 'dismissed' | 'outdated';
|
|
4
|
+
|
|
5
|
+
interface ThreadBadgeProps {
|
|
6
|
+
variant: ThreadBadgeVariant;
|
|
7
|
+
children?: React.ReactNode;
|
|
8
|
+
size?: 'sm' | 'default';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const variantStyles: Record<ThreadBadgeVariant, string> = {
|
|
12
|
+
resolved: 'bg-added/20 text-added',
|
|
13
|
+
dismissed: 'bg-text-muted/20 text-text-muted line-through',
|
|
14
|
+
outdated: 'bg-orange-100 text-orange-800 dark:bg-orange-900/50 dark:text-orange-200',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const defaultLabels: Record<ThreadBadgeVariant, string> = {
|
|
18
|
+
resolved: 'Resolved',
|
|
19
|
+
dismissed: 'Dismissed',
|
|
20
|
+
outdated: 'Outdated',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function ThreadBadge(props: ThreadBadgeProps) {
|
|
24
|
+
const { variant, children, size = 'default' } = props;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<span className={cn(
|
|
28
|
+
'rounded-full font-medium',
|
|
29
|
+
size === 'sm' ? 'px-1 py-0.5 text-[9px]' : 'px-1.5 py-0.5 text-[10px]',
|
|
30
|
+
variantStyles[variant],
|
|
31
|
+
)}>
|
|
32
|
+
{children ?? defaultLabels[variant]}
|
|
33
|
+
</span>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { DiffLine } from '@diffity/parser';
|
|
2
|
+
import type { SyntaxToken } from '../lib/syntax-token';
|
|
3
|
+
|
|
4
|
+
interface WordDiffProps {
|
|
5
|
+
line: DiffLine;
|
|
6
|
+
syntaxTokens?: SyntaxToken[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function applySyntaxToText(text: string, offset: number, syntaxTokens: SyntaxToken[]): React.ReactNode[] {
|
|
10
|
+
const nodes: React.ReactNode[] = [];
|
|
11
|
+
let textPos = 0;
|
|
12
|
+
let tokenIdx = 0;
|
|
13
|
+
let tokenCharPos = 0;
|
|
14
|
+
|
|
15
|
+
for (let i = 0; i < syntaxTokens.length; i++) {
|
|
16
|
+
tokenCharPos += syntaxTokens[i].text.length;
|
|
17
|
+
if (tokenCharPos > offset) {
|
|
18
|
+
tokenIdx = i;
|
|
19
|
+
tokenCharPos = tokenCharPos - syntaxTokens[i].text.length;
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
while (textPos < text.length && tokenIdx < syntaxTokens.length) {
|
|
25
|
+
const token = syntaxTokens[tokenIdx];
|
|
26
|
+
const tokenStart = tokenCharPos;
|
|
27
|
+
const tokenEnd = tokenStart + token.text.length;
|
|
28
|
+
const overlapStart = Math.max(offset + textPos, tokenStart);
|
|
29
|
+
const overlapEnd = Math.min(offset + text.length, tokenEnd);
|
|
30
|
+
|
|
31
|
+
if (overlapStart >= overlapEnd) {
|
|
32
|
+
tokenIdx++;
|
|
33
|
+
tokenCharPos = tokenEnd;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const sliceStart = overlapStart - offset;
|
|
38
|
+
const sliceEnd = overlapEnd - offset;
|
|
39
|
+
const slice = text.slice(sliceStart, sliceEnd);
|
|
40
|
+
|
|
41
|
+
if (sliceStart > textPos) {
|
|
42
|
+
nodes.push(<span key={`gap-${textPos}`}>{text.slice(textPos, sliceStart)}</span>);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
nodes.push(
|
|
46
|
+
<span key={`${tokenIdx}-${sliceStart}`} style={token.color ? { color: token.color } : undefined}>
|
|
47
|
+
{slice}
|
|
48
|
+
</span>
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
textPos = sliceEnd;
|
|
52
|
+
|
|
53
|
+
if (overlapEnd >= tokenEnd) {
|
|
54
|
+
tokenIdx++;
|
|
55
|
+
tokenCharPos = tokenEnd;
|
|
56
|
+
} else {
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (textPos < text.length) {
|
|
62
|
+
nodes.push(<span key={`rest-${textPos}`}>{text.slice(textPos)}</span>);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return nodes;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function WordDiff(props: WordDiffProps) {
|
|
69
|
+
const { line, syntaxTokens } = props;
|
|
70
|
+
|
|
71
|
+
if (!line.wordDiff || line.wordDiff.length === 0) {
|
|
72
|
+
return <span>{line.content || '\n'}</span>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let charOffset = 0;
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<>
|
|
79
|
+
{line.wordDiff.map((seg, i) => {
|
|
80
|
+
const segOffset = charOffset;
|
|
81
|
+
|
|
82
|
+
if (seg.type === 'equal') {
|
|
83
|
+
charOffset += seg.text.length;
|
|
84
|
+
if (syntaxTokens && syntaxTokens.length > 0) {
|
|
85
|
+
return <span key={i}>{applySyntaxToText(seg.text, segOffset, syntaxTokens)}</span>;
|
|
86
|
+
}
|
|
87
|
+
return <span key={i}>{seg.text}</span>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (seg.type === 'delete' && line.type === 'delete') {
|
|
91
|
+
charOffset += seg.text.length;
|
|
92
|
+
if (syntaxTokens && syntaxTokens.length > 0) {
|
|
93
|
+
return (
|
|
94
|
+
<span key={i} className="bg-diff-del-word rounded-sm">
|
|
95
|
+
{applySyntaxToText(seg.text, segOffset, syntaxTokens)}
|
|
96
|
+
</span>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return (
|
|
100
|
+
<span key={i} className="bg-diff-del-word rounded-sm">
|
|
101
|
+
{seg.text}
|
|
102
|
+
</span>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (seg.type === 'insert' && line.type === 'add') {
|
|
107
|
+
charOffset += seg.text.length;
|
|
108
|
+
if (syntaxTokens && syntaxTokens.length > 0) {
|
|
109
|
+
return (
|
|
110
|
+
<span key={i} className="bg-diff-add-word rounded-sm">
|
|
111
|
+
{applySyntaxToText(seg.text, segOffset, syntaxTokens)}
|
|
112
|
+
</span>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return (
|
|
116
|
+
<span key={i} className="bg-diff-add-word rounded-sm">
|
|
117
|
+
{seg.text}
|
|
118
|
+
</span>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return null;
|
|
123
|
+
})}
|
|
124
|
+
</>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useCallback } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import type { CommentAuthor, CommentSide } from '../types/comment';
|
|
4
|
+
import * as api from '../lib/api';
|
|
5
|
+
|
|
6
|
+
export function useCommentActions(sessionId: string | null, enabled: boolean) {
|
|
7
|
+
const queryClient = useQueryClient();
|
|
8
|
+
|
|
9
|
+
const invalidateThreads = useCallback(() => {
|
|
10
|
+
queryClient.invalidateQueries({ queryKey: ['threads', sessionId] });
|
|
11
|
+
}, [queryClient, sessionId]);
|
|
12
|
+
|
|
13
|
+
const addThread = useCallback((filePath: string, side: CommentSide, startLine: number, endLine: number, body: string, author: CommentAuthor, anchorContent?: string) => {
|
|
14
|
+
if (!enabled || !sessionId) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
api.createThread({ sessionId, filePath, side, startLine, endLine, body, author, anchorContent }).then(() => {
|
|
18
|
+
invalidateThreads();
|
|
19
|
+
});
|
|
20
|
+
}, [enabled, sessionId, invalidateThreads]);
|
|
21
|
+
|
|
22
|
+
const addReply = useCallback((threadId: string, body: string, author: CommentAuthor) => {
|
|
23
|
+
if (!enabled) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
api.replyToThread(threadId, body, author).then(() => {
|
|
27
|
+
invalidateThreads();
|
|
28
|
+
});
|
|
29
|
+
}, [enabled, invalidateThreads]);
|
|
30
|
+
|
|
31
|
+
const resolveThread = useCallback((threadId: string) => {
|
|
32
|
+
if (!enabled) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
api.updateThreadStatus(threadId, 'resolved').then(() => {
|
|
36
|
+
invalidateThreads();
|
|
37
|
+
});
|
|
38
|
+
}, [enabled, invalidateThreads]);
|
|
39
|
+
|
|
40
|
+
const unresolveThread = useCallback((threadId: string) => {
|
|
41
|
+
if (!enabled) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
api.updateThreadStatus(threadId, 'open').then(() => {
|
|
45
|
+
invalidateThreads();
|
|
46
|
+
});
|
|
47
|
+
}, [enabled, invalidateThreads]);
|
|
48
|
+
|
|
49
|
+
const dismissThread = useCallback((threadId: string) => {
|
|
50
|
+
if (!enabled) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
api.updateThreadStatus(threadId, 'dismissed').then(() => {
|
|
54
|
+
invalidateThreads();
|
|
55
|
+
});
|
|
56
|
+
}, [enabled, invalidateThreads]);
|
|
57
|
+
|
|
58
|
+
const deleteComment = useCallback((threadId: string, commentId: string) => {
|
|
59
|
+
if (!enabled) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
api.deleteComment(commentId).then(() => {
|
|
63
|
+
invalidateThreads();
|
|
64
|
+
});
|
|
65
|
+
}, [enabled, invalidateThreads]);
|
|
66
|
+
|
|
67
|
+
const deleteThread = useCallback((threadId: string) => {
|
|
68
|
+
if (!enabled) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
api.deleteThread(threadId).then(() => {
|
|
72
|
+
invalidateThreads();
|
|
73
|
+
});
|
|
74
|
+
}, [enabled, invalidateThreads]);
|
|
75
|
+
|
|
76
|
+
const deleteAllThreads = useCallback(() => {
|
|
77
|
+
if (!enabled || !sessionId) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
api.deleteAllThreads(sessionId).then(() => {
|
|
81
|
+
invalidateThreads();
|
|
82
|
+
});
|
|
83
|
+
}, [enabled, sessionId, invalidateThreads]);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
addThread,
|
|
87
|
+
addReply,
|
|
88
|
+
resolveThread,
|
|
89
|
+
unresolveThread,
|
|
90
|
+
dismissThread,
|
|
91
|
+
deleteComment,
|
|
92
|
+
deleteThread,
|
|
93
|
+
deleteAllThreads,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type CommentActions = ReturnType<typeof useCommentActions>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query';
|
|
2
|
+
import { commitsOptions } from '../queries/commits';
|
|
3
|
+
|
|
4
|
+
export function useCommits() {
|
|
5
|
+
const { data, isLoading, error } = useQuery(commitsOptions());
|
|
6
|
+
|
|
7
|
+
return {
|
|
8
|
+
data: data ?? null,
|
|
9
|
+
loading: isLoading,
|
|
10
|
+
error: error?.message ?? null,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
export function useCopy(resetDelay = 2000) {
|
|
4
|
+
const [copied, setCopied] = useState(false);
|
|
5
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
6
|
+
|
|
7
|
+
const copy = useCallback((text: string) => {
|
|
8
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
9
|
+
setCopied(true);
|
|
10
|
+
if (timeoutRef.current) {
|
|
11
|
+
clearTimeout(timeoutRef.current);
|
|
12
|
+
}
|
|
13
|
+
timeoutRef.current = setTimeout(() => setCopied(false), resetDelay);
|
|
14
|
+
});
|
|
15
|
+
}, [resetDelay]);
|
|
16
|
+
|
|
17
|
+
return { copied, copy };
|
|
18
|
+
}
|