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,158 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { Markdown } from "../components/Markdown.tsx";
3
+ import { RefreshCw, ExternalLink, AlertCircle, Loader2 } from "lucide-react";
4
+ import type { PrComment } from "../../../types/github.ts";
5
+
6
+ interface DiscussionData {
7
+ body: string;
8
+ comments: PrComment[];
9
+ }
10
+
11
+ function timeAgo(dateStr: string): string {
12
+ const diff = Date.now() - new Date(dateStr).getTime();
13
+ const mins = Math.floor(diff / 60_000);
14
+ if (mins < 1) return "just now";
15
+ if (mins < 60) return `${mins}m ago`;
16
+ const hours = Math.floor(mins / 60);
17
+ if (hours < 24) return `${hours}h ago`;
18
+ const days = Math.floor(hours / 24);
19
+ if (days < 30) return `${days}d ago`;
20
+ return new Date(dateStr).toLocaleDateString();
21
+ }
22
+
23
+ export function DiscussionPanel({ sessionId }: { sessionId?: string | null }) {
24
+ const [data, setData] = useState<DiscussionData | null>(null);
25
+ const [loading, setLoading] = useState(false);
26
+ const [error, setError] = useState<string | null>(null);
27
+
28
+ const fetchDiscussion = useCallback(async () => {
29
+ if (!sessionId) return;
30
+ setLoading(true);
31
+ setError(null);
32
+ try {
33
+ const res = await fetch(`/api/sessions/${sessionId}/discussion`);
34
+ if (!res.ok) {
35
+ const body = await res.json().catch(() => ({})) as { error?: string };
36
+ throw new Error(body.error ?? `HTTP ${res.status}`);
37
+ }
38
+ const json = await res.json() as DiscussionData;
39
+ setData(json);
40
+ } catch (err) {
41
+ setError(err instanceof Error ? err.message : String(err));
42
+ } finally {
43
+ setLoading(false);
44
+ }
45
+ }, [sessionId]);
46
+
47
+ useEffect(() => {
48
+ setData(null);
49
+ setError(null);
50
+ fetchDiscussion();
51
+ }, [fetchDiscussion]);
52
+
53
+ if (!sessionId) {
54
+ return (
55
+ <div className="flex flex-col items-center justify-center py-20">
56
+ <p className="text-xs text-muted-foreground/50">No session available</p>
57
+ </div>
58
+ );
59
+ }
60
+
61
+ if (loading) {
62
+ return (
63
+ <div className="flex items-center justify-center py-20 gap-2">
64
+ <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground/40" />
65
+ <span className="text-xs text-muted-foreground/50">Loading discussion</span>
66
+ </div>
67
+ );
68
+ }
69
+
70
+ if (error) {
71
+ return (
72
+ <div className="flex flex-col items-center justify-center py-20 gap-3">
73
+ <div className="flex items-center gap-2 text-destructive">
74
+ <AlertCircle className="h-3.5 w-3.5" />
75
+ <p className="text-xs">{error}</p>
76
+ </div>
77
+ <button
78
+ type="button"
79
+ onClick={fetchDiscussion}
80
+ className="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground/50 hover:text-foreground transition-colors"
81
+ >
82
+ <RefreshCw className="h-3 w-3" />
83
+ Retry
84
+ </button>
85
+ </div>
86
+ );
87
+ }
88
+
89
+ if (!data) return null;
90
+
91
+ const hasBody = data.body.trim().length > 0;
92
+ const hasComments = data.comments.length > 0;
93
+
94
+ return (
95
+ <div className="pt-5 space-y-6">
96
+ {hasBody && (
97
+ <div>
98
+ <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider mb-3">
99
+ Description
100
+ </div>
101
+ <div className="text-xs">
102
+ <Markdown>{data.body}</Markdown>
103
+ </div>
104
+ </div>
105
+ )}
106
+
107
+ {!hasBody && !hasComments && (
108
+ <div className="text-center py-12">
109
+ <p className="text-xs text-muted-foreground/40">No description or comments</p>
110
+ </div>
111
+ )}
112
+
113
+ {hasComments && (
114
+ <div>
115
+ <div className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider mb-3">
116
+ Comments
117
+ <span className="ml-1.5 text-muted-foreground/25">{data.comments.length}</span>
118
+ </div>
119
+ <div className="space-y-0">
120
+ {data.comments.map((comment, i) => (
121
+ <div
122
+ key={comment.id}
123
+ className={`py-4 ${i > 0 ? "border-t border-border/50" : ""}`}
124
+ >
125
+ <div className="flex items-center gap-2 mb-2.5">
126
+ {comment.author_avatar ? (
127
+ <img
128
+ src={comment.author_avatar}
129
+ alt={comment.author}
130
+ className="h-5 w-5 rounded-full"
131
+ />
132
+ ) : (
133
+ <div className="h-5 w-5 rounded-full bg-muted" />
134
+ )}
135
+ <span className="text-xs font-medium">{comment.author}</span>
136
+ <span className="text-[10px] text-muted-foreground/40">
137
+ {timeAgo(comment.created_at)}
138
+ </span>
139
+ <a
140
+ href={comment.html_url}
141
+ target="_blank"
142
+ rel="noopener noreferrer"
143
+ className="ml-auto text-muted-foreground/20 hover:text-muted-foreground/60 transition-colors"
144
+ >
145
+ <ExternalLink className="h-3 w-3" />
146
+ </a>
147
+ </div>
148
+ <div className="pl-7 text-xs">
149
+ <Markdown>{comment.body}</Markdown>
150
+ </div>
151
+ </div>
152
+ ))}
153
+ </div>
154
+ </div>
155
+ )}
156
+ </div>
157
+ );
158
+ }
@@ -1,6 +1,8 @@
1
- import { useState } from "react";
2
- import { ChevronDown, ChevronRight, Plus, Pencil, Trash2, ArrowRight } from "lucide-react";
3
- import type { FileChange, FileStatus } from "../../../types/output.ts";
1
+ import { useState, useMemo } from "react";
2
+ import { ChevronRight, Plus, Pencil, Trash2, ArrowRight, FolderTree, Layers, ArrowDownWideNarrow } from "lucide-react";
3
+ import type { FileChange, FileGroup, FileStatus } from "../../../types/output.ts";
4
+
5
+ type ViewMode = "tree" | "group" | "changes";
4
6
 
5
7
  const STATUS_ICON: Record<FileStatus, typeof Plus> = {
6
8
  added: Plus,
@@ -16,16 +18,369 @@ const STATUS_COLOR: Record<FileStatus, string> = {
16
18
  renamed: "text-blue-500",
17
19
  };
18
20
 
19
- function splitPath(fullPath: string): { dir: string; name: string } {
20
- const lastSlash = fullPath.lastIndexOf("/");
21
- if (lastSlash === -1) return { dir: "", name: fullPath };
22
- return { dir: fullPath.slice(0, lastSlash + 1), name: fullPath.slice(lastSlash + 1) };
21
+ interface TreeNode {
22
+ name: string;
23
+ fullPath: string;
24
+ file?: FileChange;
25
+ children: Map<string, TreeNode>;
26
+ }
27
+
28
+ function buildTree(files: FileChange[]): TreeNode {
29
+ const root: TreeNode = { name: "", fullPath: "", children: new Map() };
30
+ for (const file of files) {
31
+ const parts = file.path.split("/");
32
+ let node = root;
33
+ for (let i = 0; i < parts.length; i++) {
34
+ const part = parts[i]!;
35
+ const fullPath = parts.slice(0, i + 1).join("/");
36
+ if (!node.children.has(part)) {
37
+ node.children.set(part, { name: part, fullPath, children: new Map() });
38
+ }
39
+ node = node.children.get(part)!;
40
+ }
41
+ node.file = file;
42
+ }
43
+ return root;
44
+ }
45
+
46
+ function collapseTree(node: TreeNode): TreeNode {
47
+ if (!node.file && node.children.size === 1) {
48
+ const [childName, child] = [...node.children.entries()][0]!;
49
+ const collapsed = collapseTree(child);
50
+ const mergedName = node.name ? `${node.name}/${childName}` : childName;
51
+ return { ...collapsed, name: mergedName };
52
+ }
53
+ const children = new Map<string, TreeNode>();
54
+ for (const [key, child] of node.children) {
55
+ children.set(key, collapseTree(child));
56
+ }
57
+ return { ...node, children };
58
+ }
59
+
60
+ function FileRow({
61
+ file,
62
+ selectedPath,
63
+ onFileSelect,
64
+ expanded,
65
+ onToggleExpand,
66
+ indent,
67
+ showFullPath,
68
+ }: {
69
+ file: FileChange;
70
+ selectedPath?: string | null;
71
+ onFileSelect?: (path: string) => void;
72
+ expanded: Set<string>;
73
+ onToggleExpand: (e: React.MouseEvent, path: string) => void;
74
+ indent?: number;
75
+ showFullPath?: boolean;
76
+ }) {
77
+ const Icon = STATUS_ICON[file.status];
78
+ const open = expanded.has(file.path);
79
+ const isSelected = selectedPath === file.path;
80
+ const lastSlash = file.path.lastIndexOf("/");
81
+ const dir = showFullPath && lastSlash >= 0 ? file.path.slice(0, lastSlash + 1) : "";
82
+ const name = showFullPath && lastSlash >= 0 ? file.path.slice(lastSlash + 1) : (showFullPath ? file.path : file.path.split("/").pop()!);
83
+
84
+ return (
85
+ <div>
86
+ <div
87
+ role="button"
88
+ tabIndex={0}
89
+ onClick={() => onFileSelect?.(file.path)}
90
+ onKeyDown={(e) => { if (e.key === "Enter") onFileSelect?.(file.path); }}
91
+ style={indent ? { paddingLeft: `${indent * 14 + 4}px` } : undefined}
92
+ className={`w-full flex items-center gap-1.5 h-7 text-left transition-colors min-w-0 pr-2 rounded-md cursor-pointer ${
93
+ isSelected ? "bg-accent text-foreground" : "hover:bg-accent/30"
94
+ }`}
95
+ >
96
+ <button
97
+ type="button"
98
+ onClick={(e) => onToggleExpand(e, file.path)}
99
+ className="shrink-0 p-0.5 -m-0.5 rounded hover:bg-accent/50 transition-colors"
100
+ >
101
+ <ChevronRight className={`h-3 w-3 text-muted-foreground/40 transition-transform ${open ? "rotate-90" : ""}`} />
102
+ </button>
103
+ <Icon className={`h-2.5 w-2.5 shrink-0 ${STATUS_COLOR[file.status]}`} />
104
+ <span className="flex-1 min-w-0 flex items-baseline overflow-hidden" title={file.path}>
105
+ {dir && <span className="text-[11px] text-muted-foreground/30 font-mono truncate shrink">{dir}</span>}
106
+ <span className="text-[11px] font-mono shrink-0">{name}</span>
107
+ </span>
108
+ <span className="text-[10px] tabular-nums text-green-600 dark:text-green-400 shrink-0 w-7 text-right">+{file.additions}</span>
109
+ <span className="text-[10px] tabular-nums text-red-600 dark:text-red-400 shrink-0 w-7 text-right">-{file.deletions}</span>
110
+ </div>
111
+ {open && (
112
+ <div className="pb-2 pt-0.5" style={{ paddingLeft: `${(indent ?? 0) * 14 + 36}px` }}>
113
+ <p className="text-[11px] text-muted-foreground/50 leading-relaxed break-words">{file.summary}</p>
114
+ {file.groups.length > 0 && (
115
+ <div className="flex flex-wrap gap-1 mt-1.5">
116
+ {file.groups.map((g) => (
117
+ <span key={g} className="text-[10px] text-muted-foreground/40 bg-accent/50 px-1.5 py-0.5 rounded">{g}</span>
118
+ ))}
119
+ </div>
120
+ )}
121
+ </div>
122
+ )}
123
+ </div>
124
+ );
125
+ }
126
+
127
+ function TreeView({
128
+ node,
129
+ files,
130
+ selectedPath,
131
+ onFileSelect,
132
+ expanded,
133
+ onToggleExpand,
134
+ folderOpen,
135
+ onToggleFolder,
136
+ depth,
137
+ }: {
138
+ node: TreeNode;
139
+ files: FileChange[];
140
+ selectedPath?: string | null;
141
+ onFileSelect?: (path: string) => void;
142
+ expanded: Set<string>;
143
+ onToggleExpand: (e: React.MouseEvent, path: string) => void;
144
+ folderOpen: Set<string>;
145
+ onToggleFolder: (path: string) => void;
146
+ depth: number;
147
+ }) {
148
+ const dirs: TreeNode[] = [];
149
+ const leaves: TreeNode[] = [];
150
+ for (const child of node.children.values()) {
151
+ if (child.file && child.children.size === 0) {
152
+ leaves.push(child);
153
+ } else {
154
+ dirs.push(child);
155
+ }
156
+ }
157
+ dirs.sort((a, b) => a.name.localeCompare(b.name));
158
+ leaves.sort((a, b) => a.name.localeCompare(b.name));
159
+
160
+ return (
161
+ <>
162
+ {dirs.map((dir) => {
163
+ const isOpen = folderOpen.has(dir.fullPath);
164
+ return (
165
+ <div key={dir.fullPath}>
166
+ <div
167
+ role="button"
168
+ tabIndex={0}
169
+ onClick={() => onToggleFolder(dir.fullPath)}
170
+ onKeyDown={(e) => { if (e.key === "Enter") onToggleFolder(dir.fullPath); }}
171
+ style={{ paddingLeft: `${depth * 14 + 4}px` }}
172
+ className="flex items-center gap-1.5 h-7 cursor-pointer hover:bg-accent/30 rounded-md transition-colors"
173
+ >
174
+ <ChevronRight className={`h-3 w-3 text-muted-foreground/40 shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`} />
175
+ <span className="text-[11px] font-mono text-muted-foreground/60">{dir.name}</span>
176
+ </div>
177
+ {isOpen && (
178
+ <TreeView
179
+ node={dir}
180
+ files={files}
181
+ selectedPath={selectedPath}
182
+ onFileSelect={onFileSelect}
183
+ expanded={expanded}
184
+ onToggleExpand={onToggleExpand}
185
+ folderOpen={folderOpen}
186
+ onToggleFolder={onToggleFolder}
187
+ depth={depth + 1}
188
+ />
189
+ )}
190
+ </div>
191
+ );
192
+ })}
193
+ {leaves.map((leaf) => (
194
+ <FileRow
195
+ key={leaf.fullPath}
196
+ file={leaf.file!}
197
+ selectedPath={selectedPath}
198
+ onFileSelect={onFileSelect}
199
+ expanded={expanded}
200
+ onToggleExpand={onToggleExpand}
201
+ indent={depth}
202
+ />
203
+ ))}
204
+ </>
205
+ );
206
+ }
207
+
208
+ function GroupView({
209
+ files,
210
+ groups,
211
+ selectedPath,
212
+ onFileSelect,
213
+ expanded,
214
+ onToggleExpand,
215
+ }: {
216
+ files: FileChange[];
217
+ groups: FileGroup[];
218
+ selectedPath?: string | null;
219
+ onFileSelect?: (path: string) => void;
220
+ expanded: Set<string>;
221
+ onToggleExpand: (e: React.MouseEvent, path: string) => void;
222
+ }) {
223
+ const [openGroups, setOpenGroups] = useState<Set<string>>(() => new Set(groups.map((g) => g.name)));
224
+
225
+ const filesByGroup = useMemo(() => {
226
+ const map = new Map<string, FileChange[]>();
227
+ const fileMap = new Map(files.map((f) => [f.path, f]));
228
+ for (const g of groups) {
229
+ map.set(g.name, g.files.map((p) => fileMap.get(p)).filter(Boolean) as FileChange[]);
230
+ }
231
+ const grouped = new Set(groups.flatMap((g) => g.files));
232
+ const ungrouped = files.filter((f) => !grouped.has(f.path));
233
+ if (ungrouped.length > 0) map.set("_ungrouped", ungrouped);
234
+ return map;
235
+ }, [files, groups]);
236
+
237
+ const groupMeta = useMemo(() => {
238
+ const map = new Map<string, FileGroup>();
239
+ for (const g of groups) map.set(g.name, g);
240
+ return map;
241
+ }, [groups]);
242
+
243
+ function toggleGroup(name: string) {
244
+ setOpenGroups((s) => {
245
+ const next = new Set(s);
246
+ next.has(name) ? next.delete(name) : next.add(name);
247
+ return next;
248
+ });
249
+ }
250
+
251
+ return (
252
+ <div className="space-y-px">
253
+ {[...filesByGroup.entries()].map(([groupName, groupFiles]) => {
254
+ const isOpen = openGroups.has(groupName);
255
+ const meta = groupMeta.get(groupName);
256
+ const displayName = groupName === "_ungrouped" ? "Ungrouped" : groupName;
257
+ const totalAdd = groupFiles.reduce((s, f) => s + f.additions, 0);
258
+ const totalDel = groupFiles.reduce((s, f) => s + f.deletions, 0);
259
+
260
+ return (
261
+ <div key={groupName}>
262
+ <div
263
+ role="button"
264
+ tabIndex={0}
265
+ onClick={() => toggleGroup(groupName)}
266
+ onKeyDown={(e) => { if (e.key === "Enter") toggleGroup(groupName); }}
267
+ className={`flex items-center gap-1.5 h-8 px-2 -mx-1 cursor-pointer rounded-md transition-colors ${
268
+ isOpen ? "bg-accent/40" : "hover:bg-accent/30"
269
+ }`}
270
+ >
271
+ <ChevronRight className={`h-3 w-3 text-muted-foreground/40 shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`} />
272
+ <span className="text-xs font-medium flex-1 min-w-0 truncate">{displayName}</span>
273
+ <span className="text-[10px] text-muted-foreground/30 tabular-nums shrink-0">{groupFiles.length}</span>
274
+ <span className="text-[10px] tabular-nums text-green-600 dark:text-green-400 shrink-0 w-7 text-right">+{totalAdd}</span>
275
+ <span className="text-[10px] tabular-nums text-red-600 dark:text-red-400 shrink-0 w-7 text-right">-{totalDel}</span>
276
+ </div>
277
+ {isOpen && (
278
+ <div className="pl-3">
279
+ {meta?.description && (
280
+ <p className="text-[11px] text-muted-foreground/40 pl-5 pb-1.5 pt-0.5 leading-relaxed">{meta.description}</p>
281
+ )}
282
+ {groupFiles.map((file) => (
283
+ <FileRow
284
+ key={file.path}
285
+ file={file}
286
+ selectedPath={selectedPath}
287
+ onFileSelect={onFileSelect}
288
+ expanded={expanded}
289
+ onToggleExpand={onToggleExpand}
290
+ showFullPath
291
+ />
292
+ ))}
293
+ </div>
294
+ )}
295
+ </div>
296
+ );
297
+ })}
298
+ </div>
299
+ );
300
+ }
301
+
302
+ function ChangesView({
303
+ files,
304
+ selectedPath,
305
+ onFileSelect,
306
+ expanded,
307
+ onToggleExpand,
308
+ }: {
309
+ files: FileChange[];
310
+ selectedPath?: string | null;
311
+ onFileSelect?: (path: string) => void;
312
+ expanded: Set<string>;
313
+ onToggleExpand: (e: React.MouseEvent, path: string) => void;
314
+ }) {
315
+ const sorted = useMemo(
316
+ () => [...files].sort((a, b) => (b.additions + b.deletions) - (a.additions + a.deletions)),
317
+ [files],
318
+ );
319
+
320
+ const maxChanges = sorted.length > 0 ? sorted[0]!.additions + sorted[0]!.deletions : 1;
321
+
322
+ return (
323
+ <div className="space-y-px">
324
+ {sorted.map((file) => {
325
+ const total = file.additions + file.deletions;
326
+ const addPct = total > 0 ? (file.additions / total) * 100 : 0;
327
+ const barWidth = (total / maxChanges) * 100;
328
+
329
+ return (
330
+ <div key={file.path}>
331
+ <FileRow
332
+ file={file}
333
+ selectedPath={selectedPath}
334
+ onFileSelect={onFileSelect}
335
+ expanded={expanded}
336
+ onToggleExpand={onToggleExpand}
337
+ showFullPath
338
+ />
339
+ <div className="h-px rounded-full bg-muted overflow-hidden ml-8 mr-2 mb-1" style={{ width: `${Math.min(barWidth, 100)}%` }}>
340
+ <div className="h-full bg-green-500/50 float-left" style={{ width: `${addPct}%` }} />
341
+ <div className="h-full bg-red-500/50 float-left" style={{ width: `${100 - addPct}%` }} />
342
+ </div>
343
+ </div>
344
+ );
345
+ })}
346
+ </div>
347
+ );
23
348
  }
24
349
 
25
- export function FilesPanel({ files }: { files: FileChange[] }) {
350
+ const VIEW_MODES: { value: ViewMode; icon: typeof FolderTree; label: string }[] = [
351
+ { value: "tree", icon: FolderTree, label: "Tree" },
352
+ { value: "group", icon: Layers, label: "Groups" },
353
+ { value: "changes", icon: ArrowDownWideNarrow, label: "Changes" },
354
+ ];
355
+
356
+ export function FilesPanel({
357
+ files,
358
+ groups,
359
+ selectedPath,
360
+ onFileSelect,
361
+ }: {
362
+ files: FileChange[];
363
+ groups?: FileGroup[];
364
+ selectedPath?: string | null;
365
+ onFileSelect?: (path: string) => void;
366
+ }) {
367
+ const [viewMode, setViewMode] = useState<ViewMode>("tree");
26
368
  const [expanded, setExpanded] = useState<Set<string>>(new Set());
369
+ const [folderOpen, setFolderOpen] = useState<Set<string>>(() => {
370
+ const dirs = new Set<string>();
371
+ for (const f of files) {
372
+ const parts = f.path.split("/");
373
+ for (let i = 1; i < parts.length; i++) {
374
+ dirs.add(parts.slice(0, i).join("/"));
375
+ }
376
+ }
377
+ return dirs;
378
+ });
27
379
 
28
- function toggle(path: string) {
380
+ const tree = useMemo(() => collapseTree(buildTree(files)), [files]);
381
+
382
+ function toggleExpand(e: React.MouseEvent, path: string) {
383
+ e.stopPropagation();
29
384
  setExpanded((s) => {
30
385
  const next = new Set(s);
31
386
  next.has(path) ? next.delete(path) : next.add(path);
@@ -33,53 +388,79 @@ export function FilesPanel({ files }: { files: FileChange[] }) {
33
388
  });
34
389
  }
35
390
 
391
+ function toggleFolder(path: string) {
392
+ setFolderOpen((s) => {
393
+ const next = new Set(s);
394
+ next.has(path) ? next.delete(path) : next.add(path);
395
+ return next;
396
+ });
397
+ }
398
+
399
+ const totalAdd = files.reduce((s, f) => s + f.additions, 0);
400
+ const totalDel = files.reduce((s, f) => s + f.deletions, 0);
401
+
36
402
  return (
37
- <div className="pt-6">
38
- <div className="text-xs text-muted-foreground mb-3">
39
- {files.length} files changed
40
- </div>
41
- <div className="divide-y">
42
- {files.map((file) => {
43
- const Icon = STATUS_ICON[file.status];
44
- const open = expanded.has(file.path);
45
- const { dir, name } = splitPath(file.path);
46
-
47
- return (
48
- <div key={file.path}>
49
- <button
50
- type="button"
51
- onClick={() => toggle(file.path)}
52
- className="w-full flex items-center gap-3 py-2.5 text-left hover:bg-accent/30 transition-colors min-w-0 -mx-1 px-1 rounded"
53
- >
54
- {open ? (
55
- <ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
56
- ) : (
57
- <ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
58
- )}
59
- <Icon className={`h-3.5 w-3.5 shrink-0 ${STATUS_COLOR[file.status]}`} />
60
- <span className="flex-1 min-w-0 flex items-baseline overflow-hidden" title={file.path}>
61
- <span className="text-xs text-muted-foreground/50 font-mono truncate shrink">{dir}</span>
62
- <span className="text-sm font-mono font-medium shrink-0">{name}</span>
63
- </span>
64
- <span className="text-xs tabular-nums text-green-500 shrink-0 w-10 text-right">+{file.additions}</span>
65
- <span className="text-xs tabular-nums text-red-500 shrink-0 w-10 text-right">−{file.deletions}</span>
66
- </button>
67
- {open && (
68
- <div className="pb-3 pl-12">
69
- <p className="text-xs text-muted-foreground leading-relaxed break-words">{file.summary}</p>
70
- {file.groups.length > 0 && (
71
- <div className="flex flex-wrap gap-1.5 mt-2">
72
- {file.groups.map((g) => (
73
- <span key={g} className="text-[11px] bg-muted px-2 py-0.5 rounded-full">{g}</span>
74
- ))}
75
- </div>
76
- )}
77
- </div>
78
- )}
79
- </div>
80
- );
81
- })}
403
+ <div className="pt-5">
404
+ <div className="flex items-center justify-between mb-3">
405
+ <div className="flex items-center gap-2">
406
+ <span className="text-[10px] font-medium text-muted-foreground/40 uppercase tracking-wider">{files.length} files</span>
407
+ <span className="text-[10px] tabular-nums text-green-600 dark:text-green-400">+{totalAdd}</span>
408
+ <span className="text-[10px] tabular-nums text-red-600 dark:text-red-400">-{totalDel}</span>
409
+ </div>
410
+ <div className="flex items-center gap-px rounded-md border p-0.5">
411
+ {VIEW_MODES.map(({ value, icon: ModeIcon, label }) => (
412
+ <button
413
+ key={value}
414
+ type="button"
415
+ onClick={() => setViewMode(value)}
416
+ title={label}
417
+ className={`inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] transition-colors ${
418
+ viewMode === value
419
+ ? "bg-accent text-foreground font-medium"
420
+ : "text-muted-foreground/50 hover:text-foreground"
421
+ }`}
422
+ >
423
+ <ModeIcon className="h-3 w-3" />
424
+ <span className="hidden sm:inline">{label}</span>
425
+ </button>
426
+ ))}
427
+ </div>
82
428
  </div>
429
+
430
+ {viewMode === "tree" && (
431
+ <TreeView
432
+ node={tree}
433
+ files={files}
434
+ selectedPath={selectedPath}
435
+ onFileSelect={onFileSelect}
436
+ expanded={expanded}
437
+ onToggleExpand={toggleExpand}
438
+ folderOpen={folderOpen}
439
+ onToggleFolder={toggleFolder}
440
+ depth={0}
441
+ />
442
+ )}
443
+
444
+ {viewMode === "group" && (
445
+ <GroupView
446
+ files={files}
447
+ groups={groups ?? []}
448
+ selectedPath={selectedPath}
449
+ onFileSelect={onFileSelect}
450
+ expanded={expanded}
451
+ onToggleExpand={toggleExpand}
452
+ />
453
+ )}
454
+
455
+ {viewMode === "changes" && (
456
+ <ChangesView
457
+ files={files}
458
+ selectedPath={selectedPath}
459
+ onFileSelect={onFileSelect}
460
+ expanded={expanded}
461
+ onToggleExpand={toggleExpand}
462
+ />
463
+ )}
83
464
  </div>
84
465
  );
85
466
  }