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 breadcrumbs = build_breadcrumbs(&name, &params.sha, &resolved_path);
127    let raw_url = if resolved_path.is_empty() {
128        String::new()
129    } else {
130        format!("/{name}/commits/{}/raw/{}", params.sha, resolved_path)
131    };
132    let parent_url = if resolved_path.is_empty() {
133        String::new()
134    } else {
135        let parent_path = resolved_path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
136        if parent_path.is_empty() {
137            format!("/{name}/commits/{}/tree", params.sha)
138        } else {
139            format!("/{name}/commits/{}/tree/{parent_path}", params.sha)
140        }
141    };
142
143    match object.kind {
144        gix::object::Kind::Tree => {
145            let tree = object.into_tree();
146            let mut entries: Vec<TreeEntry> = tree
147                .iter()
148                .filter_map(|entry| {
149                    let entry = entry.ok()?;
150                    let entry_name = entry.filename().to_str_lossy().into_owned();
151                    let is_dir = entry.mode().is_tree() || entry.mode().is_commit();
152                    let size = if is_dir {
153                        String::new()
154                    } else {
155                        git_repo
156                            .find_object(entry.oid())
157                            .ok()
158                            .map(|o| format_size(o.data.len() as u64))
159                            .unwrap_or_default()
160                    };
161                    let url = if resolved_path.is_empty() {
162                        format!("/{name}/commits/{}/tree/{entry_name}", params.sha)
163                    } else {
164                        format!(
165                            "/{name}/commits/{}/tree/{resolved_path}/{entry_name}",
166                            params.sha
167                        )
168                    };
169                    Some(TreeEntry {
170                        name: entry_name,
171                        is_dir,
172                        url,
173                        size,
174                    })
175                })
176                .collect();
177
178            // Sort: directories first, then files, alphabetically within each group.
179            entries.sort_by(|a, b| {
180                if a.is_dir != b.is_dir {
181                    b.is_dir.cmp(&a.is_dir)
182                } else {
183                    a.name.cmp(&b.name)
184                }
185            });
186
187            Ok(TreeTemplate {
188                repo: name,
189                sha: params.sha,
190                path: resolved_path,
191                breadcrumbs,
192                is_file: false,
193                entries,
194                content_html: String::new(),
195                raw_url,
196                parent_url,
197            }
198            .into_response())
199        }
200        gix::object::Kind::Blob => {
201            let blob = object.into_blob();
202            let data = &blob.data;
203
204            // Binary files: redirect to raw handler.
205            if data.contains(&0) {
206                return Ok(axum::response::Redirect::to(&raw_url).into_response());
207            }
208
209            let content = String::from_utf8_lossy(data).into_owned();
210
211            let extension = resolved_path.rsplit('.').next().unwrap_or("");
212            let syntax = SYNTAX_SET
213                .find_syntax_by_extension(extension)
214                .unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
215
216            let mut generator = ClassedHTMLGenerator::new_with_class_style(
217                syntax,
218                &SYNTAX_SET,
219                ClassStyle::SpacedPrefixed { prefix: "st-" },
220            );
221            // ClassedHTMLGenerator wraps tokens in `<span class="st-*">` using
222            // CSS class mappings instead of inline hex colors, so the
223            // highlighting adapts to the app theme via CSS variables.
224            let mut hcontent = content;
225            if !hcontent.ends_with('\n') {
226                hcontent.push('\n');
227            }
228            for line in hcontent.split_inclusive('\n') {
229                generator
230                    .parse_html_for_line_which_includes_newline(line)
231                    .map_err(|e| Error::BadRequest(e.to_string()))?;
232            }
233            let inner = generator.finalize();
234            // Add line numbers by splitting on preserved newlines.  Segments
235            // ending with `\n` are content lines; the final `</span>` closing
236            // the root scope is the only segment without a trailing newline.
237            let mut numbered = String::new();
238            let mut line_num = 1;
239            for segment in inner.split_inclusive('\n') {
240                if segment.ends_with('\n') {
241                    numbered.push_str(&format!(
242                        "<span class=\"st-linenum\">{line_num}</span>{segment}"
243                    ));
244                    line_num += 1;
245                } else {
246                    numbered.push_str(segment);
247                }
248            }
249            let content_html = format!("<pre class=\"st-pre\">{numbered}</pre>");
250
251            Ok(TreeTemplate {
252                repo: name,
253                sha: params.sha,
254                path: resolved_path,
255                breadcrumbs,
256                is_file: true,
257                entries: Vec::new(),
258                content_html,
259                raw_url,
260                parent_url,
261            }
262            .into_response())
263        }
264        _ => {
265            let spec = if resolved_path.is_empty() {
266                format!("{}:", params.sha)
267            } else {
268                format!("{}:{}", params.sha, resolved_path)
269            };
270            Err(Error::NotFound(spec))
271        }
272    }
273}
274
275/// Builds breadcrumbs for a tree path.
276///
277/// The root breadcrumb is always "tree".  Each path segment adds a breadcrumb
278/// linking to its corresponding subdirectory.
279fn build_breadcrumbs(repo: &str, sha: &str, path: &str) -> Vec<Breadcrumb> {
280    let mut crumbs = Vec::new();
281    crumbs.push(Breadcrumb {
282        label: "tree".to_string(),
283        url: format!("/{repo}/commits/{sha}/tree"),
284    });
285    if !path.is_empty() {
286        let mut accumulated = String::new();
287        for segment in path.split('/') {
288            if !accumulated.is_empty() {
289                accumulated.push('/');
290            }
291            accumulated.push_str(segment);
292            crumbs.push(Breadcrumb {
293                label: segment.to_string(),
294                url: format!("/{repo}/commits/{sha}/tree/{accumulated}"),
295            });
296        }
297    }
298    crumbs
299}