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}