File
Blob: src/worker/routes/ui/tree.ts
| 1 | import type { TreeEntry } from "@/worker/git"; |
| 2 | import { isSymlinkMode, isTreeMode, readPath } from "@/worker/git"; |
| 3 | import { |
| 4 | isValidRef, |
| 5 | isValidPath, |
| 6 | bytesToText, |
| 7 | getFileIconName, |
| 8 | getHighlightLangsForBlobSmart, |
| 9 | type FileIconName, |
| 10 | } from "@/shared/web"; |
| 11 | import { renderUiView } from "@/client/server/render"; |
| 12 | import { handleError } from "@/client/server/error"; |
| 13 | import { buildCacheKeyFrom, cacheOrLoadJSONForRequestWithTTL } from "@/worker/cache"; |
| 14 | import { badRequest, isRequestPrivate, loadUiRepoActivity, resolveUiRepoAccess } from "./helpers"; |
| 15 | import type { AppContext } from "../hono"; |
| 16 | import { renderUiDocumentResponse } from "../uiResponse"; |
| 17 | import type { ReadPathResult } from "@/worker/git"; |
| 18 | |
| 19 | export async function handleTree(c: AppContext<"/:owner/:repo/tree">) { |
| 20 | const env = c.env; |
| 21 | const owner = c.req.param("owner"); |
| 22 | const repo = c.req.param("repo"); |
| 23 | const access = await resolveUiRepoAccess(c, owner, repo); |
| 24 | if (access.kind === "response") return access.response; |
| 25 | const { route, cacheCtx } = access; |
| 26 | const repoId = route.doName; |
| 27 | const isPrivate = isRequestPrivate(cacheCtx); |
| 28 | const u = new URL(c.req.url); |
| 29 | const ref = u.searchParams.get("ref") || "main"; |
| 30 | const path = u.searchParams.get("path") || ""; |
| 31 | if (!isValidRef(ref)) { |
| 32 | return badRequest(env, "Invalid ref", "Ref format not allowed", { |
| 33 | owner, |
| 34 | repo, |
| 35 | refEnc: encodeURIComponent(ref), |
| 36 | path, |
| 37 | }); |
| 38 | } |
| 39 | if (path && !isValidPath(path)) { |
| 40 | return badRequest(env, "Invalid path", "Path contains invalid characters or is too long", { |
| 41 | owner, |
| 42 | repo, |
| 43 | refEnc: encodeURIComponent(ref), |
| 44 | path, |
| 45 | }); |
| 46 | } |
| 47 | |
| 48 | const loadTree = async (): Promise<ReadPathResult | null> => { |
| 49 | try { |
| 50 | return await readPath(env, repoId, ref, path, cacheCtx); |
| 51 | } catch { |
| 52 | return null; |
| 53 | } |
| 54 | }; |
| 55 | const cacheKeyTree = buildCacheKeyFrom(c.req.raw, "/_cache/tree", { |
| 56 | repo: repoId, |
| 57 | ref, |
| 58 | path, |
| 59 | }); |
| 60 | const result = await cacheOrLoadJSONForRequestWithTTL<ReadPathResult | null>( |
| 61 | cacheCtx, |
| 62 | cacheKeyTree, |
| 63 | loadTree, |
| 64 | (value) => (value && value.type === "tree" ? 60 : 300) |
| 65 | ); |
| 66 | |
| 67 | // Handle missing tree/blob result gracefully (path not found inside an |
| 68 | // existing repo). Repo-not-found is already handled by `resolveUiRepoAccess` |
| 69 | // upstream. |
| 70 | if (!result) { |
| 71 | try { |
| 72 | const errHtml = await renderUiView( |
| 73 | env, |
| 74 | "error", |
| 75 | { |
| 76 | title: `${owner}/${repo} · Tree`, |
| 77 | message: "Not found", |
| 78 | owner, |
| 79 | repo, |
| 80 | refEnc: encodeURIComponent(ref), |
| 81 | path, |
| 82 | }, |
| 83 | { viewer: access.viewer } |
| 84 | ); |
| 85 | if (errHtml) { |
| 86 | return new Response(errHtml, { |
| 87 | status: 404, |
| 88 | headers: { |
| 89 | "Content-Type": "text/html; charset=utf-8", |
| 90 | "Cache-Control": isPrivate ? "no-store" : "no-store, no-cache, must-revalidate", |
| 91 | }, |
| 92 | }); |
| 93 | } |
| 94 | } catch {} |
| 95 | return new Response("Not found\n", { status: 404 }); |
| 96 | } |
| 97 | |
| 98 | try { |
| 99 | if (result.type === "tree") { |
| 100 | // Format tree entries as structured data |
| 101 | let entries: Array<{ |
| 102 | name: string; |
| 103 | href: string; |
| 104 | isDir: boolean; |
| 105 | isSymlink: boolean; |
| 106 | iconName: FileIconName; |
| 107 | shortOid: string; |
| 108 | size: string; |
| 109 | }> = []; |
| 110 | if (result.type === "tree" && result.entries) { |
| 111 | const sorted = result.entries.sort((a: TreeEntry, b: TreeEntry) => { |
| 112 | const aIsDir = isTreeMode(a.mode); |
| 113 | const bIsDir = isTreeMode(b.mode); |
| 114 | if (aIsDir !== bIsDir) return aIsDir ? -1 : 1; |
| 115 | return a.name.localeCompare(b.name); |
| 116 | }); |
| 117 | entries = sorted.map((e: TreeEntry) => { |
| 118 | const isDir = isTreeMode(e.mode); |
| 119 | // Git stores symlinks as blobs with mode 120000. Keep their file-like |
| 120 | // navigation behavior, but give browsers a distinct tree icon. |
| 121 | const isSymlink = isSymlinkMode(e.mode); |
| 122 | return { |
| 123 | name: e.name, |
| 124 | href: isDir |
| 125 | ? `/${owner}/${repo}/tree?ref=${encodeURIComponent(ref)}&path=${encodeURIComponent( |
| 126 | (path ? path + "/" : "") + e.name |
| 127 | )}` |
| 128 | : `/${owner}/${repo}/blob?ref=${encodeURIComponent(ref)}&path=${encodeURIComponent( |
| 129 | (path ? path + "/" : "") + e.name |
| 130 | )}`, |
| 131 | isDir, |
| 132 | isSymlink, |
| 133 | iconName: isSymlink ? "symlink" : isDir ? "folder" : getFileIconName(e.name), |
| 134 | shortOid: e.oid ? e.oid.slice(0, 7) : "", |
| 135 | size: "", // Size not available in tree entries, would need separate lookup |
| 136 | }; |
| 137 | }); |
| 138 | } |
| 139 | // Generate breadcrumbs and parent link |
| 140 | const parts = (path || "").split("/").filter(Boolean); |
| 141 | // Truncate ref if too long (e.g., commit hashes) |
| 142 | const refDisplay = ref.length > 20 ? ref.slice(0, 7) + "..." : ref; |
| 143 | const breadcrumbs = [ |
| 144 | { |
| 145 | name: refDisplay, |
| 146 | href: parts.length > 0 ? `/${owner}/${repo}/tree?ref=${encodeURIComponent(ref)}` : null, |
| 147 | }, |
| 148 | ...parts.map((part, i) => { |
| 149 | const subPath = parts.slice(0, i + 1).join("/"); |
| 150 | const isLast = i === parts.length - 1; |
| 151 | return { |
| 152 | name: part, |
| 153 | href: isLast |
| 154 | ? null |
| 155 | : `/${owner}/${repo}/tree?ref=${encodeURIComponent(ref)}&path=${encodeURIComponent(subPath)}`, |
| 156 | }; |
| 157 | }), |
| 158 | ]; |
| 159 | const parentHref = |
| 160 | parts.length > 0 |
| 161 | ? `/${owner}/${repo}/tree?ref=${encodeURIComponent(ref)}&path=${encodeURIComponent(parts.slice(0, -1).join("/"))}` |
| 162 | : null; |
| 163 | const progress = await loadUiRepoActivity(env, access); |
| 164 | const viewer = access.viewer; |
| 165 | return renderUiDocumentResponse( |
| 166 | env, |
| 167 | "tree", |
| 168 | { |
| 169 | title: `${path || "root"} · ${owner}/${repo}`, |
| 170 | owner, |
| 171 | repo, |
| 172 | refEnc: encodeURIComponent(ref), |
| 173 | progress, |
| 174 | breadcrumbs, |
| 175 | parentHref, |
| 176 | entries, |
| 177 | }, |
| 178 | { |
| 179 | cacheControl: isPrivate ? "no-store" : undefined, |
| 180 | failureBody: "Failed to render view", |
| 181 | viewer, |
| 182 | } |
| 183 | ); |
| 184 | } else { |
| 185 | const raw = `/${owner}/${repo}/raw?oid=${encodeURIComponent(result.oid)}`; |
| 186 | const text = bytesToText(result.content); |
| 187 | const lineCount = text === "" ? 0 : text.split(/\r?\n/).length; |
| 188 | const title = path || result.oid; |
| 189 | const langs = getHighlightLangsForBlobSmart(title, text); |
| 190 | const codeLang = langs[0] || null; |
| 191 | const progress = await loadUiRepoActivity(env, access); |
| 192 | const viewer = access.viewer; |
| 193 | return renderUiDocumentResponse( |
| 194 | env, |
| 195 | "blob", |
| 196 | { |
| 197 | title: `${title} · ${owner}/${repo}`, |
| 198 | owner, |
| 199 | repo, |
| 200 | refEnc: encodeURIComponent(ref), |
| 201 | progress, |
| 202 | fileName: title, |
| 203 | viewRawHref: `/${owner}/${repo}/raw?oid=${encodeURIComponent(result.oid)}&view=1&name=${encodeURIComponent(title)}`, |
| 204 | rawHref: raw, |
| 205 | codeText: text, |
| 206 | codeLang, |
| 207 | lineCount, |
| 208 | }, |
| 209 | { |
| 210 | cacheControl: isPrivate ? "no-store" : undefined, |
| 211 | failureBody: "Failed to render view", |
| 212 | viewer, |
| 213 | } |
| 214 | ); |
| 215 | } |
| 216 | } catch (e) { |
| 217 | return handleError(env, e, `${owner}/${repo} · Tree`, { |
| 218 | owner, |
| 219 | repo, |
| 220 | refEnc: encodeURIComponent(ref), |
| 221 | path, |
| 222 | }); |
| 223 | } |
| 224 | } |