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