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