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