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, &params.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}