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