Ferrit Explore
中文·繁體·EN·日本語 Sign in Register
cielxl / ferrit / src / web.rs
//! Web UI: routing, request handlers and server-side HTML rendering.

use crate::githttp;
use crate::gitio;
use crate::i18n::{self, Lang, Msg};
use crate::store::{Repo, User};
use crate::App;
use axum::body::Body;
use axum::extract::{Form, Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::Response;
use axum::routing::{get, post};
use axum::Router;
use chrono::{TimeZone, Utc};
use serde::Deserialize;
use std::sync::Arc;

/// Shorthand for translating a message into the request's language.
fn t(lang: Lang, msg: Msg) -> &'static str {
    i18n::t(lang, msg)
}

pub fn router(app: Arc<App>) -> Router {
    Router::new()
        .route("/", get(home))
        .route("/explore", get(explore))
        .route("/register", get(register_form).post(register_submit))
        .route("/login", get(login_form).post(login_submit))
        .route("/logout", post(logout))
        .route("/setlang/:code", get(set_lang))
        .route("/new", get(new_repo_form).post(new_repo_submit))
        // git smart-HTTP transport
        .route("/:user/:repo/info/refs", get(githttp::info_refs))
        .route("/:user/:repo/git-upload-pack", post(githttp::upload_pack))
        .route("/:user/:repo/git-receive-pack", post(githttp::receive_pack))
        // code browser
        .route("/:user/:repo/tree/*path", get(tree_view))
        .route("/:user/:repo/blob/*path", get(blob_view))
        .route("/:user/:repo/commits", get(commits_view))
        .route("/:user/:repo", get(repo_home))
        .route("/:user", get(profile))
        .with_state(app)
}

// ---- small response helpers ---------------------------------------------

fn html(body: String) -> Response {
    Response::builder()
        .header("content-type", "text/html; charset=utf-8")
        .body(Body::from(body))
        .unwrap()
}

fn redirect(to: &str) -> Response {
    Response::builder()
        .status(StatusCode::SEE_OTHER)
        .header("location", to)
        .body(Body::empty())
        .unwrap()
}

fn redirect_set_cookie(to: &str, cookie: &str) -> Response {
    Response::builder()
        .status(StatusCode::SEE_OTHER)
        .header("location", to)
        .header("set-cookie", cookie)
        .body(Body::empty())
        .unwrap()
}

fn not_found(lang: Lang) -> Response {
    let content = format!(
        "<div class=\"box\"><h2>404</h2><p>{}</p></div>",
        t(lang, Msg::NotFoundBody)
    );
    Response::builder()
        .status(StatusCode::NOT_FOUND)
        .header("content-type", "text/html; charset=utf-8")
        .body(Body::from(layout(
            t(lang, Msg::NotFoundTitle),
            None,
            lang,
            &[],
            &content,
        )))
        .unwrap()
}

// ---- helpers -------------------------------------------------------------

fn escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#39;"),
            _ => out.push(c),
        }
    }
    out
}

fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
    let raw = headers.get("cookie")?.to_str().ok()?;
    for part in raw.split(';') {
        let part = part.trim();
        if let Some(v) = part.strip_prefix(&format!("{name}=")) {
            return Some(v.to_string());
        }
    }
    None
}

fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
    let token = cookie_value(headers, "ferrit_session");
    app.current_user(token.as_deref())
}

fn current_lang(headers: &HeaderMap) -> Lang {
    i18n::detect(headers)
}

fn host_base(headers: &HeaderMap) -> String {
    let host = headers
        .get("host")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("127.0.0.1:3000");
    format!("http://{host}")
}

fn fmt_time(ts: i64) -> String {
    Utc.timestamp_opt(ts, 0)
        .single()
        .map(|d| d.format("%Y-%m-%d").to_string())
        .unwrap_or_default()
}

// ---- layout --------------------------------------------------------------

const STYLE: &str = r#"
:root{--bg:#0d1117;--panel:#161b22;--border:#30363d;--text:#c9d1d9;--muted:#8b949e;--link:#58a6ff;--accent:#238636;}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;}
a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline}
header{background:var(--panel);border-bottom:1px solid var(--border);padding:10px 0}
.container{max-width:980px;margin:0 auto;padding:0 16px}
header .container{display:flex;align-items:center;gap:16px}
.brand{font-weight:700;font-size:18px;color:#fff}
.brand span{color:#f0883e}
.grow{flex:1}
.btn{display:inline-block;background:var(--accent);color:#fff;border:none;padding:6px 14px;border-radius:6px;cursor:pointer;font-size:14px}
.btn:hover{filter:brightness(1.1);text-decoration:none}
.btn.secondary{background:#21262d;border:1px solid var(--border);color:var(--text)}
.box{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:16px;margin:16px 0}
.box h2{margin-top:0}
input,textarea{width:100%;background:#0d1117;border:1px solid var(--border);color:var(--text);padding:8px;border-radius:6px;margin:4px 0 12px;font:inherit}
label{font-weight:600;font-size:13px}
.muted{color:var(--muted)}
.repo-item{padding:12px 0;border-bottom:1px solid var(--border)}
.repo-item:last-child{border-bottom:none}
.repo-item .name{font-size:16px;font-weight:600}
table.files{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--border);border-radius:8px;overflow:hidden}
table.files td{padding:8px 12px;border-top:1px solid var(--border)}
table.files tr:first-child td{border-top:none}
.icon{color:var(--muted);margin-right:6px}
pre.code{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:12px;overflow:auto;font:13px/1.45 SFMono-Regular,Consolas,monospace}
pre.clone{background:#0d1117;border:1px solid var(--border);border-radius:6px;padding:10px;overflow:auto}
.error{background:#3d1418;border:1px solid #f85149;color:#ffb4ab;padding:10px;border-radius:6px;margin:10px 0}
.tag{font-size:12px;border:1px solid var(--border);border-radius:10px;padding:1px 8px;color:var(--muted)}
.commit{padding:10px 0;border-bottom:1px solid var(--border)}
.hash{font-family:monospace;color:var(--muted)}
.lang-switch{font-size:12px;color:var(--muted)}
.lang-switch a{color:var(--muted)}
.lang-switch .sep{margin:0 4px;color:var(--border)}
"#;

/// Render the language switcher: one link per language, current one highlighted.
fn lang_switch(current: Lang) -> String {
    let mut parts = Vec::new();
    for l in Lang::ALL {
        if l == current {
            parts.push(format!(
                r#"<span style="color:var(--text);font-weight:600">{}</span>"#,
                l.label()
            ));
        } else {
            parts.push(format!(
                r#"<a href="/setlang/{code}">{label}</a>"#,
                code = l.code(),
                label = l.label(),
            ));
        }
    }
    format!(
        r#"<span class="lang-switch">{}</span>"#,
        parts.join(r#"<span class="sep">·</span>"#)
    )
}

fn nav(user: Option<&User>, lang: Lang) -> String {
    let right = match user {
        Some(u) => format!(
            r#"<a class="btn secondary" href="/new">{new}</a>
               <a href="/{name}">{name}</a>
               <form method="post" action="/logout" style="margin:0"><button class="btn secondary">{logout}</button></form>"#,
            new = t(lang, Msg::NavNew),
            logout = t(lang, Msg::NavLogout),
            name = escape(&u.username)
        ),
        None => format!(
            r#"<a href="/login">{signin}</a> <a class="btn" href="/register">{register}</a>"#,
            signin = t(lang, Msg::NavSignIn),
            register = t(lang, Msg::NavRegister),
        ),
    };
    format!(
        r#"<header><div class="container">
            <a class="brand" href="/">Ferr<span>it</span></a>
            <a href="/explore">{explore}</a>
            <div class="grow"></div>
            {switch}
            {right}
        </div></header>"#,
        explore = t(lang, Msg::NavExplore),
        switch = lang_switch(lang),
    )
}

/// `crumbs` is a list of (label, href) breadcrumb pairs rendered above content.
fn layout(
    title: &str,
    user: Option<&User>,
    lang: Lang,
    crumbs: &[(String, String)],
    content: &str,
) -> String {
    let crumb_html = if crumbs.is_empty() {
        String::new()
    } else {
        let parts: Vec<String> = crumbs
            .iter()
            .map(|(label, href)| {
                if href.is_empty() {
                    escape(label)
                } else {
                    format!(r#"<a href="{}">{}</a>"#, escape(href), escape(label))
                }
            })
            .collect();
        format!(r#"<div class="muted" style="margin-top:16px">{}</div>"#, parts.join(" / "))
    };
    format!(
        r#"<!doctype html><html lang="{html_lang}"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title} · Ferrit</title><style>{STYLE}</style></head>
<body>{nav}<div class="container">{crumbs}{content}</div></body></html>"#,
        html_lang = lang.html_lang(),
        title = escape(title),
        nav = nav(user, lang),
        crumbs = crumb_html,
        content = content,
    )
}

fn repo_list_html(repos: &[Repo], lang: Lang) -> String {
    if repos.is_empty() {
        return format!(r#"<p class="muted">{}</p>"#, t(lang, Msg::NoRepos));
    }
    let mut s = String::new();
    for r in repos {
        let priv_tag = if r.private {
            format!(r#" <span class="tag">{}</span>"#, t(lang, Msg::Private))
        } else {
            String::new()
        };
        s.push_str(&format!(
            r#"<div class="repo-item">
                <div class="name"><a href="/{owner}/{name}">{owner}/{name}</a>{priv_tag}</div>
                <div class="muted">{desc}</div>
                <div class="muted" style="font-size:12px">{created} {date}</div>
            </div>"#,
            owner = escape(&r.owner),
            name = escape(&r.name),
            desc = escape(&r.description),
            created = t(lang, Msg::Created),
            date = fmt_time(r.created),
        ));
    }
    s
}

// ---- handlers: home / explore -------------------------------------------

async fn home(State(app): State<Arc<App>>, headers: HeaderMap) -> Response {
    let lang = current_lang(&headers);
    let user = current_user(&app, &headers);
    match &user {
        Some(u) => {
            let repos = app.store.repos_by_owner(&u.username);
            let content = format!(
                r#"<div class="box"><h2>{your}</h2>{list}</div>
                   <p><a class="btn" href="/new">{new}</a> <a class="btn secondary" href="/explore">{explore}</a></p>"#,
                your = t(lang, Msg::YourRepos),
                list = repo_list_html(&repos, lang),
                new = t(lang, Msg::NewRepoBtn),
                explore = t(lang, Msg::NavExplore),
            );
            html(layout(t(lang, Msg::Dashboard), user.as_ref(), lang, &[], &content))
        }
        None => {
            let content = format!(
                r#"<div class="box">
                    <h2>Ferr<span style="color:#f0883e">it</span> — {tagline}</h2>
                    <p class="muted">{desc}</p>
                    <p><a class="btn" href="/register">{start}</a>
                       <a class="btn secondary" href="/login">{signin}</a>
                       <a class="btn secondary" href="/explore">{explore}</a></p>
                    <p class="muted">{users} {count}</p>
                   </div>"#,
                tagline = t(lang, Msg::Tagline),
                desc = t(lang, Msg::AnonDesc),
                start = t(lang, Msg::GetStarted),
                signin = t(lang, Msg::NavSignIn),
                explore = t(lang, Msg::NavExplore),
                users = t(lang, Msg::RegisteredUsers),
                count = app.store.user_count(),
            );
            html(layout("Ferrit", None, lang, &[], &content))
        }
    }
}

async fn explore(State(app): State<Arc<App>>, headers: HeaderMap) -> Response {
    let lang = current_lang(&headers);
    let user = current_user(&app, &headers);
    let repos = app.store.public_repos();
    let content = format!(
        r#"<div class="box"><h2>{title}</h2>{list}</div>"#,
        title = t(lang, Msg::ExploreRepos),
        list = repo_list_html(&repos, lang),
    );
    html(layout(t(lang, Msg::NavExplore), user.as_ref(), lang, &[], &content))
}

// ---- handlers: auth ------------------------------------------------------

fn auth_page(title: &str, action: &str, lang: Lang, error: Option<&str>, extra_fields: &str) -> String {
    let err = error
        .map(|e| format!(r#"<div class="error">{}</div>"#, escape(e)))
        .unwrap_or_default();
    format!(
        r#"<div class="box" style="max-width:420px;margin:32px auto">
            <h2>{title}</h2>{err}
            <form method="post" action="{action}">
                <label>{username}</label><input name="username" autofocus required>
                {extra_fields}
                <label>{password}</label><input name="password" type="password" required>
                <button class="btn" type="submit" style="width:100%">{title}</button>
            </form>
        </div>"#,
        username = t(lang, Msg::Username),
        password = t(lang, Msg::Password),
    )
}

/// Email field markup for the registration form, in the request language.
fn email_field(lang: Lang) -> String {
    format!(
        r#"<label>{}</label><input name="email" type="email" required>"#,
        t(lang, Msg::Email)
    )
}

/// Set the language cookie and redirect back to the referring page.
async fn set_lang(Path(code): Path<String>, headers: HeaderMap) -> Response {
    let lang = Lang::from_code(&code);
    let next = headers
        .get("referer")
        .and_then(|v| v.to_str().ok())
        .filter(|s| !s.is_empty())
        .unwrap_or("/")
        .to_string();
    redirect_set_cookie(
        &next,
        &format!(
            "ferrit_lang={}; Path=/; SameSite=Lax; Max-Age=31536000",
            lang.code()
        ),
    )
}

async fn register_form(State(app): State<Arc<App>>, headers: HeaderMap) -> Response {
    let lang = current_lang(&headers);
    let user = current_user(&app, &headers);
    let title = t(lang, Msg::NavRegister);
    html(layout(
        title,
        user.as_ref(),
        lang,
        &[],
        &auth_page(title, "/register", lang, None, &email_field(lang)),
    ))
}

#[derive(Deserialize)]
struct RegisterForm {
    username: String,
    email: String,
    password: String,
}

async fn register_submit(
    State(app): State<Arc<App>>,
    headers: HeaderMap,
    Form(form): Form<RegisterForm>,
) -> Response {
    let lang = current_lang(&headers);
    match app
        .store
        .create_user(&form.username, &form.email, &form.password)
    {
        Ok(u) => {
            let token = app.start_session(u.id);
            redirect_set_cookie(
                "/",
                &format!("ferrit_session={token}; Path=/; HttpOnly; SameSite=Lax"),
            )
        }
        Err(e) => {
            let title = t(lang, Msg::NavRegister);
            html(layout(
                title,
                None,
                lang,
                &[],
                &auth_page(title, "/register", lang, Some(&e.to_string()), &email_field(lang)),
            ))
        }
    }
}

async fn login_form(State(app): State<Arc<App>>, headers: HeaderMap) -> Response {
    let lang = current_lang(&headers);
    let user = current_user(&app, &headers);
    let title = t(lang, Msg::NavSignIn);
    html(layout(
        title,
        user.as_ref(),
        lang,
        &[],
        &auth_page(title, "/login", lang, None, ""),
    ))
}

#[derive(Deserialize)]
struct LoginForm {
    username: String,
    password: String,
}

async fn login_submit(
    State(app): State<Arc<App>>,
    headers: HeaderMap,
    Form(form): Form<LoginForm>,
) -> Response {
    let lang = current_lang(&headers);
    match app.store.authenticate(&form.username, &form.password) {
        Some(u) => {
            let token = app.start_session(u.id);
            redirect_set_cookie(
                "/",
                &format!("ferrit_session={token}; Path=/; HttpOnly; SameSite=Lax"),
            )
        }
        None => {
            let title = t(lang, Msg::NavSignIn);
            html(layout(
                title,
                None,
                lang,
                &[],
                &auth_page(title, "/login", lang, Some(t(lang, Msg::InvalidLogin)), ""),
            ))
        }
    }
}

async fn logout(State(app): State<Arc<App>>, headers: HeaderMap) -> Response {
    if let Some(token) = cookie_value(&headers, "ferrit_session") {
        app.end_session(&token);
    }
    redirect_set_cookie("/", "ferrit_session=; Path=/; Max-Age=0")
}

// ---- handlers: repositories ---------------------------------------------

async fn new_repo_form(State(app): State<Arc<App>>, headers: HeaderMap) -> Response {
    let lang = current_lang(&headers);
    let user = match current_user(&app, &headers) {
        Some(u) => u,
        None => return redirect("/login"),
    };
    let content = format!(
        r#"<div class="box" style="max-width:560px;margin:24px auto">
        <h2>{create}</h2>
        <form method="post" action="/new">
            <label>{name}</label><input name="name" required autofocus>
            <label>{desc} <span class="muted">{optional}</span></label><input name="description">
            <label><input type="checkbox" name="private" style="width:auto"> {private}</label>
            <div style="margin-top:12px"><button class="btn" type="submit">{btn}</button></div>
        </form>
    </div>"#,
        create = t(lang, Msg::CreateNewRepo),
        name = t(lang, Msg::RepoName),
        desc = t(lang, Msg::Description),
        optional = t(lang, Msg::Optional),
        private = t(lang, Msg::PrivateRepo),
        btn = t(lang, Msg::CreateRepo),
    );
    html(layout(t(lang, Msg::NewRepository), Some(&user), lang, &[], &content))
}

#[derive(Deserialize)]
struct NewRepoForm {
    name: String,
    #[serde(default)]
    description: String,
    #[serde(default)]
    private: Option<String>,
}

async fn new_repo_submit(
    State(app): State<Arc<App>>,
    headers: HeaderMap,
    Form(form): Form<NewRepoForm>,
) -> Response {
    let lang = current_lang(&headers);
    let user = match current_user(&app, &headers) {
        Some(u) => u,
        None => return redirect("/login"),
    };
    let private = form.private.is_some();
    match app
        .store
        .create_repo(&user.username, &form.name, &form.description, private)
    {
        Ok(repo) => {
            let path = gitio::repo_path(&app.data_dir, &repo.owner, &repo.name);
            if let Err(e) = gitio::init_bare(&path) {
                let content = format!(
                    r#"<div class="box"><div class="error">{} {}</div></div>"#,
                    t(lang, Msg::GitInitFailed),
                    escape(&e.to_string())
                );
                return html(layout(t(lang, Msg::Error), Some(&user), lang, &[], &content));
            }
            redirect(&format!("/{}/{}", repo.owner, repo.name))
        }
        Err(e) => {
            let content = format!(
                r#"<div class="box" style="max-width:560px;margin:24px auto">
                    <h2>{create}</h2>
                    <div class="error">{err}</div>
                    <form method="post" action="/new">
                        <label>{name_label}</label><input name="name" value="{name_val}" required>
                        <label>{desc}</label><input name="description" value="{desc_val}">
                        <label><input type="checkbox" name="private" style="width:auto"> {private}</label>
                        <div style="margin-top:12px"><button class="btn" type="submit">{btn}</button></div>
                    </form></div>"#,
                create = t(lang, Msg::CreateNewRepo),
                err = escape(&e.to_string()),
                name_label = t(lang, Msg::RepoName),
                name_val = escape(&form.name),
                desc = t(lang, Msg::Description),
                desc_val = escape(&form.description),
                private = t(lang, Msg::PrivateRepo),
                btn = t(lang, Msg::CreateRepo),
            );
            html(layout(t(lang, Msg::NewRepository), Some(&user), lang, &[], &content))
        }
    }
}

async fn profile(State(app): State<Arc<App>>, headers: HeaderMap, Path(name): Path<String>) -> Response {
    let lang = current_lang(&headers);
    let viewer = current_user(&app, &headers);
    let owner = match app.store.user_by_name(&name) {
        Some(u) => u,
        None => return not_found(lang),
    };
    let is_self = viewer.as_ref().map(|v| v.id == owner.id).unwrap_or(false);
    let repos: Vec<Repo> = app
        .store
        .repos_by_owner(&owner.username)
        .into_iter()
        .filter(|r| is_self || !r.private)
        .collect();
    let content = format!(
        r#"<div class="box"><h2>{user}</h2><div class="muted">{joined} {date}</div></div>
           <div class="box"><h2>{repos_title}</h2>{list}</div>"#,
        user = escape(&owner.username),
        joined = t(lang, Msg::Joined),
        date = fmt_time(owner.created),
        repos_title = t(lang, Msg::Repositories),
        list = repo_list_html(&repos, lang),
    );
    html(layout(&owner.username, viewer.as_ref(), lang, &[], &content))
}

/// Resolve repo + access, returning the repo meta or an error response.
fn load_repo(
    app: &App,
    viewer: &Option<User>,
    lang: Lang,
    owner: &str,
    name: &str,
) -> Result<Repo, Response> {
    let name = name.trim_end_matches(".git");
    let repo = app.store.repo(owner, name).ok_or_else(|| not_found(lang))?;
    if repo.private {
        let ok = viewer
            .as_ref()
            .map(|v| v.username.eq_ignore_ascii_case(&repo.owner))
            .unwrap_or(false);
        if !ok {
            return Err(not_found(lang));
        }
    }
    Ok(repo)
}

fn repo_crumbs(repo: &Repo) -> Vec<(String, String)> {
    vec![
        (repo.owner.clone(), format!("/{}", repo.owner)),
        (repo.name.clone(), format!("/{}/{}", repo.owner, repo.name)),
    ]
}

async fn repo_home(
    State(app): State<Arc<App>>,
    headers: HeaderMap,
    Path((owner, name)): Path<(String, String)>,
) -> Response {
    let lang = current_lang(&headers);
    let viewer = current_user(&app, &headers);
    let repo = match load_repo(&app, &viewer, lang, &owner, &name) {
        Ok(r) => r,
        Err(resp) => return resp,
    };
    let dir = gitio::repo_path(&app.data_dir, &repo.owner, &repo.name);
    let clone_url = format!("{}/{}/{}.git", host_base(&headers), repo.owner, repo.name);

    let header = format!(
        r#"<div class="box">
            <h2 style="margin-bottom:4px"><a href="/{owner}/{name}">{owner}/{name}</a>{priv_tag}</h2>
            <div class="muted">{desc}</div>
            <div style="margin-top:12px"><span class="muted">{clone_label}</span>
              <pre class="clone">git clone {clone}</pre></div>
        </div>"#,
        owner = escape(&repo.owner),
        name = escape(&repo.name),
        priv_tag = if repo.private {
            format!(r#" <span class="tag">{}</span>"#, t(lang, Msg::Private))
        } else {
            String::new()
        },
        desc = escape(&repo.description),
        clone_label = t(lang, Msg::Clone),
        clone = escape(&clone_url),
    );

    let body = if gitio::is_empty(&dir) {
        format!(
            r##"<div class="box">
                <h3>{quick}</h3>
                <p class="muted">{hint}</p>
                <pre class="clone">git clone {clone}
cd {name}
echo "# {name}" &gt; README.md
git add README.md
git commit -m "first commit"
git push -u origin main</pre>
            </div>"##,
            quick = t(lang, Msg::QuickSetup),
            hint = t(lang, Msg::EmptyRepoHint),
            clone = escape(&clone_url),
            name = escape(&repo.name),
        )
    } else {
        let branch = gitio::default_branch(&dir).unwrap_or_else(|| "main".into());
        let count = gitio::commit_count(&dir, &branch);
        let entries = gitio::ls_tree(&dir, &branch, "");
        let table = render_tree(&repo, "", &entries, lang);
        let latest = gitio::log(&dir, &branch, 1);
        let latest_html = latest
            .first()
            .map(|c| {
                format!(
                    r#"<div class="muted" style="margin-bottom:8px">{latest} <span class="hash">{short}</span> {subject} · {author} · {date}</div>"#,
                    latest = t(lang, Msg::Latest),
                    short = escape(&c.short),
                    subject = escape(&c.subject),
                    author = escape(&c.author),
                    date = escape(&c.date)
                )
            })
            .unwrap_or_default();
        format!(
            r#"<div style="margin:8px 0"><span class="tag">{branch_label}: {branch}</span>
               <a href="/{owner}/{name}/commits" style="margin-left:8px">{count} {commits}</a></div>
               {latest_html}{table}"#,
            branch_label = t(lang, Msg::Branch),
            branch = escape(&branch),
            owner = escape(&repo.owner),
            name = escape(&repo.name),
            commits = t(lang, Msg::CommitsWord),
        )
    };

    html(layout(
        &format!("{}/{}", repo.owner, repo.name),
        viewer.as_ref(),
        lang,
        &repo_crumbs(&repo),
        &format!("{header}{body}"),
    ))
}

fn render_tree(repo: &Repo, base: &str, entries: &[gitio::TreeEntry], lang: Lang) -> String {
    let mut rows = String::new();
    if entries.is_empty() {
        return format!(r#"<p class="muted">{}</p>"#, t(lang, Msg::EmptyTree));
    }
    for e in entries {
        let child = if base.is_empty() {
            e.name.clone()
        } else {
            format!("{base}/{}", e.name)
        };
        let (icon, link) = if e.kind == "tree" {
            ("📁", format!("/{}/{}/tree/{}", repo.owner, repo.name, child))
        } else {
            ("📄", format!("/{}/{}/blob/{}", repo.owner, repo.name, child))
        };
        rows.push_str(&format!(
            r#"<tr><td><span class="icon">{icon}</span><a href="{link}">{name}</a></td></tr>"#,
            link = escape(&link),
            name = escape(&e.name),
        ));
    }
    format!(r#"<table class="files">{rows}</table>"#)
}

async fn tree_view(
    State(app): State<Arc<App>>,
    headers: HeaderMap,
    Path((owner, name, path)): Path<(String, String, String)>,
) -> Response {
    let lang = current_lang(&headers);
    let viewer = current_user(&app, &headers);
    let repo = match load_repo(&app, &viewer, lang, &owner, &name) {
        Ok(r) => r,
        Err(resp) => return resp,
    };
    let dir = gitio::repo_path(&app.data_dir, &repo.owner, &repo.name);
    let branch = gitio::default_branch(&dir).unwrap_or_else(|| "main".into());
    let path = path.trim_matches('/').to_string();
    let entries = gitio::ls_tree(&dir, &branch, &path);
    let table = render_tree(&repo, &path, &entries, lang);

    let mut crumbs = repo_crumbs(&repo);
    let mut acc = String::new();
    for seg in path.split('/').filter(|s| !s.is_empty()) {
        if acc.is_empty() {
            acc = seg.to_string();
        } else {
            acc = format!("{acc}/{seg}");
        }
        crumbs.push((seg.to_string(), format!("/{}/{}/tree/{}", repo.owner, repo.name, acc)));
    }
    html(layout(
        &format!("{}/{}", repo.owner, repo.name),
        viewer.as_ref(),
        lang,
        &crumbs,
        &table,
    ))
}

async fn blob_view(
    State(app): State<Arc<App>>,
    headers: HeaderMap,
    Path((owner, name, path)): Path<(String, String, String)>,
) -> Response {
    let lang = current_lang(&headers);
    let viewer = current_user(&app, &headers);
    let repo = match load_repo(&app, &viewer, lang, &owner, &name) {
        Ok(r) => r,
        Err(resp) => return resp,
    };
    let dir = gitio::repo_path(&app.data_dir, &repo.owner, &repo.name);
    let branch = gitio::default_branch(&dir).unwrap_or_else(|| "main".into());
    let path = path.trim_matches('/').to_string();

    let content = match gitio::read_blob(&dir, &branch, &path) {
        Some(bytes) => {
            if bytes.contains(&0) {
                format!(
                    r#"<div class="box"><p class="muted">{prefix}{len}{suffix}</p></div>"#,
                    prefix = t(lang, Msg::BinaryPrefix),
                    len = bytes.len(),
                    suffix = t(lang, Msg::BinarySuffix),
                )
            } else {
                let text = String::from_utf8_lossy(&bytes);
                format!(r#"<pre class="code">{}</pre>"#, escape(&text))
            }
        }
        None => return not_found(lang),
    };

    let mut crumbs = repo_crumbs(&repo);
    let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
    let mut acc = String::new();
    for (i, seg) in segs.iter().enumerate() {
        if acc.is_empty() {
            acc = seg.to_string();
        } else {
            acc = format!("{acc}/{seg}");
        }
        let is_last = i == segs.len() - 1;
        let href = if is_last {
            String::new()
        } else {
            format!("/{}/{}/tree/{}", repo.owner, repo.name, acc)
        };
        crumbs.push((seg.to_string(), href));
    }
    html(layout(
        &format!("{}/{}", repo.owner, repo.name),
        viewer.as_ref(),
        lang,
        &crumbs,
        &content,
    ))
}

async fn commits_view(
    State(app): State<Arc<App>>,
    headers: HeaderMap,
    Path((owner, name)): Path<(String, String)>,
) -> Response {
    let lang = current_lang(&headers);
    let viewer = current_user(&app, &headers);
    let repo = match load_repo(&app, &viewer, lang, &owner, &name) {
        Ok(r) => r,
        Err(resp) => return resp,
    };
    let dir = gitio::repo_path(&app.data_dir, &repo.owner, &repo.name);
    let branch = gitio::default_branch(&dir).unwrap_or_else(|| "main".into());
    let commits = gitio::log(&dir, &branch, 100);

    let mut body = format!(r#"<div class="box"><h2>{}</h2>"#, t(lang, Msg::CommitsTitle));
    if commits.is_empty() {
        body.push_str(&format!(r#"<p class="muted">{}</p>"#, t(lang, Msg::NoCommits)));
    }
    for c in &commits {
        body.push_str(&format!(
            r#"<div class="commit"><div>{subject}</div>
               <div class="muted" style="font-size:12px"><span class="hash">{short}</span> · {author} · {date}</div></div>"#,
            subject = escape(&c.subject),
            short = escape(&c.short),
            author = escape(&c.author),
            date = escape(&c.date),
        ));
    }
    body.push_str("</div>");

    let mut crumbs = repo_crumbs(&repo);
    crumbs.push((t(lang, Msg::CommitsTitle).into(), String::new()));
    html(layout(
        &format!("{}/{} commits", repo.owner, repo.name),
        viewer.as_ref(),
        lang,
        &crumbs,
        &body,
    ))
}