newpr 0.1.3 → 0.2.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/package.json +11 -1
- package/src/analyzer/pipeline.ts +22 -5
- package/src/github/fetch-pr.ts +43 -1
- package/src/history/store.ts +106 -1
- package/src/llm/client.ts +197 -0
- package/src/llm/prompts.ts +33 -8
- package/src/tui/Shell.tsx +7 -2
- package/src/types/github.ts +11 -0
- package/src/types/output.ts +44 -0
- package/src/web/client/App.tsx +29 -3
- package/src/web/client/components/AppShell.tsx +94 -47
- package/src/web/client/components/ChatSection.tsx +427 -0
- package/src/web/client/components/DetailPane.tsx +163 -75
- package/src/web/client/components/DiffViewer.tsx +679 -0
- package/src/web/client/components/InputScreen.tsx +110 -26
- package/src/web/client/components/Markdown.tsx +169 -43
- package/src/web/client/components/ResultsScreen.tsx +66 -71
- package/src/web/client/components/TipTapEditor.tsx +405 -0
- package/src/web/client/hooks/useAnalysis.ts +8 -1
- package/src/web/client/lib/shiki.ts +63 -0
- package/src/web/client/panels/CartoonPanel.tsx +94 -37
- package/src/web/client/panels/DiscussionPanel.tsx +158 -0
- package/src/web/client/panels/FilesPanel.tsx +435 -54
- package/src/web/client/panels/GroupsPanel.tsx +49 -40
- package/src/web/client/panels/StoryPanel.tsx +42 -22
- package/src/web/components/ui/tabs.tsx +3 -3
- package/src/web/server/routes.ts +716 -14
- package/src/web/server/session-manager.ts +11 -2
- package/src/web/server.ts +33 -0
- package/src/web/styles/built.css +1 -1
- package/src/web/styles/globals.css +117 -1
- package/src/web/client/panels/NarrativePanel.tsx +0 -9
- package/src/web/client/panels/SummaryPanel.tsx +0 -20
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo, useRef, useCallback, type ReactNode } from "react";
|
|
2
|
+
import { type Highlighter, type ThemedToken } from "shiki";
|
|
3
|
+
import { MessageSquare, Trash2, ExternalLink, CornerDownLeft, Pencil, Check, X } from "lucide-react";
|
|
4
|
+
import { ensureHighlighter, getHighlighterSync, detectShikiLang, type ShikiLang } from "../lib/shiki.ts";
|
|
5
|
+
import type { DiffComment } from "../../../types/output.ts";
|
|
6
|
+
import { TipTapEditor } from "./TipTapEditor.tsx";
|
|
7
|
+
|
|
8
|
+
interface DiffLine {
|
|
9
|
+
type: "header" | "hunk" | "added" | "removed" | "context" | "binary";
|
|
10
|
+
content: string;
|
|
11
|
+
oldNum: number | null;
|
|
12
|
+
newNum: number | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const HUNK_RE = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
|
|
16
|
+
const RENDER_CAP = 2000;
|
|
17
|
+
const TOTAL_CAP = 3000;
|
|
18
|
+
|
|
19
|
+
function parseLines(patch: string): DiffLine[] {
|
|
20
|
+
const raw = patch.split("\n");
|
|
21
|
+
const lines: DiffLine[] = [];
|
|
22
|
+
let oldNum = 0;
|
|
23
|
+
let newNum = 0;
|
|
24
|
+
|
|
25
|
+
if (raw.some((l) => l.startsWith("Binary files") || l.includes("GIT binary patch"))) {
|
|
26
|
+
return [{ type: "binary", content: "Binary file — cannot display diff", oldNum: null, newNum: null }];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const line of raw) {
|
|
30
|
+
if (
|
|
31
|
+
line.startsWith("diff --git") ||
|
|
32
|
+
line.startsWith("index ") ||
|
|
33
|
+
line.startsWith("--- ") ||
|
|
34
|
+
line.startsWith("+++ ") ||
|
|
35
|
+
line.startsWith("old mode") ||
|
|
36
|
+
line.startsWith("new mode") ||
|
|
37
|
+
line.startsWith("new file mode") ||
|
|
38
|
+
line.startsWith("deleted file mode") ||
|
|
39
|
+
line.startsWith("rename from") ||
|
|
40
|
+
line.startsWith("rename to") ||
|
|
41
|
+
line.startsWith("similarity index")
|
|
42
|
+
) {
|
|
43
|
+
lines.push({ type: "header", content: line, oldNum: null, newNum: null });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const hunkMatch = line.match(HUNK_RE);
|
|
48
|
+
if (hunkMatch) {
|
|
49
|
+
oldNum = Number(hunkMatch[1]);
|
|
50
|
+
newNum = Number(hunkMatch[2]);
|
|
51
|
+
lines.push({ type: "hunk", content: line, oldNum: null, newNum: null });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (line.startsWith("+")) {
|
|
56
|
+
lines.push({ type: "added", content: line.slice(1), oldNum: null, newNum: newNum });
|
|
57
|
+
newNum++;
|
|
58
|
+
} else if (line.startsWith("-")) {
|
|
59
|
+
lines.push({ type: "removed", content: line.slice(1), oldNum: oldNum, newNum: null });
|
|
60
|
+
oldNum++;
|
|
61
|
+
} else if (line.startsWith("\\")) {
|
|
62
|
+
lines.push({ type: "context", content: line, oldNum: null, newNum: null });
|
|
63
|
+
} else {
|
|
64
|
+
const text = line.startsWith(" ") ? line.slice(1) : line;
|
|
65
|
+
if (oldNum > 0 || newNum > 0) {
|
|
66
|
+
lines.push({ type: "context", content: text, oldNum: oldNum, newNum: newNum });
|
|
67
|
+
oldNum++;
|
|
68
|
+
newNum++;
|
|
69
|
+
} else {
|
|
70
|
+
lines.push({ type: "context", content: text, oldNum: null, newNum: null });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return lines;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function useHighlighter(): Highlighter | null {
|
|
79
|
+
const [hl, setHl] = useState<Highlighter | null>(getHighlighterSync());
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!hl) ensureHighlighter().then(setHl).catch(() => {});
|
|
82
|
+
}, [hl]);
|
|
83
|
+
return hl;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function useDarkMode(): boolean {
|
|
87
|
+
const [dark, setDark] = useState(() => document.documentElement.classList.contains("dark"));
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const observer = new MutationObserver(() => {
|
|
90
|
+
setDark(document.documentElement.classList.contains("dark"));
|
|
91
|
+
});
|
|
92
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
|
93
|
+
return () => observer.disconnect();
|
|
94
|
+
}, []);
|
|
95
|
+
return dark;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type TokenMap = Map<number, ThemedToken[]>;
|
|
99
|
+
|
|
100
|
+
function useTokenizedLines(
|
|
101
|
+
hl: Highlighter | null,
|
|
102
|
+
lines: DiffLine[],
|
|
103
|
+
lang: ShikiLang | null,
|
|
104
|
+
dark: boolean,
|
|
105
|
+
): TokenMap | null {
|
|
106
|
+
return useMemo(() => {
|
|
107
|
+
if (!hl || !lang) return null;
|
|
108
|
+
|
|
109
|
+
const codeIndices: number[] = [];
|
|
110
|
+
const codeLines: string[] = [];
|
|
111
|
+
for (let i = 0; i < lines.length; i++) {
|
|
112
|
+
const t = lines[i]!.type;
|
|
113
|
+
if (t === "added" || t === "removed" || t === "context") {
|
|
114
|
+
codeIndices.push(i);
|
|
115
|
+
codeLines.push(lines[i]!.content);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (codeLines.length === 0) return null;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const theme = dark ? "github-dark" : "github-light";
|
|
123
|
+
const result = hl.codeToTokens(codeLines.join("\n"), { lang, theme });
|
|
124
|
+
const map: TokenMap = new Map();
|
|
125
|
+
for (let j = 0; j < codeIndices.length; j++) {
|
|
126
|
+
const tokens = result.tokens[j];
|
|
127
|
+
if (tokens) map.set(codeIndices[j]!, tokens);
|
|
128
|
+
}
|
|
129
|
+
return map;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}, [hl, lines, lang, dark]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function renderHighlighted(tokens: ThemedToken[]): ReactNode {
|
|
137
|
+
return tokens.map((t, i) => (
|
|
138
|
+
<span key={i} style={t.color ? { color: t.color } : undefined}>{t.content}</span>
|
|
139
|
+
));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const ROW_STYLE: Record<DiffLine["type"], string> = {
|
|
143
|
+
header: "text-muted-foreground",
|
|
144
|
+
hunk: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
|
145
|
+
added: "bg-green-500/10",
|
|
146
|
+
removed: "bg-red-500/10",
|
|
147
|
+
context: "",
|
|
148
|
+
binary: "text-muted-foreground italic py-4 text-center",
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const GUTTER_STYLE: Record<string, string> = {
|
|
152
|
+
added: "bg-green-500/15 text-green-600/60 dark:text-green-400/60",
|
|
153
|
+
removed: "bg-red-500/15 text-red-600/60 dark:text-red-400/60",
|
|
154
|
+
default: "text-muted-foreground/40",
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const PREFIX_STYLE: Record<string, string> = {
|
|
158
|
+
added: "text-green-700 dark:text-green-300 select-none",
|
|
159
|
+
removed: "text-red-700 dark:text-red-300 select-none",
|
|
160
|
+
context: "text-transparent select-none",
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
let cachedUser: { login: string; avatar_url: string } | null = null;
|
|
164
|
+
async function getCurrentUser(): Promise<{ login: string; avatar_url: string } | null> {
|
|
165
|
+
if (cachedUser) return cachedUser;
|
|
166
|
+
try {
|
|
167
|
+
const res = await fetch("/api/me");
|
|
168
|
+
const data = await res.json() as Record<string, unknown>;
|
|
169
|
+
if (data.login) {
|
|
170
|
+
cachedUser = { login: data.login as string, avatar_url: data.avatar_url as string };
|
|
171
|
+
}
|
|
172
|
+
} catch {}
|
|
173
|
+
return cachedUser;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function formatTimeAgo(iso: string): string {
|
|
177
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
178
|
+
const minutes = Math.floor(diff / 60000);
|
|
179
|
+
if (minutes < 1) return "just now";
|
|
180
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
181
|
+
const hours = Math.floor(minutes / 60);
|
|
182
|
+
if (hours < 24) return `${hours}h ago`;
|
|
183
|
+
const days = Math.floor(hours / 24);
|
|
184
|
+
return `${days}d ago`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function commentKey(side: "old" | "new", line: number): string {
|
|
188
|
+
return `${side}:${line}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function lineKey(line: DiffLine): { side: "old" | "new"; num: number } | null {
|
|
192
|
+
if (line.type === "added" && line.newNum != null) return { side: "new", num: line.newNum };
|
|
193
|
+
if (line.type === "removed" && line.oldNum != null) return { side: "old", num: line.oldNum };
|
|
194
|
+
if (line.type === "context" && line.newNum != null) return { side: "new", num: line.newNum };
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function CommentCard({
|
|
199
|
+
comment,
|
|
200
|
+
currentLogin,
|
|
201
|
+
onEdit,
|
|
202
|
+
onDelete,
|
|
203
|
+
}: {
|
|
204
|
+
comment: DiffComment;
|
|
205
|
+
currentLogin: string | null;
|
|
206
|
+
onEdit: (id: string, body: string) => Promise<void>;
|
|
207
|
+
onDelete: (id: string) => void;
|
|
208
|
+
}) {
|
|
209
|
+
const [deleting, setDeleting] = useState(false);
|
|
210
|
+
const [editing, setEditing] = useState(false);
|
|
211
|
+
const [editBody, setEditBody] = useState(comment.body);
|
|
212
|
+
const [saving, setSaving] = useState(false);
|
|
213
|
+
const isOwn = currentLogin === comment.author;
|
|
214
|
+
|
|
215
|
+
const handleSave = useCallback(async () => {
|
|
216
|
+
const trimmed = editBody.trim();
|
|
217
|
+
if (!trimmed || saving || trimmed === comment.body) { setEditing(false); return; }
|
|
218
|
+
setSaving(true);
|
|
219
|
+
try {
|
|
220
|
+
await onEdit(comment.id, trimmed);
|
|
221
|
+
setEditing(false);
|
|
222
|
+
} finally {
|
|
223
|
+
setSaving(false);
|
|
224
|
+
}
|
|
225
|
+
}, [editBody, saving, comment.id, comment.body, onEdit]);
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<div className="group/comment px-3 py-2.5">
|
|
229
|
+
<div className="flex items-center gap-1.5 mb-1">
|
|
230
|
+
{comment.authorAvatar ? (
|
|
231
|
+
<img src={comment.authorAvatar} alt="" className="h-4 w-4 rounded-full shrink-0" />
|
|
232
|
+
) : (
|
|
233
|
+
<div className="h-4 w-4 rounded-full bg-muted-foreground/20 shrink-0" />
|
|
234
|
+
)}
|
|
235
|
+
<span className="text-[11px] font-medium text-foreground/90">{comment.author}</span>
|
|
236
|
+
<span className="text-[10px] text-muted-foreground/60">{formatTimeAgo(comment.createdAt)}</span>
|
|
237
|
+
{comment.startLine != null && comment.startLine !== comment.line && (
|
|
238
|
+
<span className="text-[10px] text-muted-foreground/40 font-mono">L{comment.startLine}-{comment.line}</span>
|
|
239
|
+
)}
|
|
240
|
+
{comment.githubUrl && (
|
|
241
|
+
<a href={comment.githubUrl} target="_blank" rel="noopener noreferrer" className="text-muted-foreground/40 hover:text-foreground/60 transition-colors">
|
|
242
|
+
<ExternalLink className="h-2.5 w-2.5" />
|
|
243
|
+
</a>
|
|
244
|
+
)}
|
|
245
|
+
{isOwn && !editing && (
|
|
246
|
+
<div className="ml-auto flex items-center gap-0.5 opacity-0 group-hover/comment:opacity-100 transition-opacity">
|
|
247
|
+
<button
|
|
248
|
+
type="button"
|
|
249
|
+
onClick={() => { setEditBody(comment.body); setEditing(true); }}
|
|
250
|
+
className="p-0.5 -m-0.5 rounded text-muted-foreground/40 hover:text-foreground/70"
|
|
251
|
+
>
|
|
252
|
+
<Pencil className="h-3 w-3" />
|
|
253
|
+
</button>
|
|
254
|
+
<button
|
|
255
|
+
type="button"
|
|
256
|
+
disabled={deleting}
|
|
257
|
+
onClick={() => { setDeleting(true); onDelete(comment.id); }}
|
|
258
|
+
className="p-0.5 -m-0.5 rounded text-muted-foreground/40 hover:text-red-500"
|
|
259
|
+
>
|
|
260
|
+
<Trash2 className="h-3 w-3" />
|
|
261
|
+
</button>
|
|
262
|
+
</div>
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
{editing ? (
|
|
266
|
+
<div className="pl-[22px]">
|
|
267
|
+
<div className="border rounded-md px-2 py-1.5 focus-within:border-foreground/20 min-h-[36px]">
|
|
268
|
+
<TipTapEditor
|
|
269
|
+
content={editBody}
|
|
270
|
+
onChange={setEditBody}
|
|
271
|
+
autoFocus
|
|
272
|
+
submitOnModEnter
|
|
273
|
+
onSubmit={handleSave}
|
|
274
|
+
onEscape={() => setEditing(false)}
|
|
275
|
+
/>
|
|
276
|
+
</div>
|
|
277
|
+
<div className="flex items-center justify-end gap-1.5 mt-1">
|
|
278
|
+
<button
|
|
279
|
+
type="button"
|
|
280
|
+
onClick={() => setEditing(false)}
|
|
281
|
+
className="p-1 rounded-md text-muted-foreground/50 hover:text-foreground/70 transition-colors"
|
|
282
|
+
>
|
|
283
|
+
<X className="h-3.5 w-3.5" />
|
|
284
|
+
</button>
|
|
285
|
+
<button
|
|
286
|
+
type="button"
|
|
287
|
+
onClick={handleSave}
|
|
288
|
+
disabled={!editBody.trim() || saving}
|
|
289
|
+
className={`p-1 rounded-md transition-colors ${editBody.trim() && !saving ? "text-foreground/80 hover:text-foreground" : "text-muted-foreground/30 cursor-not-allowed"}`}
|
|
290
|
+
>
|
|
291
|
+
<Check className="h-3.5 w-3.5" />
|
|
292
|
+
</button>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
) : (
|
|
296
|
+
<p className="text-[12px] text-foreground/80 whitespace-pre-wrap break-words leading-[1.6] pl-[22px]">{comment.body}</p>
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function CommentForm({
|
|
303
|
+
currentUser,
|
|
304
|
+
onSubmit,
|
|
305
|
+
onCancel,
|
|
306
|
+
}: {
|
|
307
|
+
currentUser: { login: string; avatar_url: string } | null;
|
|
308
|
+
onSubmit: (body: string) => Promise<void>;
|
|
309
|
+
onCancel: () => void;
|
|
310
|
+
}) {
|
|
311
|
+
const [body, setBody] = useState("");
|
|
312
|
+
const [submitting, setSubmitting] = useState(false);
|
|
313
|
+
|
|
314
|
+
const handleSubmit = useCallback(async () => {
|
|
315
|
+
const trimmed = body.trim();
|
|
316
|
+
if (!trimmed || submitting) return;
|
|
317
|
+
setSubmitting(true);
|
|
318
|
+
try {
|
|
319
|
+
await onSubmit(trimmed);
|
|
320
|
+
} finally {
|
|
321
|
+
setSubmitting(false);
|
|
322
|
+
}
|
|
323
|
+
}, [body, submitting, onSubmit]);
|
|
324
|
+
|
|
325
|
+
const hasContent = body.trim().length > 0;
|
|
326
|
+
const modKey = typeof navigator !== "undefined" && navigator.platform.includes("Mac") ? "\u2318" : "Ctrl";
|
|
327
|
+
|
|
328
|
+
return (
|
|
329
|
+
<div className="px-3 py-2.5">
|
|
330
|
+
<div className="rounded-lg border border-border/60 transition-colors focus-within:border-foreground/20 focus-within:shadow-sm">
|
|
331
|
+
<div className="flex items-start gap-2 p-2">
|
|
332
|
+
{currentUser?.avatar_url ? (
|
|
333
|
+
<img src={currentUser.avatar_url} alt="" className="h-5 w-5 rounded-full shrink-0 mt-0.5" />
|
|
334
|
+
) : (
|
|
335
|
+
<div className="h-5 w-5 rounded-full bg-muted-foreground/20 shrink-0 mt-0.5" />
|
|
336
|
+
)}
|
|
337
|
+
<div className="flex-1 min-h-[44px]">
|
|
338
|
+
<TipTapEditor
|
|
339
|
+
placeholder="Write a comment..."
|
|
340
|
+
autoFocus
|
|
341
|
+
submitOnModEnter
|
|
342
|
+
onSubmit={handleSubmit}
|
|
343
|
+
onChange={setBody}
|
|
344
|
+
onEscape={onCancel}
|
|
345
|
+
/>
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
<div className="flex items-center justify-end gap-2 px-2 pb-2">
|
|
349
|
+
<button
|
|
350
|
+
type="button"
|
|
351
|
+
onClick={onCancel}
|
|
352
|
+
className="text-[11px] text-muted-foreground/60 hover:text-foreground/80 px-2 py-1 rounded-md transition-colors"
|
|
353
|
+
>
|
|
354
|
+
Cancel
|
|
355
|
+
</button>
|
|
356
|
+
<button
|
|
357
|
+
type="button"
|
|
358
|
+
onClick={handleSubmit}
|
|
359
|
+
disabled={!hasContent || submitting}
|
|
360
|
+
className={`
|
|
361
|
+
text-[11px] font-medium px-3 py-1 rounded-md transition-all
|
|
362
|
+
${hasContent && !submitting
|
|
363
|
+
? "bg-foreground text-background hover:bg-foreground/90"
|
|
364
|
+
: "bg-muted text-muted-foreground/40 cursor-not-allowed"}
|
|
365
|
+
`}
|
|
366
|
+
>
|
|
367
|
+
{submitting ? "Posting..." : "Comment"}
|
|
368
|
+
</button>
|
|
369
|
+
<kbd className="hidden sm:flex items-center gap-0.5 text-[10px] text-muted-foreground/40 select-none">
|
|
370
|
+
{modKey}<CornerDownLeft className="h-2.5 w-2.5" />
|
|
371
|
+
</kbd>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function InlineComments({
|
|
379
|
+
comments,
|
|
380
|
+
currentUser,
|
|
381
|
+
onEdit,
|
|
382
|
+
onDelete,
|
|
383
|
+
formTarget,
|
|
384
|
+
onSubmit,
|
|
385
|
+
onCancel,
|
|
386
|
+
}: {
|
|
387
|
+
comments: DiffComment[];
|
|
388
|
+
currentUser: { login: string; avatar_url: string } | null;
|
|
389
|
+
onEdit: (id: string, body: string) => Promise<void>;
|
|
390
|
+
onDelete: (id: string) => void;
|
|
391
|
+
formTarget: boolean;
|
|
392
|
+
onSubmit: (body: string) => Promise<void>;
|
|
393
|
+
onCancel: () => void;
|
|
394
|
+
}) {
|
|
395
|
+
const hasComments = comments.length > 0;
|
|
396
|
+
if (!hasComments && !formTarget) return null;
|
|
397
|
+
|
|
398
|
+
return (
|
|
399
|
+
<div className="border-y border-border/30 bg-card/80 font-sans divide-y divide-border/20">
|
|
400
|
+
{comments.map((c) => (
|
|
401
|
+
<CommentCard key={c.id} comment={c} currentLogin={currentUser?.login ?? null} onEdit={onEdit} onDelete={onDelete} />
|
|
402
|
+
))}
|
|
403
|
+
{formTarget && <CommentForm currentUser={currentUser} onSubmit={onSubmit} onCancel={onCancel} />}
|
|
404
|
+
</div>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export function DiffViewer({
|
|
409
|
+
patch,
|
|
410
|
+
filePath,
|
|
411
|
+
sessionId,
|
|
412
|
+
githubUrl,
|
|
413
|
+
}: {
|
|
414
|
+
patch: string;
|
|
415
|
+
filePath: string;
|
|
416
|
+
sessionId?: string | null;
|
|
417
|
+
githubUrl?: string;
|
|
418
|
+
}) {
|
|
419
|
+
const [showAll, setShowAll] = useState(false);
|
|
420
|
+
const hl = useHighlighter();
|
|
421
|
+
const dark = useDarkMode();
|
|
422
|
+
const lang = useMemo(() => detectShikiLang(filePath), [filePath]);
|
|
423
|
+
const allLines = useMemo(() => parseLines(patch), [patch]);
|
|
424
|
+
const tokenMap = useTokenizedLines(hl, allLines, lang, dark);
|
|
425
|
+
const isCapped = !showAll && allLines.length > TOTAL_CAP;
|
|
426
|
+
const lines = isCapped ? allLines.slice(0, RENDER_CAP) : allLines;
|
|
427
|
+
const fileName = filePath.split("/").pop() ?? filePath;
|
|
428
|
+
|
|
429
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
430
|
+
const [visibleWidth, setVisibleWidth] = useState(0);
|
|
431
|
+
const [comments, setComments] = useState<DiffComment[]>([]);
|
|
432
|
+
const [currentUser, setCurrentUser] = useState<{ login: string; avatar_url: string } | null>(null);
|
|
433
|
+
const [formRange, setFormRange] = useState<{ side: "old" | "new"; startLine: number; endLine: number } | null>(null);
|
|
434
|
+
const dragRef = useRef<{ side: "old" | "new"; num: number } | null>(null);
|
|
435
|
+
const dragRangeRef = useRef<{ side: "old" | "new"; start: number; end: number } | null>(null);
|
|
436
|
+
const [dragRange, setDragRange] = useState<{ side: "old" | "new"; start: number; end: number } | null>(null);
|
|
437
|
+
|
|
438
|
+
useEffect(() => {
|
|
439
|
+
const el = scrollRef.current;
|
|
440
|
+
if (!el) return;
|
|
441
|
+
setVisibleWidth(el.clientWidth);
|
|
442
|
+
const observer = new ResizeObserver(() => setVisibleWidth(el.clientWidth));
|
|
443
|
+
observer.observe(el);
|
|
444
|
+
return () => observer.disconnect();
|
|
445
|
+
}, []);
|
|
446
|
+
|
|
447
|
+
useEffect(() => {
|
|
448
|
+
if (!sessionId) return;
|
|
449
|
+
fetch(`/api/sessions/${sessionId}/comments?path=${encodeURIComponent(filePath)}`)
|
|
450
|
+
.then((r) => r.ok ? r.json() : [])
|
|
451
|
+
.then((data) => setComments(data as DiffComment[]))
|
|
452
|
+
.catch(() => {});
|
|
453
|
+
}, [sessionId, filePath]);
|
|
454
|
+
|
|
455
|
+
useEffect(() => {
|
|
456
|
+
getCurrentUser().then((u) => {
|
|
457
|
+
if (u) setCurrentUser(u);
|
|
458
|
+
});
|
|
459
|
+
}, []);
|
|
460
|
+
|
|
461
|
+
const { commentsByKey, commentedLines } = useMemo(() => {
|
|
462
|
+
const map = new Map<string, DiffComment[]>();
|
|
463
|
+
const lineSet = new Set<string>();
|
|
464
|
+
for (const c of comments) {
|
|
465
|
+
const key = commentKey(c.side, c.line);
|
|
466
|
+
const arr = map.get(key);
|
|
467
|
+
if (arr) arr.push(c);
|
|
468
|
+
else map.set(key, [c]);
|
|
469
|
+
const start = c.startLine ?? c.line;
|
|
470
|
+
for (let n = start; n <= c.line; n++) lineSet.add(commentKey(c.side, n));
|
|
471
|
+
}
|
|
472
|
+
return { commentsByKey: map, commentedLines: lineSet };
|
|
473
|
+
}, [comments]);
|
|
474
|
+
|
|
475
|
+
const handleAddComment = useCallback(async (body: string) => {
|
|
476
|
+
if (!sessionId || !formRange) return;
|
|
477
|
+
const payload: Record<string, unknown> = {
|
|
478
|
+
filePath,
|
|
479
|
+
line: formRange.endLine,
|
|
480
|
+
side: formRange.side,
|
|
481
|
+
body,
|
|
482
|
+
};
|
|
483
|
+
if (formRange.startLine !== formRange.endLine) {
|
|
484
|
+
payload.startLine = formRange.startLine;
|
|
485
|
+
}
|
|
486
|
+
const res = await fetch(`/api/sessions/${sessionId}/comments`, {
|
|
487
|
+
method: "POST",
|
|
488
|
+
headers: { "Content-Type": "application/json" },
|
|
489
|
+
body: JSON.stringify(payload),
|
|
490
|
+
});
|
|
491
|
+
if (res.ok) {
|
|
492
|
+
const comment = await res.json() as DiffComment;
|
|
493
|
+
setComments((prev) => [...prev, comment]);
|
|
494
|
+
setFormRange(null);
|
|
495
|
+
}
|
|
496
|
+
}, [sessionId, filePath, formRange]);
|
|
497
|
+
|
|
498
|
+
const handleEditComment = useCallback(async (commentId: string, body: string) => {
|
|
499
|
+
if (!sessionId) return;
|
|
500
|
+
const res = await fetch(`/api/sessions/${sessionId}/comments/${commentId}`, {
|
|
501
|
+
method: "PATCH",
|
|
502
|
+
headers: { "Content-Type": "application/json" },
|
|
503
|
+
body: JSON.stringify({ body }),
|
|
504
|
+
});
|
|
505
|
+
if (res.ok) {
|
|
506
|
+
const updated = await res.json() as DiffComment;
|
|
507
|
+
setComments((prev) => prev.map((c) => c.id === commentId ? updated : c));
|
|
508
|
+
}
|
|
509
|
+
}, [sessionId]);
|
|
510
|
+
|
|
511
|
+
const handleDeleteComment = useCallback(async (commentId: string) => {
|
|
512
|
+
if (!sessionId) return;
|
|
513
|
+
const res = await fetch(`/api/sessions/${sessionId}/comments/${commentId}`, { method: "DELETE" });
|
|
514
|
+
if (res.ok) {
|
|
515
|
+
setComments((prev) => prev.filter((c) => c.id !== commentId));
|
|
516
|
+
}
|
|
517
|
+
}, [sessionId]);
|
|
518
|
+
|
|
519
|
+
const handleMouseDown = useCallback((side: "old" | "new", num: number, e: React.MouseEvent) => {
|
|
520
|
+
e.preventDefault();
|
|
521
|
+
dragRef.current = { side, num };
|
|
522
|
+
const r = { side, start: num, end: num };
|
|
523
|
+
dragRangeRef.current = r;
|
|
524
|
+
setDragRange(r);
|
|
525
|
+
}, []);
|
|
526
|
+
|
|
527
|
+
const handleMouseEnter = useCallback((side: "old" | "new", num: number) => {
|
|
528
|
+
const dr = dragRef.current;
|
|
529
|
+
if (!dr || dr.side !== side) return;
|
|
530
|
+
const start = Math.min(dr.num, num);
|
|
531
|
+
const end = Math.max(dr.num, num);
|
|
532
|
+
const r = { side, start, end };
|
|
533
|
+
dragRangeRef.current = r;
|
|
534
|
+
setDragRange(r);
|
|
535
|
+
}, []);
|
|
536
|
+
|
|
537
|
+
useEffect(() => {
|
|
538
|
+
const handleUp = () => {
|
|
539
|
+
const dr = dragRef.current;
|
|
540
|
+
const range = dragRangeRef.current;
|
|
541
|
+
dragRef.current = null;
|
|
542
|
+
dragRangeRef.current = null;
|
|
543
|
+
setDragRange(null);
|
|
544
|
+
if (!dr || !range) return;
|
|
545
|
+
setFormRange((prev) => {
|
|
546
|
+
if (prev && prev.side === range.side && prev.startLine === range.start && prev.endLine === range.end) return null;
|
|
547
|
+
return { side: range.side, startLine: range.start, endLine: range.end };
|
|
548
|
+
});
|
|
549
|
+
};
|
|
550
|
+
document.addEventListener("mouseup", handleUp);
|
|
551
|
+
return () => document.removeEventListener("mouseup", handleUp);
|
|
552
|
+
}, []);
|
|
553
|
+
|
|
554
|
+
const commentCount = comments.length;
|
|
555
|
+
|
|
556
|
+
return (
|
|
557
|
+
<div className="rounded-lg border overflow-hidden">
|
|
558
|
+
<div className="sticky top-0 z-10 bg-muted px-3 py-1.5 border-b flex items-center gap-2">
|
|
559
|
+
<span className="text-xs font-mono font-medium truncate flex-1" title={filePath}>
|
|
560
|
+
{fileName}
|
|
561
|
+
</span>
|
|
562
|
+
{commentCount > 0 && (
|
|
563
|
+
<span className="flex items-center gap-1 text-[10px] text-muted-foreground shrink-0">
|
|
564
|
+
<MessageSquare className="h-3 w-3" />
|
|
565
|
+
{commentCount}
|
|
566
|
+
</span>
|
|
567
|
+
)}
|
|
568
|
+
</div>
|
|
569
|
+
<div ref={scrollRef} className="overflow-x-auto">
|
|
570
|
+
<div className="min-w-max font-mono text-xs leading-5 select-text">
|
|
571
|
+
{lines.map((line, i) => {
|
|
572
|
+
if (line.type === "binary") {
|
|
573
|
+
return (
|
|
574
|
+
<div key={i} className={ROW_STYLE.binary}>
|
|
575
|
+
{line.content}
|
|
576
|
+
</div>
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (line.type === "header") {
|
|
581
|
+
return (
|
|
582
|
+
<div key={i} className={`px-3 ${ROW_STYLE.header}`}>
|
|
583
|
+
{line.content}
|
|
584
|
+
</div>
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (line.type === "hunk") {
|
|
589
|
+
return (
|
|
590
|
+
<div key={i} className={`px-3 py-0.5 ${ROW_STYLE.hunk}`}>
|
|
591
|
+
{line.content}
|
|
592
|
+
</div>
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const gutterStyle = GUTTER_STYLE[line.type] ?? GUTTER_STYLE.default;
|
|
597
|
+
const prefix = line.type === "added" ? "+" : line.type === "removed" ? "−" : " ";
|
|
598
|
+
const prefixStyle = PREFIX_STYLE[line.type] ?? PREFIX_STYLE.context;
|
|
599
|
+
const tokens = tokenMap?.get(i);
|
|
600
|
+
const content = tokens ? renderHighlighted(tokens) : line.content;
|
|
601
|
+
const lk = lineKey(line);
|
|
602
|
+
const key = lk ? commentKey(lk.side, lk.num) : null;
|
|
603
|
+
const lineComments = key ? commentsByKey.get(key) ?? [] : [];
|
|
604
|
+
const canComment = sessionId && lk != null;
|
|
605
|
+
|
|
606
|
+
const inDrag = canComment && dragRange != null && dragRange.side === lk.side && lk.num >= dragRange.start && lk.num <= dragRange.end;
|
|
607
|
+
const inFormRange = canComment && formRange != null && formRange.side === lk.side && lk.num >= formRange.startLine && lk.num <= formRange.endLine;
|
|
608
|
+
const isFormAnchor = canComment && formRange != null && formRange.side === lk.side && formRange.endLine === lk.num;
|
|
609
|
+
const hasComment = key != null && commentedLines.has(key);
|
|
610
|
+
const hasInline = lineComments.length > 0 || isFormAnchor;
|
|
611
|
+
|
|
612
|
+
const selectShadow = inDrag
|
|
613
|
+
? "inset 0 0 0 9999px oklch(0.623 0.214 259.815 / 0.20)"
|
|
614
|
+
: inFormRange
|
|
615
|
+
? "inset 0 0 0 9999px oklch(0.623 0.214 259.815 / 0.15)"
|
|
616
|
+
: hasComment
|
|
617
|
+
? "inset 0 0 0 9999px oklch(0.623 0.214 259.815 / 0.08)"
|
|
618
|
+
: undefined;
|
|
619
|
+
|
|
620
|
+
return (
|
|
621
|
+
<div key={i}>
|
|
622
|
+
<div
|
|
623
|
+
className={`flex ${ROW_STYLE[line.type]} ${canComment ? "cursor-pointer select-none hover:brightness-[1.15] dark:hover:brightness-[1.3]" : ""}`}
|
|
624
|
+
style={selectShadow ? { boxShadow: selectShadow } : undefined}
|
|
625
|
+
onMouseDown={canComment ? (e) => handleMouseDown(lk.side, lk.num, e) : undefined}
|
|
626
|
+
onMouseEnter={canComment ? () => handleMouseEnter(lk.side, lk.num) : undefined}
|
|
627
|
+
>
|
|
628
|
+
{hasComment && <span className="inline-block w-[3px] shrink-0 bg-blue-500/60" />}
|
|
629
|
+
<span className={`inline-block ${hasComment ? "w-[37px]" : "w-10"} shrink-0 text-right pr-1 select-none ${gutterStyle}`}>
|
|
630
|
+
{line.oldNum ?? ""}
|
|
631
|
+
</span>
|
|
632
|
+
<span className={`inline-block w-10 shrink-0 text-right pr-1 select-none border-r border-border/50 ${gutterStyle}`}>
|
|
633
|
+
{line.newNum ?? ""}
|
|
634
|
+
</span>
|
|
635
|
+
<span className={`inline-block w-4 shrink-0 text-center ${prefixStyle}`}>{prefix}</span>
|
|
636
|
+
<span className="pr-3 whitespace-pre">{content}</span>
|
|
637
|
+
</div>
|
|
638
|
+
{hasInline && (
|
|
639
|
+
<div className="sticky left-0" style={visibleWidth ? { width: visibleWidth } : undefined}>
|
|
640
|
+
<InlineComments
|
|
641
|
+
comments={lineComments}
|
|
642
|
+
currentUser={currentUser}
|
|
643
|
+
onEdit={handleEditComment}
|
|
644
|
+
onDelete={handleDeleteComment}
|
|
645
|
+
formTarget={!!isFormAnchor}
|
|
646
|
+
onSubmit={handleAddComment}
|
|
647
|
+
onCancel={() => setFormRange(null)}
|
|
648
|
+
/>
|
|
649
|
+
</div>
|
|
650
|
+
)}
|
|
651
|
+
</div>
|
|
652
|
+
);
|
|
653
|
+
})}
|
|
654
|
+
</div>
|
|
655
|
+
</div>
|
|
656
|
+
{isCapped && (
|
|
657
|
+
<button
|
|
658
|
+
type="button"
|
|
659
|
+
onClick={() => setShowAll(true)}
|
|
660
|
+
className="w-full py-2 text-xs text-blue-600 dark:text-blue-400 hover:bg-accent/50 transition-colors border-t"
|
|
661
|
+
>
|
|
662
|
+
Show all {allLines.length} lines
|
|
663
|
+
</button>
|
|
664
|
+
)}
|
|
665
|
+
{githubUrl && (
|
|
666
|
+
<div className="px-3 py-2 border-t text-center">
|
|
667
|
+
<a
|
|
668
|
+
href={githubUrl}
|
|
669
|
+
target="_blank"
|
|
670
|
+
rel="noopener noreferrer"
|
|
671
|
+
className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
|
672
|
+
>
|
|
673
|
+
View on GitHub
|
|
674
|
+
</a>
|
|
675
|
+
</div>
|
|
676
|
+
)}
|
|
677
|
+
</div>
|
|
678
|
+
);
|
|
679
|
+
}
|