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