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.
Files changed (33) hide show
  1. package/package.json +11 -1
  2. package/src/analyzer/pipeline.ts +22 -5
  3. package/src/github/fetch-pr.ts +43 -1
  4. package/src/history/store.ts +106 -1
  5. package/src/llm/client.ts +197 -0
  6. package/src/llm/prompts.ts +33 -8
  7. package/src/tui/Shell.tsx +7 -2
  8. package/src/types/github.ts +11 -0
  9. package/src/types/output.ts +44 -0
  10. package/src/web/client/App.tsx +29 -3
  11. package/src/web/client/components/AppShell.tsx +94 -47
  12. package/src/web/client/components/ChatSection.tsx +427 -0
  13. package/src/web/client/components/DetailPane.tsx +163 -75
  14. package/src/web/client/components/DiffViewer.tsx +679 -0
  15. package/src/web/client/components/InputScreen.tsx +110 -26
  16. package/src/web/client/components/Markdown.tsx +169 -43
  17. package/src/web/client/components/ResultsScreen.tsx +66 -71
  18. package/src/web/client/components/TipTapEditor.tsx +405 -0
  19. package/src/web/client/hooks/useAnalysis.ts +8 -1
  20. package/src/web/client/lib/shiki.ts +63 -0
  21. package/src/web/client/panels/CartoonPanel.tsx +94 -37
  22. package/src/web/client/panels/DiscussionPanel.tsx +158 -0
  23. package/src/web/client/panels/FilesPanel.tsx +435 -54
  24. package/src/web/client/panels/GroupsPanel.tsx +49 -40
  25. package/src/web/client/panels/StoryPanel.tsx +42 -22
  26. package/src/web/components/ui/tabs.tsx +3 -3
  27. package/src/web/server/routes.ts +716 -14
  28. package/src/web/server/session-manager.ts +11 -2
  29. package/src/web/server.ts +33 -0
  30. package/src/web/styles/built.css +1 -1
  31. package/src/web/styles/globals.css +117 -1
  32. package/src/web/client/panels/NarrativePanel.tsx +0 -9
  33. 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
+ }