Skip to main content

abbaye/
git_browse.rs

1use std::path::Path;
2
3use gix::bstr::ByteSlice;
4use miette::{IntoDiagnostic, Result};
5use serde::Serialize;
6use tera::{Context, Tera};
7
8use crate::config::OutputFormat;
9use crate::git_ui::{
10    TEMPLATE_GIT_BLOB_GEMTEXT, TEMPLATE_GIT_BLOB_HTML, TEMPLATE_GIT_TREE_GEMTEXT,
11    TEMPLATE_GIT_TREE_HTML,
12};
13
14// ── Types ──────────────────────────────────────────────────────────────────────
15
16#[derive(Serialize)]
17struct Crumb {
18    name: String,
19    url: Option<String>,
20}
21
22#[derive(Serialize)]
23#[serde(rename_all = "lowercase")]
24enum TreeEntryKind {
25    Tree,
26    Blob,
27}
28
29#[derive(Serialize)]
30struct TreeEntry {
31    name: String,
32    kind: TreeEntryKind,
33    url: String,
34}
35
36// ── Entry point ────────────────────────────────────────────────────────────────
37
38/// Build the full static tree browser for every revision in `revisions`.
39#[allow(clippy::too_many_arguments)]
40pub(crate) fn build_browse_pages(
41    revisions: &[(String, gix::ObjectId)],
42    browse_dir: &Path,
43    repo_path: &Path,
44    theme_path: &Path,
45    project_name: &str,
46    lang: &Option<String>,
47    clone_url: &Option<String>,
48    formats: &[OutputFormat],
49    git_ui_path: &str,
50    prefix_empty: bool,
51) -> Result<()> {
52    use syntect::highlighting::ThemeSet;
53    use syntect::parsing::SyntaxSet;
54
55    let ss = SyntaxSet::load_defaults_newlines();
56    let ts = ThemeSet::load_defaults();
57    let theme = &ts.themes["InspiredGitHub"];
58
59    let mut tera = Tera::default();
60    crate::site::register_format_templates(
61        &mut tera,
62        theme_path,
63        formats,
64        &[
65            (
66                "git_tree",
67                TEMPLATE_GIT_TREE_HTML,
68                TEMPLATE_GIT_TREE_GEMTEXT,
69            ),
70            (
71                "git_blob",
72                TEMPLATE_GIT_BLOB_HTML,
73                TEMPLATE_GIT_BLOB_GEMTEXT,
74            ),
75        ],
76    )?;
77
78    for (hash, oid) in revisions {
79        let rev_dir = browse_dir.join(hash);
80        std::fs::create_dir_all(&rev_dir).into_diagnostic()?;
81
82        let repo = gix::open(repo_path).into_diagnostic()?;
83        let commit_obj = repo.find_object(*oid).into_diagnostic()?.into_commit();
84        let decoded = commit_obj.decode().into_diagnostic()?;
85        let tree_id = decoded.tree();
86
87        walk_tree_dir(
88            repo_path,
89            &repo,
90            tree_id,
91            "",
92            hash,
93            &rev_dir,
94            &tera,
95            project_name,
96            lang,
97            clone_url,
98            &ss,
99            theme,
100            formats,
101            git_ui_path,
102            prefix_empty,
103        )?;
104    }
105
106    Ok(())
107}
108
109// ── Tree walker ────────────────────────────────────────────────────────────────
110
111#[allow(clippy::too_many_arguments)]
112fn walk_tree_dir(
113    repo_path: &Path,
114    repo: &gix::Repository,
115    tree_id: gix::ObjectId,
116    dir_path: &str,
117    commit_hash: &str,
118    rev_dir: &Path,
119    tera: &Tera,
120    project_name: &str,
121    lang: &Option<String>,
122    clone_url: &Option<String>,
123    ss: &syntect::parsing::SyntaxSet,
124    theme: &syntect::highlighting::Theme,
125    formats: &[OutputFormat],
126    git_ui_path: &str,
127    prefix_empty: bool,
128) -> Result<()> {
129    let tree_obj = repo.find_object(tree_id).into_diagnostic()?.into_tree();
130    let decoded = tree_obj.decode().into_diagnostic()?;
131
132    let depth: usize = dir_path.split('/').filter(|s| !s.is_empty()).count();
133
134    let page_dir = if dir_path.is_empty() {
135        rev_dir.to_path_buf()
136    } else {
137        dir_path
138            .split('/')
139            .filter(|s| !s.is_empty())
140            .fold(rev_dir.to_path_buf(), |p, c| p.join(c))
141    };
142    std::fs::create_dir_all(&page_dir).into_diagnostic()?;
143
144    let mut entries: Vec<TreeEntry> = Vec::new();
145    let mut subdirs: Vec<(String, gix::ObjectId)> = Vec::new();
146    let mut blobs: Vec<(String, gix::ObjectId)> = Vec::new();
147
148    for entry in decoded.entries.iter() {
149        let name = entry.filename.to_str_lossy().into_owned();
150        let oid: gix::ObjectId = entry.oid.to_owned();
151
152        if entry.mode.is_tree() {
153            entries.push(TreeEntry {
154                url: format!("{name}/index.html"),
155                name: name.clone(),
156                kind: TreeEntryKind::Tree,
157            });
158            subdirs.push((name, oid));
159        } else {
160            entries.push(TreeEntry {
161                url: format!("{name}.html"),
162                name: name.clone(),
163                kind: TreeEntryKind::Blob,
164            });
165            if !entry.mode.is_commit() {
166                blobs.push((name, oid));
167            }
168        }
169    }
170
171    entries.sort_by(|a, b| {
172        let a_tree = matches!(a.kind, TreeEntryKind::Tree);
173        let b_tree = matches!(b.kind, TreeEntryKind::Tree);
174        b_tree.cmp(&a_tree).then(a.name.cmp(&b.name))
175    });
176
177    let prefix_depth = if prefix_empty { 0 } else { 1 };
178    let root_path = "../".repeat(prefix_depth + depth + 2);
179    let commit_url = format!(
180        "{}commit/{commit_hash}.html",
181        "../".repeat(prefix_depth + depth + 1)
182    );
183    let breadcrumbs = make_crumbs(dir_path, false, None);
184
185    for format in formats {
186        let suffix = format.extension();
187        let tmpl_name = format!("git_tree.{suffix}");
188        let ext = format.extension();
189
190        let mut ctx = Context::new();
191        ctx.insert("project_name", project_name);
192        ctx.insert("lang", lang);
193        ctx.insert("clone_url", clone_url);
194        ctx.insert("commit_hash", commit_hash);
195        ctx.insert("commit_hash_short", &commit_hash[..7]);
196        ctx.insert("commit_url", &commit_url);
197        ctx.insert("dir_path", dir_path);
198        ctx.insert("entries", &entries);
199        ctx.insert("breadcrumbs", &breadcrumbs);
200        ctx.insert("root_path", &root_path);
201        ctx.insert("git_ui_path", git_ui_path);
202
203        let content = tera
204            .render(&tmpl_name, &ctx)
205            .map_err(|e| miette::miette!("{e}"))?;
206        std::fs::write(page_dir.join(format!("index.{ext}")), content).into_diagnostic()?;
207    }
208
209    for (name, oid) in subdirs {
210        let child_path = if dir_path.is_empty() {
211            name
212        } else {
213            format!("{dir_path}/{name}")
214        };
215        walk_tree_dir(
216            repo_path,
217            repo,
218            oid,
219            &child_path,
220            commit_hash,
221            rev_dir,
222            tera,
223            project_name,
224            lang,
225            clone_url,
226            ss,
227            theme,
228            formats,
229            git_ui_path,
230            prefix_empty,
231        )?;
232    }
233
234    for (name, oid) in blobs {
235        let file_path = if dir_path.is_empty() {
236            name.clone()
237        } else {
238            format!("{dir_path}/{name}")
239        };
240        render_blob_page(
241            repo_path,
242            &name,
243            &file_path,
244            oid,
245            commit_hash,
246            depth,
247            &page_dir,
248            tera,
249            project_name,
250            lang,
251            clone_url,
252            ss,
253            theme,
254            formats,
255            git_ui_path,
256            prefix_empty,
257        )?;
258    }
259
260    Ok(())
261}
262
263// ── Blob page ──────────────────────────────────────────────────────────────────
264
265#[allow(clippy::too_many_arguments)]
266fn render_blob_page(
267    repo_path: &Path,
268    filename: &str,
269    file_path: &str,
270    oid: gix::ObjectId,
271    commit_hash: &str,
272    depth: usize,
273    page_dir: &Path,
274    tera: &Tera,
275    project_name: &str,
276    lang: &Option<String>,
277    clone_url: &Option<String>,
278    ss: &syntect::parsing::SyntaxSet,
279    theme: &syntect::highlighting::Theme,
280    formats: &[OutputFormat],
281    git_ui_path: &str,
282    prefix_empty: bool,
283) -> Result<()> {
284    const MAX_BLOB_BYTES: usize = 1024 * 1024;
285
286    let data: Vec<u8> = std::process::Command::new("git")
287        .current_dir(repo_path)
288        .args(["cat-file", "blob", &oid.to_string()])
289        .output()
290        .map(|o| o.stdout)
291        .unwrap_or_default();
292
293    let is_binary = data[..data.len().min(8192)].contains(&0u8);
294    let too_large = data.len() > MAX_BLOB_BYTES;
295
296    let text = String::from_utf8_lossy(&data);
297    let content_plain: Option<String> = if is_binary || too_large || data.is_empty() {
298        None
299    } else {
300        Some(text.to_string())
301    };
302    let content_html: Option<String> = if is_binary || too_large || data.is_empty() {
303        None
304    } else {
305        let ext = std::path::Path::new(filename)
306            .extension()
307            .and_then(|s| s.to_str())
308            .unwrap_or("");
309        let syntax = ss
310            .find_syntax_by_extension(ext)
311            .or_else(|| {
312                text.lines()
313                    .next()
314                    .and_then(|l| ss.find_syntax_by_first_line(l))
315            })
316            .unwrap_or_else(|| ss.find_syntax_plain_text());
317        Some(
318            syntect::html::highlighted_html_for_string(&text, ss, syntax, theme)
319                .unwrap_or_else(|_| format!("<pre>{}</pre>", escape_html(&text))),
320        )
321    };
322
323    let prefix_depth = if prefix_empty { 0 } else { 1 };
324    let root_path = "../".repeat(prefix_depth + depth + 2);
325    let commit_url = format!(
326        "{}commit/{commit_hash}.html",
327        "../".repeat(prefix_depth + depth + 1)
328    );
329    let breadcrumbs = make_crumbs(
330        std::path::Path::new(file_path)
331            .parent()
332            .and_then(|p| p.to_str())
333            .unwrap_or(""),
334        true,
335        Some(filename),
336    );
337
338    for format in formats {
339        let suffix = format.extension();
340        let tmpl_name = format!("git_blob.{suffix}");
341        let ext = format.extension();
342
343        let mut ctx = Context::new();
344        ctx.insert("project_name", project_name);
345        ctx.insert("lang", lang);
346        ctx.insert("clone_url", clone_url);
347        ctx.insert("commit_hash", commit_hash);
348        ctx.insert("commit_hash_short", &commit_hash[..7]);
349        ctx.insert("commit_url", &commit_url);
350        ctx.insert("file_path", file_path);
351        ctx.insert("filename", filename);
352        ctx.insert("breadcrumbs", &breadcrumbs);
353        ctx.insert("content_html", &content_html);
354        ctx.insert("content_plain", &content_plain);
355        ctx.insert("is_binary", &is_binary);
356        ctx.insert("too_large", &too_large);
357        ctx.insert("size", &data.len());
358        ctx.insert("root_path", &root_path);
359        ctx.insert("git_ui_path", git_ui_path);
360
361        let content = tera
362            .render(&tmpl_name, &ctx)
363            .map_err(|e| miette::miette!("{e}"))?;
364        std::fs::write(page_dir.join(format!("{filename}.{ext}")), content).into_diagnostic()?;
365    }
366
367    Ok(())
368}
369
370// ── Helpers ────────────────────────────────────────────────────────────────────
371
372fn make_crumbs(dir_path: &str, is_blob: bool, filename: Option<&str>) -> Vec<Crumb> {
373    let parts: Vec<&str> = dir_path.split('/').filter(|s| !s.is_empty()).collect();
374    let depth = parts.len();
375    let mut crumbs = Vec::new();
376
377    let root_url = if depth == 0 && !is_blob {
378        None
379    } else {
380        Some(format!("{}index.html", "../".repeat(depth)))
381    };
382    crumbs.push(Crumb {
383        name: "~".to_string(),
384        url: root_url,
385    });
386
387    for (i, &part) in parts.iter().enumerate() {
388        let is_last_and_tree = i == depth - 1 && !is_blob;
389        let url = if is_last_and_tree {
390            None
391        } else {
392            let levels_up = depth - i - 1;
393            Some(format!("{}index.html", "../".repeat(levels_up)))
394        };
395        crumbs.push(Crumb {
396            name: part.to_string(),
397            url,
398        });
399    }
400
401    if is_blob {
402        if let Some(name) = filename {
403            crumbs.push(Crumb {
404                name: name.to_string(),
405                url: None,
406            });
407        }
408    }
409
410    crumbs
411}
412
413fn escape_html(s: &str) -> String {
414    s.replace('&', "&amp;")
415        .replace('<', "&lt;")
416        .replace('>', "&gt;")
417}