Skip to content
File

Blob: src/worker/routes/ui/tree.ts

typescript225 lines
1import type { TreeEntry } from "@/worker/git";
2import { isSymlinkMode, isTreeMode, readPath } from "@/worker/git";
3import {
4 isValidRef,
5 isValidPath,
6 bytesToText,
7 getFileIconName,
8 getHighlightLangsForBlobSmart,
9 type FileIconName,
10} from "@/shared/web";
11import { renderUiView } from "@/client/server/render";
12import { handleError } from "@/client/server/error";
13import { buildCacheKeyFrom, cacheOrLoadJSONForRequestWithTTL } from "@/worker/cache";
14import { badRequest, isRequestPrivate, loadUiRepoActivity, resolveUiRepoAccess } from "./helpers";
15import type { AppContext } from "../hono";
16import { renderUiDocumentResponse } from "../uiResponse";
17import type { ReadPathResult } from "@/worker/git";
18 
19export 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}