elvsc

19 commits
Updated 2026-04-30 16:50:05
src/commands/update/repo
src/commands/update/repo/tree.rs
use crate::commands::update::hub::RepositorySummary;

use std::{
    borrow::Cow,
    collections::{BTreeMap, BTreeSet, btree_map::Entry},
    path::{Path, PathBuf},
};

use askama::Template;

use jj_cli::{
    cli_util::CommandHelper,
    command_error::{CommandError, internal_error_with_message, user_error_with_message},
    ui::Ui,
};

use jj_lib::{backend::TreeValue, commit::Commit, repo::Repo, revset::ResolvedRevsetExpression};

use tokio::io::AsyncReadExt;

#[derive(Debug, Clone, Default)]
pub struct LanguageContent {
    pub language: Option<&'static str>,
    pub file: String,
}

#[derive(Debug, Clone, Default)]
pub struct DirectoryContent {
    pub files: BTreeMap<String, LanguageContent>,
    pub directories: BTreeSet<String>,
}

#[derive(Debug, Clone, Template)]
#[template(path = "repository/tree.html")]
struct TreeTemplate<'a> {
    public: &'a str,
    summary: &'a RepositorySummary,

    bookmark: &'a str,
    bookmarks: &'a [String],
    current_dir: &'a Path,
    current_name: &'a str,

    adjacent_directories: &'a BTreeSet<String>,
    adjacent_files: &'a BTreeMap<String, LanguageContent>,

    content: Option<Cow<'a, LanguageContent>>,
}

pub async fn write_frontend(
    output: &Path,
    ui: &mut Ui,
    ch: &CommandHelper,
    name: &str,
    public: &str,
    bookmark: &str,
    bookmarks: &[String],
    commit: Commit,
) -> Result<(), CommandError> {
    std::fs::create_dir_all(&output)?;
    let wch = ch.workspace_helper(ui)?;

    let tree = read_tree(&commit).await?;
    let commits = ResolvedRevsetExpression::ancestors(&ResolvedRevsetExpression::commits(vec![
        commit.id().clone(),
    ]))
    .evaluate(wch.repo().base_repo())?
    .count_estimate()?
    .0;

    let latest = commit
        .author()
        .timestamp
        .to_datetime()
        .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
        .map_err(|e| user_error_with_message("Couldn't format date properly", e))?;

    let description = tree
        .get(Path::new(""))
        .and_then(|dir| dir.files.get("DESCRIPTION").map(|f| f.file.clone()))
        .unwrap_or_else(|| String::from("<no_description>"));

    let summary = RepositorySummary {
        name: name.to_string(),
        commits,
        latest,
        description,
    };

    for (path, directory) in &tree {
        let mut template = TreeTemplate {
            public,
            summary: &summary,
            current_dir: path,
            current_name: "",
            bookmark,
            bookmarks,
            adjacent_directories: &directory.directories,
            adjacent_files: &directory.files,
            content: directory.files.get("README.md").map(|f| {
                Cow::Owned(LanguageContent {
                    language: Some("markdown"),
                    file: f.file.clone(),
                })
            }),
        };

        std::fs::create_dir_all(output.join(&path))?;
        std::fs::write(
            output.join(&path).join("index.html"),
            template
                .render()
                .map_err(|e| internal_error_with_message("Couldn't render html", e))?,
        )?;

        for (adj_file, content) in &directory.files {
            template.current_name = &adj_file;
            template.content = Some(Cow::Borrowed(content));

            std::fs::write(
                output.join(&path).join(format!("{}.html", adj_file)),
                template
                    .render()
                    .map_err(|e| internal_error_with_message("Couldn't render html", e))?,
            )?;
        }
    }

    Ok(())
}

async fn read_tree(commit: &Commit) -> Result<BTreeMap<PathBuf, DirectoryContent>, CommandError> {
    let mut content = BTreeMap::<PathBuf, DirectoryContent>::new();
    let root = PathBuf::new();

    for (path, merge_tree_value) in commit.tree().entries() {
        for tree_value in merge_tree_value? {
            let Some(tree_value) = tree_value else {
                continue;
            };

            let TreeValue::File { id, .. } = tree_value else {
                continue;
            };

            let Some((parent, filename)) = path.split() else {
                continue;
            };

            let Ok(parent_path) = parent.to_fs_path(&root) else {
                continue;
            };

            let name = filename.as_internal_str().to_string();

            let (read, language) = match classify_file(&path.to_fs_path_unchecked(&root)) {
                None => (false, None),
                Some(language) => (true, language),
            };

            let file = if read {
                let mut buf = String::new();
                commit
                    .store()
                    .read_file(&path, &id)
                    .await?
                    .read_to_string(&mut buf)
                    .await?;
                buf
            } else {
                String::from("<no readable content>")
            };

            let entry = content.entry(parent_path);
            let walked = matches!(entry, Entry::Occupied(_));
            entry
                .or_default()
                .files
                .insert(name, LanguageContent { language, file });

            if walked {
                continue;
            }

            let mut current = parent.to_owned();
            while let Some((ancestor, dir_name)) = current.split() {
                let Ok(ancestor_path) = ancestor.to_fs_path(&root) else {
                    break;
                };

                let dir_name = dir_name.as_internal_str().to_string();

                content
                    .entry(ancestor_path)
                    .or_default()
                    .directories
                    .insert(dir_name);

                current = ancestor.to_owned();
            }
        }
    }

    Ok(content)
}

fn classify_file(path: &Path) -> Option<Option<&'static str>> {
    let name = path.file_name()?.to_str()?;

    match name {
        "Cargo.lock" | "package-lock.json" | "yarn.lock" => return Some(Some("toml")),
        "Makefile" | "makefile" | "GNUmakefile" => return Some(Some("makefile")),
        "Dockerfile" => return Some(Some("dockerfile")),
        "LICENSE" | "README" | "CHANGELOG" | "AUTHORS" | "NOTICE" => return Some(None),
        ".gitignore" | ".gitattributes" | ".editorconfig" => return Some(None),
        _ => {}
    }

    if let Some(ext) = path.extension() {
        let ext = ext.to_str()?.to_ascii_lowercase();

        let lang = match ext.as_str() {
            "rs" => Some("rust"),
            "py" => Some("python"),
            "js" | "mjs" | "cjs" => Some("javascript"),
            "ts" => Some("typescript"),
            "jsx" => Some("javascript"),
            "tsx" => Some("typescript"),
            "go" => Some("go"),
            "c" | "h" => Some("c"),
            "cpp" | "cc" | "cxx" | "hpp" | "hh" | "hxx" => Some("cpp"),
            "java" => Some("java"),
            "kt" | "kts" => Some("kotlin"),
            "scala" => Some("scala"),
            "rb" => Some("ruby"),
            "php" => Some("php"),
            "swift" => Some("swift"),
            "cs" => Some("csharp"),
            "sh" | "bash" | "zsh" => Some("bash"),
            "ps1" => Some("powershell"),
            "lua" => Some("lua"),
            "r" => Some("r"),
            "sql" => Some("sql"),
            "html" | "htm" => Some("xml"),
            "xml" | "svg" => Some("xml"),
            "css" => Some("css"),
            "scss" | "sass" => Some("scss"),
            "less" => Some("less"),
            "json" => Some("json"),
            "yaml" | "yml" => Some("yaml"),
            "toml" => Some("toml"),
            "ini" | "cfg" | "conf" => Some("ini"),
            "md" | "markdown" => Some("markdown"),
            "tex" => Some("latex"),
            "diff" | "patch" => Some("diff"),
            "dockerfile" => Some("dockerfile"),
            "vim" => Some("vim"),
            "el" => Some("lisp"),
            "clj" | "cljs" => Some("clojure"),
            "hs" => Some("haskell"),
            "ml" | "mli" => Some("ocaml"),
            "ex" | "exs" => Some("elixir"),
            "erl" => Some("erlang"),
            "dart" => Some("dart"),
            "nim" => Some("nim"),
            "zig" => Some("zig"),
            "nix" => Some("nix"),
            "nu" => Some("nu"),

            "txt" | "log" | "csv" | "tsv" => return Some(None),

            "png" | "jpg" | "jpeg" | "gif" | "bmp" | "ico" | "webp" | "tiff" | "pdf" | "zip"
            | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar" | "exe" | "dll" | "so" | "dylib" | "o"
            | "a" | "lib" | "class" | "jar" | "wasm" | "mp3" | "mp4" | "wav" | "flac" | "ogg"
            | "avi" | "mov" | "mkv" | "ttf" | "otf" | "woff" | "woff2" | "eot" => return None,

            _ => return None,
        };

        Some(lang)
    } else {
        Some(None)
    }
}