| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | use forge_git::browse::EntryKind; |
| 6 | use topcoat::Result; |
| 7 | use topcoat::context::{Cx, app_context}; |
| 8 | use topcoat::router::error::not_found; |
| 9 | use topcoat::router::{page, path_param, query_params}; |
| 10 | use topcoat::view::{Unescaped, View, component, view}; |
| 11 | |
| 12 | use crate::i18n::code::{self as g, K}; |
| 13 | use crate::i18n::{self}; |
| 14 | use crate::lfs::has_object; |
| 15 | use crate::lfs_pointer; |
| 16 | use crate::pages::media::media_url; |
| 17 | use crate::pages::repo::repo_head; |
| 18 | use crate::pages::repo_ctx::load_repo; |
| 19 | use crate::pages::tree::{link_context, path_crumbs, read_file, resolve}; |
| 20 | use crate::raw::raw_url; |
| 21 | use crate::render::highlight::highlight_lines; |
| 22 | use crate::render::markdown::render_markdown; |
| 23 | use crate::state::AppState; |
| 24 | use crate::views::{Crumb, breadcrumb, human_size, repo_href, shell}; |
| 25 | |
| 26 | const IMAGE_EXTS: [&str; 8] = ["png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "ico"]; |
| 27 | const MARKDOWN_EXTS: [&str; 2] = ["md", "markdown"]; |
| 28 | |
| 29 | path_param!(owner); |
| 30 | path_param!(repo); |
| 31 | path_param!(*rest); |
| 32 | |
| 33 | #[query_params(error = bad_request)] |
| 34 | struct BlobQuery { |
| 35 | plain: Option<String>, |
| 36 | } |
| 37 | |
| 38 | |
| 39 | pub struct Line { |
| 40 | pub num: String, |
| 41 | pub code: String, |
| 42 | pub highlighted: bool, |
| 43 | } |
| 44 | |
| 45 | |
| 46 | |
| 47 | struct Viewer { |
| 48 | kind: &'static str, |
| 49 | |
| 50 | note: String, |
| 51 | |
| 52 | html: String, |
| 53 | lines: Vec<Line>, |
| 54 | truncated: bool, |
| 55 | } |
| 56 | |
| 57 | fn extension(name: &str) -> String { |
| 58 | name.rsplit_once('.') |
| 59 | .map(|(_, ext)| ext.to_ascii_lowercase()) |
| 60 | .unwrap_or_default() |
| 61 | } |
| 62 | |
| 63 | fn 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] |
| 85 | async 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}")] |
| 109 | async 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 | |
| 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 | } |