//! 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("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => 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}" > 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,
))
}