forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
10.5 KiB
1//! Page: file view at `/{owner}/{repo}/blob/{*rest}`. Text is highlighted with line anchors,
2//! markdown is rendered (`?plain=1` shows the source), images use the raw origin, and binary,
3//! symlink and submodule entries get a short notice.
4
5use forge_git::browse::EntryKind;
6use topcoat::Result;
7use topcoat::context::{Cx, app_context};
8use topcoat::router::error::not_found;
9use topcoat::router::{page, path_param, query_params};
10use topcoat::view::{Unescaped, View, component, view};
11
12use crate::i18n::code::{self as g, K};
13use crate::i18n::{self};
14use crate::lfs::has_object;
15use crate::lfs_pointer;
16use crate::pages::media::media_url;
17use crate::pages::repo::repo_head;
18use crate::pages::repo_ctx::load_repo;
19use crate::pages::tree::{link_context, path_crumbs, read_file, resolve};
20use crate::raw::raw_url;
21use crate::render::highlight::highlight_lines;
22use crate::render::markdown::render_markdown;
23use crate::state::AppState;
24use crate::views::{Crumb, breadcrumb, human_size, repo_href, shell};
25
26const IMAGE_EXTS: [&str; 8] = ["png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "ico"];
27const MARKDOWN_EXTS: [&str; 2] = ["md", "markdown"];
28
29path_param!(owner);
30path_param!(repo);
31path_param!(*rest);
32
33#[query_params(error = bad_request)]
34struct BlobQuery {
35 plain: Option<String>,
36}
37
38/// One line of a text file. `highlighted` is true when `code` is trusted highlighter output.
39pub struct Line {
40 pub num: String,
41 pub code: String,
42 pub highlighted: bool,
43}
44
45/// What the page shows for the file. `kind` is one of: image, submodule, symlink, binary,
46/// markdown, text.
47struct Viewer {
48 kind: &'static str,
49 /// Notice text: the symlink target, the submodule commit, or the binary size.
50 note: String,
51 /// Rendered markdown (trusted output of `render_markdown`).
52 html: String,
53 lines: Vec<Line>,
54 truncated: bool,
55}
56
57fn extension(name: &str) -> String {
58 name.rsplit_once('.')
59 .map(|(_, ext)| ext.to_ascii_lowercase())
60 .unwrap_or_default()
61}
62
63fn text_lines(text: &str, file_name: &str) -> Vec<Line> {
64 let plain: Vec<&str> = text.lines().collect();
65 let highlighted = highlight_lines(file_name, text).filter(|h| h.len() == plain.len());
66 plain
67 .iter()
68 .enumerate()
69 .map(|(i, line)| match &highlighted {
70 Some(html) => Line {
71 num: (i + 1).to_string(),
72 code: html[i].clone(),
73 highlighted: true,
74 },
75 None => Line {
76 num: (i + 1).to_string(),
77 code: (*line).to_owned(),
78 highlighted: false,
79 },
80 })
81 .collect()
82}
83
84#[component]
85async fn file_table(lines: Vec<Line>) -> Result<impl View> {
86 Ok(view! {
87 <div class="file-view">
88 <table class="file-table">
89 <tbody>
90 for line in lines.into_iter() {
91 <tr id=(format!("L{}", line.num))>
92 <td class="ln"><a href=(format!("#L{}", line.num))>(line.num)</a></td>
93 <td class="code">
94 if line.highlighted {
95 (Unescaped::new_unchecked(line.code))
96 } else {
97 (line.code)
98 }
99 </td>
100 </tr>
101 }
102 </tbody>
103 </table>
104 </div>
105 })
106}
107
108#[page("/{owner}/{repo}/blob/{*rest}")]
109async fn blob_page(cx: &Cx) -> Result<impl View> {
110 let owner = path_param::<Owner>(cx);
111 let name = path_param::<Repo>(cx);
112 let rest = path_param::<Rest>(cx).collect::<Vec<_>>().join("/");
113 let plain = query_params::<BlobQuery>(cx)?.plain.as_deref() == Some("1");
114
115 let ctx = load_repo(cx, owner, name).await?;
116 let state = app_context::<AppState>(cx);
117 let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user));
118 let repo = &ctx.repo;
119
120 let Some((ref_name, commit, file)) = resolve(ctx.path.clone(), rest).await? else {
121 return Err(not_found().into());
122 };
123 let Some(blob) = read_file(ctx.path.clone(), commit, file.clone()).await? else {
124 return Err(not_found().into());
125 };
126
127 let file_name = file.rsplit('/').next().unwrap_or(&file).to_owned();
128 let dir = file
129 .rsplit_once('/')
130 .map(|(d, _)| d)
131 .unwrap_or("")
132 .to_owned();
133 let ext = extension(&file_name);
134 let is_markdown = MARKDOWN_EXTS.contains(&ext.as_str());
135
136 // A Git LFS pointer is a small text blob that names an object the forge stores.
137 let pointer = match blob.kind {
138 EntryKind::Submodule | EntryKind::Symlink => None,
139 _ => lfs_pointer::parse(&blob.data),
140 };
141 let (lfs_note, lfs_href) = match &pointer {
142 Some(pointer) => {
143 let size = human_size(lang, pointer.size);
144 match has_object(state, &repo.id, &pointer.oid).await {
145 Ok(Some(_)) => (
146 Some(g::lfs_stored(lang, &size)),
147 Some(media_url(state, repo, &ref_name, &file)),
148 ),
149 Ok(None) => (Some(g::lfs_missing(lang, &size)), None),
150 Err(e) => {
151 tracing::warn!(error = %e, "blob: LFS object link lookup failed");
152 (Some(g::lfs_stored(lang, &size)), None)
153 }
154 }
155 }
156 None => (None, None),
157 };
158
159 let viewer = match blob.kind {
160 EntryKind::Submodule => Viewer {
161 kind: "submodule",
162 note: blob.oid.clone(),
163 html: String::new(),
164 lines: Vec::new(),
165 truncated: false,
166 },
167 EntryKind::Symlink => Viewer {
168 kind: "symlink",
169 note: String::from_utf8_lossy(&blob.data).into_owned(),
170 html: String::new(),
171 lines: Vec::new(),
172 truncated: false,
173 },
174 _ if lfs_note.is_some() => Viewer {
175 kind: "lfs",
176 note: lfs_note.clone().unwrap_or_default(),
177 html: String::new(),
178 lines: Vec::new(),
179 truncated: false,
180 },
181 _ if IMAGE_EXTS.contains(&ext.as_str()) => Viewer {
182 kind: "image",
183 note: String::new(),
184 html: String::new(),
185 lines: Vec::new(),
186 truncated: blob.truncated,
187 },
188 _ if blob.is_binary => Viewer {
189 kind: "binary",
190 note: g::binary_note(lang, blob.size),
191 html: String::new(),
192 lines: Vec::new(),
193 truncated: false,
194 },
195 _ => {
196 let text = String::from_utf8_lossy(&blob.data).into_owned();
197 if is_markdown && !plain {
198 let link_ctx = link_context(state, repo, &ref_name, &dir);
199 Viewer {
200 kind: "markdown",
201 note: String::new(),
202 html: render_markdown(&text, Some(&link_ctx)),
203 lines: Vec::new(),
204 truncated: blob.truncated,
205 }
206 } else {
207 Viewer {
208 kind: "text",
209 note: String::new(),
210 html: String::new(),
211 lines: text_lines(&text, &file_name),
212 truncated: blob.truncated,
213 }
214 }
215 }
216 };
217
218 let raw = raw_url(state, repo, &ref_name, &file);
219 let base = repo_href(
220 &repo.owner_name,
221 &repo.name,
222 "blob",
223 &format!("{ref_name}/{file}"),
224 );
225 let history = repo_href(
226 &repo.owner_name,
227 &repo.name,
228 "commits",
229 &format!("{ref_name}/{file}"),
230 );
231 let toggle = if !is_markdown {
232 None
233 } else if plain {
234 Some((g::t(lang, K::Rendered), base.clone()))
235 } else {
236 Some((g::t(lang, K::SourceToggle), format!("{base}?plain=1")))
237 };
238 let crumbs: Vec<Crumb> = path_crumbs(repo, &ref_name, "blob", &file);
239 let title = format!("{}/{} \u{b7} {file}", repo.owner_name, repo.name);
240 let size = human_size(lang, blob.size);
241
242 let kind = viewer.kind;
243 let note = viewer.note;
244 let html = viewer.html;
245 let lines = viewer.lines;
246 let truncated = viewer.truncated;
247 let image_alt = file_name.clone();
248 let download_name = file_name.clone();
249 let download_href = lfs_href.clone().unwrap_or_else(|| raw.clone());
250
251 let header = ctx.head(state, "code").await?;
252 Ok(view! {
253 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(),
254 repo_head(head: header)
255 <div class="browse-bar">
256 breadcrumb(lang: lang, crumbs: crumbs)
257 </div>
258 <section class="file">
259 <header class="file-head">
260 <span class="muted small">(size)</span>
261 <nav class="file-actions" aria-label=(g::t(lang, K::FileNav))>
262 if let Some((label, href)) = toggle {
263 <a href=(href)>(label)</a>
264 }
265 <a href=(history)>(g::t(lang, K::HistoryLink))</a>
266 <a href=(raw.clone())>(g::t(lang, K::RawLink))</a>
267 <a href=(download_href) download=(download_name)>(g::t(lang, K::DownloadLink))</a>
268 </nav>
269 </header>
270 if kind == "image" {
271 <div class="file-image">
272 <img src=(raw.clone()) alt=(image_alt)>
273 </div>
274 }
275 if kind == "markdown" {
276 <div class="markdown-body">(Unescaped::new_unchecked(html))</div>
277 }
278 if kind == "text" {
279 file_table(lines: lines)
280 }
281 if kind == "lfs" {
282 <p class="notice">(note.clone())</p>
283 }
284 if kind == "binary" {
285 <p class="notice">(note.clone())</p>
286 }
287 if kind == "symlink" {
288 <p class="notice">(g::t(lang, K::SymlinkPrefix)) <code>(note.clone())</code></p>
289 }
290 if kind == "submodule" {
291 <p class="notice">(g::t(lang, K::SubmodulePrefix)) <code>(note.clone())</code> (g::t(lang, K::SubmoduleSuffix))</p>
292 }
293 if truncated {
294 <p class="notice">(g::t(lang, K::TruncatedPrefix))
295 <a href=(raw)>(g::t(lang, K::ViewFullRaw))</a> "."</p>
296 }
297 </section>
298 )
299 })
300}