✨ feat: update [sha].astro page
swadhinbiswas committed 3 months ago
173e539
1 parent e27c69f
commit 173e539
1 file changed
+209 -84
Showing 1 changed file+209-84
▼src/pages/[owner]/[repo]/commit/[sha].astro
+209-84
| @@ -3,18 +3,17 @@ import BaseLayout from "@/layouts/BaseLayout.astro"; | ||
3 | 3 | import { getDatabase, schema } from "@/db"; |
4 | 4 | import { eq, and } from "drizzle-orm"; |
5 | 5 | import { resolveRepoPath } from "@/lib/git-storage"; |
6 | -import { getCommitDiff } from "@/lib/git"; | |
6 | +import { getCommit, getCommitDiff, getCommitPatchDiff } from "@/lib/git"; | |
7 | 7 | import RepoHeader from "@/components/repo/RepoHeader.astro"; |
8 | 8 | import { canReadRepo } from "@/lib/permissions"; |
9 | +import DiffView from "@/components/diff/DiffView"; | |
9 | 10 | |
10 | -const { owner: ownerName, repo: repoName,sha } = Astro.params; | |
11 | +const { owner: ownerName, repo: repoName, sha } = Astro.params; | |
11 | 12 | const db = getDatabase(); |
12 | 13 | |
13 | -// Fetch Repo & Owner | |
14 | 14 | const user = await db.query.users.findFirst({ |
15 | 15 | where: eq(schema.users.username, ownerName!), |
16 | 16 | }); |
17 | - | |
18 | 17 | if (!user) return Astro.redirect("/404"); |
19 | 18 | |
20 | 19 | const repoData = await db.query.repositories.findFirst({ |
| @@ -22,20 +21,13 @@ const repoData = await db.query.repositories.findFirst({ | ||
22 | 21 | eq(schema.repositories.ownerId, user.id), |
23 | 22 | eq(schema.repositories.name, repoName!), |
24 | 23 | ), |
25 | - with: { | |
26 | - owner: true, | |
27 | - }, | |
24 | + with: { owner: true }, | |
28 | 25 | }); |
29 | - | |
30 | 26 | if (!repoData) return Astro.redirect("/404"); |
31 | 27 | |
32 | -// Check permissions | |
33 | 28 | const currentUser = Astro.locals.user; |
34 | 29 | const hasAccess = await canReadRepo(currentUser?.id, repoData); |
35 | - | |
36 | -if (!hasAccess) { | |
37 | - return Astro.redirect("/404"); | |
38 | -} | |
30 | +if (!hasAccess) return Astro.redirect("/404"); | |
39 | 31 | |
40 | 32 | const repo = { |
41 | 33 | owner: repoData.owner.username, |
| @@ -57,38 +49,113 @@ const repo = { | ||
57 | 49 | website: repoData.website, |
58 | 50 | }; |
59 | 51 | |
60 | -// Get commit diff | |
61 | 52 | const resolvedLocalPath = await resolveRepoPath(repoData.diskPath); |
62 | -let commit: any = null; | |
53 | +let commitInfo: any = null; | |
54 | +let fileStats: any[] = []; | |
55 | +let patchDiff = ""; | |
63 | 56 | let error: string | null = null; |
64 | 57 | |
65 | 58 | try { |
66 | - commit = await getCommitDiff(resolvedLocalPath, sha!); | |
59 | + commitInfo = await getCommit(resolvedLocalPath, sha!); | |
60 | + fileStats = await getCommitDiff(resolvedLocalPath, sha!); | |
61 | + patchDiff = await getCommitPatchDiff(resolvedLocalPath, sha!); | |
67 | 62 | } catch (e: any) { |
68 | - console.error("Error fetching commit:", e); | |
69 | 63 | error = e.message || "Failed to fetch commit"; |
70 | 64 | } |
71 | 65 | |
72 | -if (!commit && !error) { | |
73 | - return Astro.redirect("/404"); | |
66 | +if (!commitInfo && !error) return Astro.redirect("/404"); | |
67 | + | |
68 | +let authorUser: any = null; | |
69 | +if (commitInfo?.authorEmail) { | |
70 | + try { | |
71 | + authorUser = await db.query.users.findFirst({ | |
72 | + where: eq(schema.users.email, commitInfo.authorEmail), | |
73 | + }); | |
74 | + } catch {} | |
74 | 75 | } |
75 | 76 | |
77 | +const totalAdditions = fileStats.reduce((sum: number, f: any) => sum + (f.additions || 0), 0); | |
78 | +const totalDeletions = fileStats.reduce((sum: number, f: any) => sum + (f.deletions || 0), 0); | |
79 | + | |
80 | +// Build file tree structure for sidebar | |
81 | +interface FileNode { | |
82 | + name: string; | |
83 | + path: string; | |
84 | + type: "file" | "dir"; | |
85 | + status: string; | |
86 | + additions: number; | |
87 | + deletions: number; | |
88 | + children?: FileNode[]; | |
89 | +} | |
90 | + | |
91 | +const fileTree: FileNode[] = []; | |
92 | +for (const file of fileStats) { | |
93 | + const parts = file.file.split("/"); | |
94 | + let current = fileTree; | |
95 | + for (let i = 0; i < parts.length - 1; i++) { | |
96 | + let dir = current.find(n => n.name === parts[i] && n.type === "dir"); | |
97 | + if (!dir) { | |
98 | + dir = { name: parts[i], path: parts.slice(0, i + 1).join("/"), type: "dir", status: "modified", additions: 0, deletions: 0, children: [] }; | |
99 | + current.push(dir); | |
100 | + } | |
101 | + current = dir.children!; | |
102 | + } | |
103 | + current.push({ | |
104 | + name: parts[parts.length - 1], | |
105 | + path: file.file, | |
106 | + type: "file", | |
107 | + status: file.status, | |
108 | + additions: file.additions, | |
109 | + deletions: file.deletions, | |
110 | + }); | |
111 | +} | |
112 | + | |
113 | +function formatDate(date: Date): string { | |
114 | + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }); | |
115 | +} | |
116 | + | |
117 | +function renderFileTree(nodes: FileNode[], depth: number = 0): string { | |
118 | + let html = ""; | |
119 | + const sorted = [...nodes].sort((a, b) => { | |
120 | + if (a.type === "dir" && b.type !== "dir") return -1; | |
121 | + if (a.type !== "dir" && b.type === "dir") return 1; | |
122 | + return a.name.localeCompare(b.name); | |
123 | + }); | |
124 | + for (const node of sorted) { | |
125 | + const indent = depth * 16; | |
126 | + if (node.type === "dir") { | |
127 | + html += `<div class="flex items-center gap-1.5 py-0.5 px-2 text-xs text-muted-foreground hover:bg-accent/50 cursor-pointer" style="padding-left:${indent}px"> | |
128 | + <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> | |
129 | + <span>${node.name}</span> | |
130 | + </div>`; | |
131 | + if (node.children) html += renderFileTree(node.children, depth + 1); | |
132 | + } else { | |
133 | + const statusColor = node.status === "added" ? "text-green-500" : node.status === "deleted" ? "text-red-500" : "text-muted-foreground"; | |
134 | + html += `<a href="#file-${node.path.replace(/[^a-zA-Z0-9]/g, '-')}" class="flex items-center gap-1.5 py-0.5 px-2 text-xs hover:bg-accent/50 group" style="padding-left:${indent}px"> | |
135 | + <span class="w-2 h-2 rounded-full flex-shrink-0 ${node.status === 'added' ? 'bg-green-500' : node.status === 'deleted' ? 'bg-red-500' : node.status === 'renamed' ? 'bg-yellow-500' : 'bg-muted-foreground/30'}"></span> | |
136 | + <span class="text-foreground truncate group-hover:text-primary transition-colors">${node.name}</span> | |
137 | + <span class="ml-auto text-[10px] ${statusColor} flex-shrink-0">${node.additions > 0 ? `+${node.additions}` : ""} ${node.deletions > 0 ? `-${node.deletions}` : ""}</span> | |
138 | + </a>`; | |
139 | + } | |
140 | + } | |
141 | + return html; | |
142 | +} | |
143 | + | |
144 | +const fileTreeHtml = renderFileTree(fileTree); | |
145 | + | |
76 | 146 | function timeAgo(date: Date | string | number) { |
77 | 147 | if (!date) return ""; |
78 | - const seconds = Math.floor( | |
79 | - (new Date().getTime() - new Date(date).getTime()) / 1000, | |
80 | - ); | |
81 | - let interval = seconds / 31536000; | |
82 | - if (interval > 1) return Math.floor(interval) + " years ago"; | |
83 | - interval = seconds / 2592000; | |
84 | - if (interval > 1) return Math.floor(interval) + " months ago"; | |
85 | - interval = seconds / 86400; | |
86 | - if (interval > 1) return Math.floor(interval) + " days ago"; | |
87 | - interval = seconds / 3600; | |
88 | - if (interval > 1) return Math.floor(interval) + " hours ago"; | |
89 | - interval = seconds / 60; | |
90 | - if (interval > 1) return Math.floor(interval) + " minutes ago"; | |
91 | - return Math.floor(seconds) + " seconds ago"; | |
148 | + const seconds = Math.floor((new Date().getTime() - new Date(date).getTime()) / 1000); | |
149 | + if (seconds < 60) return "just now"; | |
150 | + const minutes = Math.floor(seconds / 60); | |
151 | + if (minutes < 60) return `${minutes} minutes ago`; | |
152 | + const hours = Math.floor(minutes / 60); | |
153 | + if (hours < 24) return `${hours} hours ago`; | |
154 | + const days = Math.floor(hours / 24); | |
155 | + if (days < 30) return `${days} days ago`; | |
156 | + const months = Math.floor(days / 30); | |
157 | + if (months < 12) return `${months} months ago`; | |
158 | + return `${Math.floor(months / 12)} years ago`; | |
92 | 159 | } |
93 | 160 | --- |
94 | 161 | |
| @@ -100,70 +167,128 @@ function timeAgo(date: Date | string | number) { | ||
100 | 167 | <div class="p-6 text-center"> |
101 | 168 | <p class="text-destructive">{error}</p> |
102 | 169 | </div> |
103 | - ) : commit && ( | |
104 | - <div class="space-y-6"> | |
170 | + ) : commitInfo && ( | |
171 | + <div class="space-y-4"> | |
105 | 172 | <!-- Commit Header --> |
106 | - <div class="rounded-lg border bg-card p-6"> | |
107 | - <div class="mb-4"> | |
108 | - <h1 class="text-2xl font-bold mb-2">{commit.message}</h1> | |
109 | - {commit.body && ( | |
110 | - <pre class="text-sm text-muted-foreground whitespace-pre-wrap font-sans">{commit.body}</pre> | |
111 | - )} | |
112 | - </div> | |
113 | - | |
114 | - <div class="flex items-center gap-4 text-sm text-muted-foreground border-t pt-4"> | |
115 | - <div class="flex items-center gap-2"> | |
116 | - <div class="h-8 w-8 rounded-full bg-muted flex items-center justify-center"> | |
117 | - <span class="text-sm font-medium"> | |
118 | - {commit.authorName?.[0]?.toUpperCase() || "?"} | |
119 | - </span> | |
120 | - </div> | |
121 | - <div> | |
122 | - <div class="font-medium text-foreground">{commit.authorName}</div> | |
123 | - <div>{commit.authorEmail}</div> | |
173 | + <div class="border border-border bg-card rounded-lg p-4"> | |
174 | + <div class="flex items-start justify-between gap-4"> | |
175 | + <div class="flex-1 min-w-0"> | |
176 | + <h1 class="text-xl font-semibold text-foreground mb-2">{commitInfo.message}</h1> | |
177 | + {commitInfo.body && ( | |
178 | + <pre class="text-sm text-muted-foreground whitespace-pre-wrap font-sans mb-3">{commitInfo.body}</pre> | |
179 | + )} | |
180 | + <div class="flex items-center gap-3 text-sm text-muted-foreground flex-wrap"> | |
181 | + <div class="flex items-center gap-2"> | |
182 | + {authorUser ? ( | |
183 | + <a href={`/${authorUser.username}`} class="flex items-center gap-1.5 hover:opacity-80"> | |
184 | + {authorUser.avatarUrl ? ( | |
185 | + <img src={authorUser.avatarUrl} alt="" class="h-5 w-5 rounded-full" /> | |
186 | + ) : ( | |
187 | + <div class="h-5 w-5 rounded-full bg-primary/20 flex items-center justify-center text-[10px] text-primary font-bold">{commitInfo.authorName?.[0]?.toUpperCase()}</div> | |
188 | + )} | |
189 | + <span class="font-medium text-foreground">{commitInfo.authorName}</span> | |
190 | + </a> | |
191 | + ) : ( | |
192 | + <span class="font-medium text-foreground">{commitInfo.authorName}</span> | |
193 | + )} | |
194 | + <span>committed {timeAgo(commitInfo.authorDate)}</span> | |
195 | + </div> | |
124 | 196 | </div> |
125 | 197 | </div> |
126 | - <div class="flex-1"></div> | |
127 | - <div> | |
128 | - committed {timeAgo(commit.authorDate)} | |
198 | + <div class="flex items-center gap-2 flex-shrink-0"> | |
199 | + <div class="font-mono text-xs bg-muted px-2 py-1 rounded border border-border">{(commitInfo.sha || sha)?.substring(0, 7)}</div> | |
200 | + <button class="p-1.5 rounded hover:bg-accent text-muted-foreground" title="Copy SHA" onclick="navigator.clipboard.writeText('{commitInfo.sha || sha}')"> | |
201 | + <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg> | |
202 | + </button> | |
129 | 203 | </div> |
130 | 204 | </div> |
131 | 205 | |
132 | - <div class="mt-4 flex items-center gap-4 text-sm"> | |
133 | - <div class="font-mono bg-muted px-3 py-1 rounded"> | |
134 | - {(commit.sha || sha)?.substring(0, 7)} | |
206 | + <!-- Parent commit & branch info --> | |
207 | + <div class="flex items-center gap-4 mt-3 pt-3 border-t border-border text-xs text-muted-foreground"> | |
208 | + {commitInfo.parentShas && commitInfo.parentShas.length > 0 && ( | |
209 | + <div class="flex items-center gap-1.5"> | |
210 | + <span>{commitInfo.parentShas.length} parent{commitInfo.parentShas.length > 1 ? 's' : ''}</span> | |
211 | + {commitInfo.parentShas.map((parentSha: string) => ( | |
212 | + <a href={`/${repo.owner}/${repo.name}/commit/${parentSha}`} class="font-mono text-blue-400 hover:underline">{parentSha.substring(0, 7)}</a> | |
213 | + ))} | |
214 | + </div> | |
215 | + )} | |
216 | + <div class="flex items-center gap-1.5"> | |
217 | + <span>commit</span> | |
218 | + <span class="font-mono text-foreground">{(commitInfo.sha || sha)?.substring(0, 7)}</span> | |
135 | 219 | </div> |
136 | - <div class="text-muted-foreground"> | |
137 | - {commit.stats?.additions || 0} additions, {commit.stats?.deletions || 0} deletions | |
220 | + </div> | |
221 | + </div> | |
222 | + | |
223 | + <!-- Files Changed Stats Bar --> | |
224 | + <div class="border border-border bg-card rounded-lg px-4 py-2 flex items-center justify-between"> | |
225 | + <div class="flex items-center gap-3"> | |
226 | + <span class="text-sm font-semibold text-foreground">{fileStats.length} file{fileStats.length !== 1 ? 's' : ''} changed</span> | |
227 | + </div> | |
228 | + <div class="flex items-center gap-3 text-xs"> | |
229 | + <span class="text-green-500 font-medium">+{totalAdditions}</span> | |
230 | + <span class="text-red-500 font-medium">-{totalDeletions}</span> | |
231 | + <div class="flex gap-0.5"> | |
232 | + {fileStats.slice(0, 20).map((f: any) => ( | |
233 | + <div class:list={[ | |
234 | + "w-2 h-2 rounded-sm", | |
235 | + f.status === "added" ? "bg-green-500" : | |
236 | + f.status === "deleted" ? "bg-red-500" : | |
237 | + "bg-muted-foreground/30" | |
238 | + ]} /> | |
239 | + ))} | |
138 | 240 | </div> |
139 | 241 | </div> |
140 | 242 | </div> |
141 | 243 | |
142 | - <!-- Changed Files --> | |
143 | - <div class="space-y-4"> | |
144 | - <h2 class="text-lg font-semibold">Changed files</h2> | |
145 | - | |
146 | - {commit.files && commit.files.length > 0 ? ( | |
147 | - commit.files.map((file: any) => ( | |
148 | - <div class="rounded-lg border overflow-hidden"> | |
149 | - <div class="flex items-center justify-between bg-muted/30 px-4 py-2 border-b"> | |
150 | - <span class="font-mono text-sm">{file.path}</span> | |
151 | - <span class="text-sm text-muted-foreground"> | |
152 | - +{file.additions || 0} -{file.deletions || 0} | |
153 | - </span> | |
154 | - </div> | |
155 | - {file.diff && ( | |
156 | - <div class="overflow-x-auto"> | |
157 | - <pre class="text-xs p-4 bg-background font-mono"><code>{file.diff}</code></pre> | |
244 | + <!-- Main Content: File Tree + Diff --> | |
245 | + <div class="flex gap-4"> | |
246 | + <!-- File Tree Sidebar --> | |
247 | + <div class="hidden lg:block w-64 flex-shrink-0"> | |
248 | + <div class="border border-border bg-card rounded-lg p-2 sticky top-20 max-h-[calc(100vh-100px)] overflow-y-auto custom-scrollbar"> | |
249 | + <div class="px-2 py-1 text-xs font-semibold text-muted-foreground border-b border-border mb-1">Files</div> | |
250 | + <div set:html={fileTreeHtml} /> | |
251 | + </div> | |
252 | + </div> | |
253 | + | |
254 | + <!-- Diff Content --> | |
255 | + <div class="flex-1 min-w-0"> | |
256 | + {patchDiff ? ( | |
257 | + <div class="border border-border bg-card rounded-lg overflow-hidden"> | |
258 | + <DiffView client:load rawDiff={patchDiff} /> | |
259 | + </div> | |
260 | + ) : fileStats.length > 0 ? ( | |
261 | + <div class="border border-border bg-card rounded-lg"> | |
262 | + {fileStats.map((file: any) => ( | |
263 | + <div id={`file-${file.file.replace(/[^a-zA-Z0-9]/g, '-')}`} class="border-b border-border last:border-0"> | |
264 | + <div class="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border"> | |
265 | + <div class="flex items-center gap-2"> | |
266 | + <span class:list={[ | |
267 | + "inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold", | |
268 | + file.status === "added" ? "bg-green-500/10 text-green-500" : | |
269 | + file.status === "deleted" ? "bg-red-500/10 text-red-500" : | |
270 | + file.status === "renamed" ? "bg-yellow-500/10 text-yellow-500" : | |
271 | + "bg-muted text-muted-foreground" | |
272 | + ]}> | |
273 | + {file.status === "added" ? "A" : file.status === "deleted" ? "D" : file.status === "renamed" ? "R" : "M"} | |
274 | + </span> | |
275 | + <span class="font-mono text-sm text-foreground">{file.file}</span> | |
276 | + {file.oldPath && <span class="text-xs text-muted-foreground">← {file.oldPath}</span>} | |
277 | + </div> | |
278 | + <div class="flex items-center gap-2 text-xs"> | |
279 | + {file.additions > 0 && <span class="text-green-500">+{file.additions}</span>} | |
280 | + {file.deletions > 0 && <span class="text-red-500">-{file.deletions}</span>} | |
281 | + </div> | |
282 | + </div> | |
158 | 283 | </div> |
159 | - )} | |
284 | + ))} | |
160 | 285 | </div> |
161 | - )) | |
162 | - ) : ( | |
163 | - <div class="text-center text-muted-foreground py-8"> | |
164 | - No files changed | |
165 | - </div> | |
166 | - )} | |
286 | + ) : ( | |
287 | + <div class="text-center text-muted-foreground py-8 border border-border bg-card rounded-lg"> | |
288 | + No file changes in this commit. | |
289 | + </div> | |
290 | + )} | |
291 | + </div> | |
167 | 292 | </div> |
168 | 293 | </div> |
169 | 294 | )} |
170 | 295 | |