Blame · Line-by-line history
components.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| fc1817a | 1 | import type { FC } from "hono/jsx"; |
| 2 | import type { GitCommit, GitTreeEntry, GitDiffFile } from "../git/repository"; | |
| 3 | ||
| 4 | export const RepoHeader: FC<{ owner: string; repo: string }> = ({ | |
| 5 | owner, | |
| 6 | repo, | |
| 7 | }) => ( | |
| 8 | <div class="repo-header"> | |
| 9 | <a href={`/${owner}`} class="owner"> | |
| 10 | {owner} | |
| 11 | </a> | |
| 12 | <span class="separator">/</span> | |
| 13 | <a href={`/${owner}/${repo}`} class="name"> | |
| 14 | {repo} | |
| 15 | </a> | |
| 16 | </div> | |
| 17 | ); | |
| 18 | ||
| 19 | export const RepoNav: FC<{ | |
| 20 | owner: string; | |
| 21 | repo: string; | |
| 22 | active: "code" | "commits"; | |
| 23 | }> = ({ owner, repo, active }) => ( | |
| 24 | <div class="repo-nav"> | |
| 25 | <a | |
| 26 | href={`/${owner}/${repo}`} | |
| 27 | class={active === "code" ? "active" : ""} | |
| 28 | > | |
| 29 | Code | |
| 30 | </a> | |
| 31 | <a | |
| 32 | href={`/${owner}/${repo}/commits`} | |
| 33 | class={active === "commits" ? "active" : ""} | |
| 34 | > | |
| 35 | Commits | |
| 36 | </a> | |
| 37 | </div> | |
| 38 | ); | |
| 39 | ||
| 40 | export const Breadcrumb: FC<{ | |
| 41 | owner: string; | |
| 42 | repo: string; | |
| 43 | ref: string; | |
| 44 | path: string; | |
| 45 | }> = ({ owner, repo, ref, path }) => { | |
| 46 | const parts = path.split("/").filter(Boolean); | |
| 47 | const crumbs: { name: string; href: string }[] = [ | |
| 48 | { name: repo, href: `/${owner}/${repo}/tree/${ref}` }, | |
| 49 | ]; | |
| 50 | let accumulated = ""; | |
| 51 | for (const part of parts) { | |
| 52 | accumulated += (accumulated ? "/" : "") + part; | |
| 53 | crumbs.push({ | |
| 54 | name: part, | |
| 55 | href: `/${owner}/${repo}/tree/${ref}/${accumulated}`, | |
| 56 | }); | |
| 57 | } | |
| 58 | return ( | |
| 59 | <div class="breadcrumb"> | |
| 60 | {crumbs.map((crumb, i) => ( | |
| 61 | <> | |
| 62 | {i > 0 && <span>/</span>} | |
| 63 | {i === crumbs.length - 1 ? ( | |
| 64 | <strong>{crumb.name}</strong> | |
| 65 | ) : ( | |
| 66 | <a href={crumb.href}>{crumb.name}</a> | |
| 67 | )} | |
| 68 | </> | |
| 69 | ))} | |
| 70 | </div> | |
| 71 | ); | |
| 72 | }; | |
| 73 | ||
| 74 | export const FileTable: FC<{ | |
| 75 | entries: GitTreeEntry[]; | |
| 76 | owner: string; | |
| 77 | repo: string; | |
| 78 | ref: string; | |
| 79 | path: string; | |
| 80 | }> = ({ entries, owner, repo, ref, path }) => ( | |
| 81 | <table class="file-table"> | |
| 82 | <tbody> | |
| 83 | {entries.map((entry) => { | |
| 84 | const fullPath = path ? `${path}/${entry.name}` : entry.name; | |
| 85 | const href = | |
| 86 | entry.type === "tree" | |
| 87 | ? `/${owner}/${repo}/tree/${ref}/${fullPath}` | |
| 88 | : `/${owner}/${repo}/blob/${ref}/${fullPath}`; | |
| 89 | return ( | |
| 90 | <tr> | |
| 91 | <td class="file-icon"> | |
| 92 | {entry.type === "tree" ? "\u{1F4C1}" : "\u{1F4C4}"} | |
| 93 | </td> | |
| 94 | <td class="file-name"> | |
| 95 | <a href={href}>{entry.name}</a> | |
| 96 | </td> | |
| 97 | <td style="text-align: right; color: var(--text-muted); font-size: 13px;"> | |
| 98 | {entry.size !== undefined ? formatSize(entry.size) : ""} | |
| 99 | </td> | |
| 100 | </tr> | |
| 101 | ); | |
| 102 | })} | |
| 103 | </tbody> | |
| 104 | </table> | |
| 105 | ); | |
| 106 | ||
| 107 | export const CommitList: FC<{ | |
| 108 | commits: GitCommit[]; | |
| 109 | owner: string; | |
| 110 | repo: string; | |
| 111 | }> = ({ commits, owner, repo }) => ( | |
| 112 | <div class="commit-list"> | |
| 113 | {commits.map((commit) => ( | |
| 114 | <div class="commit-item"> | |
| 115 | <div> | |
| 116 | <div class="commit-message"> | |
| 117 | <a href={`/${owner}/${repo}/commit/${commit.sha}`}> | |
| 118 | {commit.message} | |
| 119 | </a> | |
| 120 | </div> | |
| 121 | <div class="commit-meta"> | |
| 122 | {commit.author} committed{" "} | |
| 123 | {formatRelativeDate(commit.date)} | |
| 124 | </div> | |
| 125 | </div> | |
| 126 | <a | |
| 127 | href={`/${owner}/${repo}/commit/${commit.sha}`} | |
| 128 | class="commit-sha" | |
| 129 | > | |
| 130 | {commit.sha.slice(0, 7)} | |
| 131 | </a> | |
| 132 | </div> | |
| 133 | ))} | |
| 134 | </div> | |
| 135 | ); | |
| 136 | ||
| 137 | export const DiffView: FC<{ raw: string; files: GitDiffFile[] }> = ({ | |
| 138 | raw, | |
| 139 | files, | |
| 140 | }) => { | |
| 141 | // Parse unified diff into per-file sections | |
| 142 | const sections = parseDiff(raw); | |
| 143 | ||
| 144 | return ( | |
| 145 | <div class="diff-view"> | |
| 146 | <div style="margin-bottom: 16px; font-size: 14px; color: var(--text-muted);"> | |
| 147 | Showing{" "} | |
| 148 | <strong style="color: var(--text)">{files.length}</strong> changed | |
| 149 | file{files.length !== 1 ? "s" : ""} with{" "} | |
| 150 | <span class="stat-add"> | |
| 151 | +{files.reduce((s, f) => s + f.additions, 0)} | |
| 152 | </span>{" "} | |
| 153 | and{" "} | |
| 154 | <span class="stat-del"> | |
| 155 | -{files.reduce((s, f) => s + f.deletions, 0)} | |
| 156 | </span> | |
| 157 | </div> | |
| 158 | {sections.map((section) => ( | |
| 159 | <div class="diff-file"> | |
| 160 | <div class="diff-file-header">{section.path}</div> | |
| 161 | <div class="diff-content"> | |
| 162 | {section.lines.map((line) => { | |
| 163 | let cls = "line"; | |
| 164 | if (line.startsWith("+")) cls += " line-add"; | |
| 165 | else if (line.startsWith("-")) cls += " line-del"; | |
| 166 | else if (line.startsWith("@@")) cls += " line-hunk"; | |
| 167 | return <span class={cls}>{line + "\n"}</span>; | |
| 168 | })} | |
| 169 | </div> | |
| 170 | </div> | |
| 171 | ))} | |
| 172 | </div> | |
| 173 | ); | |
| 174 | }; | |
| 175 | ||
| 176 | function parseDiff(raw: string): Array<{ path: string; lines: string[] }> { | |
| 177 | const sections: Array<{ path: string; lines: string[] }> = []; | |
| 178 | const diffRegex = /^diff --git a\/(.+?) b\/.+$/; | |
| 179 | let current: { path: string; lines: string[] } | null = null; | |
| 180 | ||
| 181 | for (const line of raw.split("\n")) { | |
| 182 | const match = line.match(diffRegex); | |
| 183 | if (match) { | |
| 184 | if (current) sections.push(current); | |
| 185 | current = { path: match[1], lines: [] }; | |
| 186 | continue; | |
| 187 | } | |
| 188 | if (current && !line.startsWith("diff --git")) { | |
| 189 | // Skip index/--- /+++ header lines from display | |
| 190 | if ( | |
| 191 | line.startsWith("index ") || | |
| 192 | line.startsWith("--- ") || | |
| 193 | line.startsWith("+++ ") || | |
| 194 | line.startsWith("new file") || | |
| 195 | line.startsWith("deleted file") || | |
| 196 | line.startsWith("old mode") || | |
| 197 | line.startsWith("new mode") | |
| 198 | ) { | |
| 199 | continue; | |
| 200 | } | |
| 201 | current.lines.push(line); | |
| 202 | } | |
| 203 | } | |
| 204 | if (current) sections.push(current); | |
| 205 | return sections; | |
| 206 | } | |
| 207 | ||
| 208 | function formatSize(bytes: number): string { | |
| 209 | if (bytes < 1024) return `${bytes} B`; | |
| 210 | if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; | |
| 211 | return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; | |
| 212 | } | |
| 213 | ||
| 214 | function formatRelativeDate(dateStr: string): string { | |
| 215 | const date = new Date(dateStr); | |
| 216 | const now = new Date(); | |
| 217 | const diffMs = now.getTime() - date.getTime(); | |
| 218 | const diffMins = Math.floor(diffMs / 60000); | |
| 219 | if (diffMins < 1) return "just now"; | |
| 220 | if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`; | |
| 221 | const diffHours = Math.floor(diffMins / 60); | |
| 222 | if (diffHours < 24) | |
| 223 | return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`; | |
| 224 | const diffDays = Math.floor(diffHours / 24); | |
| 225 | if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`; | |
| 226 | return date.toLocaleDateString("en-US", { | |
| 227 | month: "short", | |
| 228 | day: "numeric", | |
| 229 | year: "numeric", | |
| 230 | }); | |
| 231 | } |