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