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,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 {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
38
|
-
<div className="
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
}
|