Parent
[>]
1//! Tree browser page. 2//! 3//! Covers two routes: `/{repo}/commits/{sha}/tree` and 4//! `/{repo}/commits/{sha}/tree/{*path}`. Renders a directory listing for the 5//! given tree object, with file entries syntax-highlighted inline and linked to 6//! the raw handler. Sub-directory entries are linked to deeper tree pages. 7 8use std::fmt::Write; 9use std::sync::LazyLock; 10 11use askama::Template; 12use askama_web::WebTemplate; 13use axum::extract::{Path, State}; 14use axum::response::IntoResponse; 15use gix::bstr::ByteSlice; 16use serde::Deserialize; 17use syntect::html::{ClassStyle, ClassedHTMLGenerator}; 18use syntect::parsing::SyntaxSet; 19 20use crate::config::SiteConfig; 21use crate::error::{Error, Result}; 22use crate::git::{self, GitResultExt as _}; 23use crate::routes::{AppState, Breadcrumb, RepoName}; 24 25/// Syntax definitions loaded once and reused for every file view. 26static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines); 27 28/// Path parameters for the tree page routes. 29#[derive(Deserialize)] 30pub(super) struct TreePath { 31 sha: String, 32 #[serde(default)] 33 path: Option<String>, 34} 35 36/// A single entry in a directory listing. 37struct TreeEntry { 38 name: String, 39 is_dir: bool, 40 url: String, 41 size: String, 42} 43 44/// Formats a byte count as a human-readable string. 45#[allow(clippy::cast_precision_loss)] 46fn format_size(bytes: u64) -> String { 47 const UNITS: &[&str] = &["B", "KB", "MB", "GB"]; 48 let mut size = bytes as f64; 49 let mut unit_idx = 0; 50 while size >= 1024.0 && unit_idx < UNITS.len() - 1 { 51 size /= 1024.0; 52 unit_idx += 1; 53 } 54 if unit_idx == 0 { 55 format!("{} {}", bytes, UNITS[unit_idx]) 56 } else { 57 format!("{:.1} {}", size, UNITS[unit_idx]) 58 } 59} 60 61/// Template context for the tree/file page. 62#[derive(Template, WebTemplate)] 63#[template(path = "tree.html")] 64struct TreeTemplate { 65 repo: String, 66 sha: String, 67 path: String, 68 breadcrumbs: Vec<Breadcrumb>, 69 is_file: bool, 70 entries: Vec<TreeEntry>, 71 content_html: String, 72 raw_url: String, 73 parent_url: String, 74 site: std::sync::Arc<SiteConfig>, 75} 76 77/// Renders a tree or file at `{sha}:{path}` in the given repository. 78#[allow(clippy::too_many_lines)] 79pub async fn handler( 80 State(state): State<AppState>, 81 RepoName(name): RepoName, 82 Path(params): Path<TreePath>, 83) -> Result<impl IntoResponse> { 84 let path = params.path.unwrap_or_default(); 85 86 if !path.is_empty() { 87 git::validate_path(&path)?; 88 } 89 90 let git_repo = git::open_repo(&state.root, &name)?; 91 92 let (object, resolved_path) = if path.is_empty() { 93 // When path is empty, resolve the sha to a commit and get its tree. 94 let oid = gix::ObjectId::from_hex(params.sha.as_bytes()) 95 .map_err(|_| Error::BadRequest(format!("invalid sha: {}", params.sha)))?; 96 let commit = git_repo 97 .find_object(oid) 98 .map_err(|_| Error::NotFound(params.sha.clone()))? 99 .try_into_commit() 100 .map_err(|_| Error::BadRequest(format!("{} is not a commit", params.sha)))?; 101 let tree_id = commit.tree_id().corrupt()?; 102 let tree = git_repo.find_object(tree_id).corrupt()?; 103 (tree, String::new()) 104 } else { 105 let spec = format!("{}:{}", params.sha, path); 106 let obj = git_repo 107 .rev_parse_single(spec.as_str()) 108 .map_err(|_| Error::NotFound(spec.clone()))? 109 .object() 110 .corrupt()?; 111 (obj, path) 112 }; 113 114 let b = &state.site.base_url; 115 let breadcrumbs = crate::routes::build_breadcrumbs(b, &name, ¶ms.sha, &resolved_path); 116 let raw_url = if resolved_path.is_empty() { 117 String::new() 118 } else { 119 format!("{b}/{name}/commits/{}/raw/{}", params.sha, resolved_path) 120 }; 121 let parent_url = if resolved_path.is_empty() { 122 String::new() 123 } else { 124 let parent_path = resolved_path.rsplit_once('/').map_or("", |(p, _)| p); 125 if parent_path.is_empty() { 126 format!("{b}/{name}/commits/{}/tree", params.sha) 127 } else { 128 format!("{b}/{name}/commits/{}/tree/{parent_path}", params.sha) 129 } 130 }; 131 132 match object.kind { 133 gix::object::Kind::Tree => { 134 let tree = object.into_tree(); 135 let mut entries: Vec<TreeEntry> = tree 136 .iter() 137 .filter_map(|entry| { 138 let entry = entry.ok()?; 139 let entry_name = entry.filename().to_str_lossy().into_owned(); 140 let is_dir = entry.mode().is_tree() || entry.mode().is_commit(); 141 let size = if is_dir { 142 String::new() 143 } else { 144 git_repo 145 .find_object(entry.oid()) 146 .ok() 147 .map(|o| format_size(o.data.len() as u64)) 148 .unwrap_or_default() 149 }; 150 let url = if resolved_path.is_empty() { 151 format!("{b}/{name}/commits/{}/tree/{entry_name}", params.sha) 152 } else { 153 format!( 154 "{b}/{name}/commits/{}/tree/{resolved_path}/{entry_name}", 155 params.sha 156 ) 157 }; 158 Some(TreeEntry { 159 name: entry_name, 160 is_dir, 161 url, 162 size, 163 }) 164 }) 165 .collect(); 166 167 // Sort: directories first, then files, alphabetically within each group. 168 entries.sort_by(|a, b| { 169 if a.is_dir == b.is_dir { 170 a.name.cmp(&b.name) 171 } else { 172 b.is_dir.cmp(&a.is_dir) 173 } 174 }); 175 176 Ok(TreeTemplate { 177 repo: name, 178 sha: params.sha, 179 path: resolved_path, 180 breadcrumbs, 181 is_file: false, 182 entries, 183 content_html: String::new(), 184 raw_url, 185 parent_url, 186 site: state.site, 187 } 188 .into_response()) 189 } 190 gix::object::Kind::Blob => { 191 let blob = object.into_blob(); 192 let data = &blob.data; 193 194 // Binary files: redirect to raw handler. 195 if data.contains(&0) { 196 return Ok(axum::response::Redirect::to(&raw_url).into_response()); 197 } 198 199 let content = String::from_utf8_lossy(data).into_owned(); 200 201 let extension = resolved_path.rsplit('.').next().unwrap_or(""); 202 let syntax = SYNTAX_SET 203 .find_syntax_by_extension(extension) 204 .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text()); 205 206 let mut generator = ClassedHTMLGenerator::new_with_class_style( 207 syntax, 208 &SYNTAX_SET, 209 ClassStyle::SpacedPrefixed { prefix: "st-" }, 210 ); 211 // ClassedHTMLGenerator wraps tokens in `<span class="st-*">` using 212 // CSS class mappings instead of inline hex colors, so the 213 // highlighting adapts to the app theme via CSS variables. 214 let mut hcontent = content; 215 if !hcontent.ends_with('\n') { 216 hcontent.push('\n'); 217 } 218 for line in hcontent.split_inclusive('\n') { 219 generator 220 .parse_html_for_line_which_includes_newline(line) 221 .map_err(|e| Error::BadRequest(e.to_string()))?; 222 } 223 let inner = generator.finalize(); 224 // Add line numbers by splitting on preserved newlines. Segments 225 // ending with `\n` are content lines; the final `</span>` closing 226 // the root scope is the only segment without a trailing newline. 227 let mut numbered = String::new(); 228 let mut line_num = 1; 229 for segment in inner.split_inclusive('\n') { 230 if segment.ends_with('\n') { 231 let _ = write!( 232 numbered, 233 "<span class=\"st-linenum\">{line_num}</span>{segment}" 234 ); 235 line_num += 1; 236 } else { 237 numbered.push_str(segment); 238 } 239 } 240 let content_html = format!("<pre class=\"st-pre\">{numbered}</pre>"); 241 242 Ok(TreeTemplate { 243 repo: name, 244 sha: params.sha, 245 path: resolved_path, 246 breadcrumbs, 247 is_file: true, 248 entries: Vec::new(), 249 content_html, 250 raw_url, 251 parent_url, 252 site: state.site, 253 } 254 .into_response()) 255 } 256 _ => { 257 let spec = if resolved_path.is_empty() { 258 format!("{}:", params.sha) 259 } else { 260 format!("{}:{}", params.sha, resolved_path) 261 }; 262 Err(Error::NotFound(spec)) 263 } 264 } 265} 266 267#[cfg(test)] 268mod tests { 269 use super::*; 270 271 #[test] 272 fn size_zero() { 273 assert_eq!(format_size(0), "0 B"); 274 } 275 276 #[test] 277 fn size_bytes() { 278 assert_eq!(format_size(1023), "1023 B"); 279 } 280 281 #[test] 282 fn size_kb() { 283 assert_eq!(format_size(1024), "1.0 KB"); 284 } 285 286 #[test] 287 fn size_mb() { 288 assert_eq!(format_size(1_048_576), "1.0 MB"); 289 } 290}