CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
web.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 | /** |
| 2 | * Web UI routes — browse repositories, code, commits, diffs. | |
| 3 | */ | |
| 4 | ||
| 5 | import { Hono } from "hono"; | |
| 6 | import { Layout } from "../views/layout"; | |
| 7 | import { | |
| 8 | RepoHeader, | |
| 9 | RepoNav, | |
| 10 | Breadcrumb, | |
| 11 | FileTable, | |
| 12 | CommitList, | |
| 13 | DiffView, | |
| 14 | } from "../views/components"; | |
| 15 | import { | |
| 16 | getTree, | |
| 17 | getBlob, | |
| 18 | listCommits, | |
| 19 | getCommit, | |
| 20 | getCommitFullMessage, | |
| 21 | getDiff, | |
| 22 | getReadme, | |
| 23 | getDefaultBranch, | |
| 24 | listBranches, | |
| 25 | repoExists, | |
| 26 | } from "../git/repository"; | |
| 27 | ||
| 28 | const web = new Hono(); | |
| 29 | ||
| 30 | // Home page | |
| 31 | web.get("/", (c) => { | |
| 32 | return c.html( | |
| 33 | <Layout> | |
| 34 | <div class="empty-state"> | |
| 35 | <h2>gluecron</h2> | |
| 36 | <p>AI-native code intelligence platform</p> | |
| 37 | <pre>{`# Quick start | |
| 38 | curl -X POST http://localhost:3000/api/setup \\ | |
| 39 | -H 'Content-Type: application/json' \\ | |
| 40 | -d '{"username":"you","email":"you@dev.com","repoName":"hello"}' | |
| 41 | ||
| 42 | git remote add gluecron http://localhost:3000/you/hello.git | |
| 43 | git push gluecron main`}</pre> | |
| 44 | </div> | |
| 45 | </Layout> | |
| 46 | ); | |
| 47 | }); | |
| 48 | ||
| 49 | // User profile (list repos) — placeholder | |
| 50 | web.get("/:owner", async (c) => { | |
| 51 | const { owner } = c.req.param(); | |
| 52 | return c.html( | |
| 53 | <Layout title={owner}> | |
| 54 | <h2 style="margin-bottom: 16px">{owner}</h2> | |
| 55 | <p style="color: var(--text-muted)"> | |
| 56 | Repository listing coming soon. Use the API to browse repos. | |
| 57 | </p> | |
| 58 | </Layout> | |
| 59 | ); | |
| 60 | }); | |
| 61 | ||
| 62 | // Repository overview — file tree at HEAD | |
| 63 | web.get("/:owner/:repo", async (c) => { | |
| 64 | const { owner, repo } = c.req.param(); | |
| 65 | ||
| 66 | if (!(await repoExists(owner, repo))) { | |
| 67 | return c.html( | |
| 68 | <Layout title="Not Found"> | |
| 69 | <div class="empty-state"> | |
| 70 | <h2>Repository not found</h2> | |
| 71 | <p> | |
| 72 | {owner}/{repo} does not exist. | |
| 73 | </p> | |
| 74 | </div> | |
| 75 | </Layout>, | |
| 76 | 404 | |
| 77 | ); | |
| 78 | } | |
| 79 | ||
| 80 | const defaultBranch = (await getDefaultBranch(owner, repo)) || "main"; | |
| 81 | const tree = await getTree(owner, repo, defaultBranch); | |
| 82 | ||
| 83 | if (tree.length === 0) { | |
| 84 | return c.html( | |
| 85 | <Layout title={`${owner}/${repo}`}> | |
| 86 | <RepoHeader owner={owner} repo={repo} /> | |
| 87 | <RepoNav owner={owner} repo={repo} active="code" /> | |
| 88 | <div class="empty-state"> | |
| 89 | <h2>Empty repository</h2> | |
| 90 | <p>Get started by pushing code:</p> | |
| 91 | <pre>{`git remote add gluecron http://localhost:3000/${owner}/${repo}.git | |
| 92 | git push -u gluecron main`}</pre> | |
| 93 | </div> | |
| 94 | </Layout> | |
| 95 | ); | |
| 96 | } | |
| 97 | ||
| 98 | const readme = await getReadme(owner, repo, defaultBranch); | |
| 99 | ||
| 100 | return c.html( | |
| 101 | <Layout title={`${owner}/${repo}`}> | |
| 102 | <RepoHeader owner={owner} repo={repo} /> | |
| 103 | <RepoNav owner={owner} repo={repo} active="code" /> | |
| 104 | <div class="branch-selector">{defaultBranch}</div> | |
| 105 | <FileTable | |
| 106 | entries={tree} | |
| 107 | owner={owner} | |
| 108 | repo={repo} | |
| 109 | ref={defaultBranch} | |
| 110 | path="" | |
| 111 | /> | |
| 112 | {readme && ( | |
| 113 | <div | |
| 114 | class="blob-view" | |
| 115 | style="margin-top: 20px" | |
| 116 | > | |
| 117 | <div class="blob-header">README.md</div> | |
| 118 | <div style="padding: 16px; white-space: pre-wrap; font-size: 14px;"> | |
| 119 | {readme} | |
| 120 | </div> | |
| 121 | </div> | |
| 122 | )} | |
| 123 | </Layout> | |
| 124 | ); | |
| 125 | }); | |
| 126 | ||
| 127 | // Browse tree at ref/path | |
| 128 | web.get("/:owner/:repo/tree/:ref{.+$}", async (c) => { | |
| 129 | const { owner, repo } = c.req.param(); | |
| 130 | const refAndPath = c.req.param("ref"); | |
| 131 | ||
| 132 | // Parse ref from path — try known branches first, fallback to first segment | |
| 133 | const branches = await listBranches(owner, repo); | |
| 134 | let ref = ""; | |
| 135 | let treePath = ""; | |
| 136 | ||
| 137 | for (const branch of branches) { | |
| 138 | if (refAndPath === branch || refAndPath.startsWith(branch + "/")) { | |
| 139 | ref = branch; | |
| 140 | treePath = refAndPath.slice(branch.length + 1); | |
| 141 | break; | |
| 142 | } | |
| 143 | } | |
| 144 | ||
| 145 | if (!ref) { | |
| 146 | // Assume first path segment is the ref | |
| 147 | const slashIdx = refAndPath.indexOf("/"); | |
| 148 | if (slashIdx === -1) { | |
| 149 | ref = refAndPath; | |
| 150 | } else { | |
| 151 | ref = refAndPath.slice(0, slashIdx); | |
| 152 | treePath = refAndPath.slice(slashIdx + 1); | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 156 | const tree = await getTree(owner, repo, ref, treePath); | |
| 157 | ||
| 158 | return c.html( | |
| 159 | <Layout title={`${treePath || "/"} — ${owner}/${repo}`}> | |
| 160 | <RepoHeader owner={owner} repo={repo} /> | |
| 161 | <RepoNav owner={owner} repo={repo} active="code" /> | |
| 162 | <div class="branch-selector">{ref}</div> | |
| 163 | <Breadcrumb owner={owner} repo={repo} ref={ref} path={treePath} /> | |
| 164 | <FileTable | |
| 165 | entries={tree} | |
| 166 | owner={owner} | |
| 167 | repo={repo} | |
| 168 | ref={ref} | |
| 169 | path={treePath} | |
| 170 | /> | |
| 171 | </Layout> | |
| 172 | ); | |
| 173 | }); | |
| 174 | ||
| 175 | // View file blob | |
| 176 | web.get("/:owner/:repo/blob/:ref{.+$}", async (c) => { | |
| 177 | const { owner, repo } = c.req.param(); | |
| 178 | const refAndPath = c.req.param("ref"); | |
| 179 | ||
| 180 | const branches = await listBranches(owner, repo); | |
| 181 | let ref = ""; | |
| 182 | let filePath = ""; | |
| 183 | ||
| 184 | for (const branch of branches) { | |
| 185 | if (refAndPath.startsWith(branch + "/")) { | |
| 186 | ref = branch; | |
| 187 | filePath = refAndPath.slice(branch.length + 1); | |
| 188 | break; | |
| 189 | } | |
| 190 | } | |
| 191 | ||
| 192 | if (!ref) { | |
| 193 | const slashIdx = refAndPath.indexOf("/"); | |
| 194 | if (slashIdx === -1) return c.text("Not found", 404); | |
| 195 | ref = refAndPath.slice(0, slashIdx); | |
| 196 | filePath = refAndPath.slice(slashIdx + 1); | |
| 197 | } | |
| 198 | ||
| 199 | const blob = await getBlob(owner, repo, ref, filePath); | |
| 200 | if (!blob) { | |
| 201 | return c.html( | |
| 202 | <Layout title="Not Found"> | |
| 203 | <div class="empty-state"> | |
| 204 | <h2>File not found</h2> | |
| 205 | </div> | |
| 206 | </Layout>, | |
| 207 | 404 | |
| 208 | ); | |
| 209 | } | |
| 210 | ||
| 211 | const lines = blob.content.split("\n"); | |
| 212 | // Remove trailing empty line from split | |
| 213 | if (lines[lines.length - 1] === "") lines.pop(); | |
| 214 | ||
| 215 | return c.html( | |
| 216 | <Layout title={`${filePath} — ${owner}/${repo}`}> | |
| 217 | <RepoHeader owner={owner} repo={repo} /> | |
| 218 | <RepoNav owner={owner} repo={repo} active="code" /> | |
| 219 | <div class="branch-selector">{ref}</div> | |
| 220 | <Breadcrumb owner={owner} repo={repo} ref={ref} path={filePath} /> | |
| 221 | <div class="blob-view"> | |
| 222 | <div class="blob-header"> | |
| 223 | {filePath.split("/").pop()} — {blob.size} bytes | |
| 224 | </div> | |
| 225 | {blob.isBinary ? ( | |
| 226 | <div style="padding: 16px; color: var(--text-muted)"> | |
| 227 | Binary file not shown. | |
| 228 | </div> | |
| 229 | ) : ( | |
| 230 | <div class="blob-code"> | |
| 231 | <table> | |
| 232 | <tbody> | |
| 233 | {lines.map((line, i) => ( | |
| 234 | <tr> | |
| 235 | <td class="line-num">{i + 1}</td> | |
| 236 | <td class="line-content">{line}</td> | |
| 237 | </tr> | |
| 238 | ))} | |
| 239 | </tbody> | |
| 240 | </table> | |
| 241 | </div> | |
| 242 | )} | |
| 243 | </div> | |
| 244 | </Layout> | |
| 245 | ); | |
| 246 | }); | |
| 247 | ||
| 248 | // Commit log | |
| 249 | web.get("/:owner/:repo/commits/:ref?", async (c) => { | |
| 250 | const { owner, repo } = c.req.param(); | |
| 251 | const ref = | |
| 252 | c.req.param("ref") || (await getDefaultBranch(owner, repo)) || "main"; | |
| 253 | ||
| 254 | const commits = await listCommits(owner, repo, ref, 50); | |
| 255 | ||
| 256 | return c.html( | |
| 257 | <Layout title={`Commits — ${owner}/${repo}`}> | |
| 258 | <RepoHeader owner={owner} repo={repo} /> | |
| 259 | <RepoNav owner={owner} repo={repo} active="commits" /> | |
| 260 | <div class="branch-selector">{ref}</div> | |
| 261 | {commits.length === 0 ? ( | |
| 262 | <div class="empty-state"> | |
| 263 | <p>No commits yet.</p> | |
| 264 | </div> | |
| 265 | ) : ( | |
| 266 | <CommitList commits={commits} owner={owner} repo={repo} /> | |
| 267 | )} | |
| 268 | </Layout> | |
| 269 | ); | |
| 270 | }); | |
| 271 | ||
| 272 | // Single commit with diff | |
| 273 | web.get("/:owner/:repo/commit/:sha", async (c) => { | |
| 274 | const { owner, repo, sha } = c.req.param(); | |
| 275 | ||
| 276 | const commit = await getCommit(owner, repo, sha); | |
| 277 | if (!commit) { | |
| 278 | return c.html( | |
| 279 | <Layout title="Not Found"> | |
| 280 | <div class="empty-state"> | |
| 281 | <h2>Commit not found</h2> | |
| 282 | </div> | |
| 283 | </Layout>, | |
| 284 | 404 | |
| 285 | ); | |
| 286 | } | |
| 287 | ||
| 288 | const fullMessage = await getCommitFullMessage(owner, repo, sha); | |
| 289 | const { files, raw } = await getDiff(owner, repo, sha); | |
| 290 | ||
| 291 | return c.html( | |
| 292 | <Layout title={`${commit.message} — ${owner}/${repo}`}> | |
| 293 | <RepoHeader owner={owner} repo={repo} /> | |
| 294 | <div | |
| 295 | style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px" | |
| 296 | > | |
| 297 | <div style="font-size: 18px; font-weight: 600; margin-bottom: 8px"> | |
| 298 | {commit.message} | |
| 299 | </div> | |
| 300 | {fullMessage !== commit.message && ( | |
| 301 | <div style="white-space: pre-wrap; color: var(--text-muted); font-size: 14px; margin-bottom: 12px"> | |
| 302 | {fullMessage} | |
| 303 | </div> | |
| 304 | )} | |
| 305 | <div style="font-size: 13px; color: var(--text-muted)"> | |
| 306 | <strong style="color: var(--text)">{commit.author}</strong>{" "} | |
| 307 | committed on{" "} | |
| 308 | {new Date(commit.date).toLocaleDateString("en-US", { | |
| 309 | month: "long", | |
| 310 | day: "numeric", | |
| 311 | year: "numeric", | |
| 312 | })} | |
| 313 | </div> | |
| 314 | <div style="margin-top: 8px"> | |
| 315 | <span class="commit-sha">{commit.sha}</span> | |
| 316 | {commit.parentShas.length > 0 && ( | |
| 317 | <span style="margin-left: 12px; font-size: 13px; color: var(--text-muted)"> | |
| 318 | Parent:{" "} | |
| 319 | {commit.parentShas.map((p) => ( | |
| 320 | <a | |
| 321 | href={`/${owner}/${repo}/commit/${p}`} | |
| 322 | class="commit-sha" | |
| 323 | style="margin-left: 4px" | |
| 324 | > | |
| 325 | {p.slice(0, 7)} | |
| 326 | </a> | |
| 327 | ))} | |
| 328 | </span> | |
| 329 | )} | |
| 330 | </div> | |
| 331 | </div> | |
| 332 | <DiffView raw={raw} files={files} /> | |
| 333 | </Layout> | |
| 334 | ); | |
| 335 | }); | |
| 336 | ||
| 337 | export default web; |