worklyn / zukapublic
Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.
Get a copy:
git clone https://zuka.worklyn.com/worklyn/zuka.git
| 1 | // The individual views. |
| 2 | // |
| 3 | // Each page reads through `git::discover`, which is the same code the REST API and |
| 4 | // the MCP tools use — so the browser cannot show something the API disagrees with, |
| 5 | // and a fix to a git query lands everywhere at once. |
| 6 | // |
| 7 | // Every value that reaches the output goes through `html::escape` or `url_escape`. |
| 8 | |
| 9 | use super::html::{self, escape, url_escape, Page}; |
| 10 | use super::{default_short_ref, ok_html, resolve_ref}; |
| 11 | use crate::error::{Error, Result}; |
| 12 | use crate::git::discover; |
| 13 | use crate::http::response::Body; |
| 14 | use crate::http::AppState; |
| 15 | use crate::store::RepoRecord; |
| 16 | use hyper::{Response, StatusCode}; |
| 17 | use serde_json::Value; |
| 18 | use std::path::{Path, PathBuf}; |
| 19 | use std::sync::Arc; |
| 20 | |
| 21 | pub struct Context { |
| 22 | pub record: RepoRecord, |
| 23 | /// Canonical URL prefix for every link this page emits. |
| 24 | pub base: String, |
| 25 | pub state: Arc<AppState>, |
| 26 | } |
| 27 | |
| 28 | impl Context { |
| 29 | fn link(&self, suffix: &str) -> String { |
| 30 | format!("{}{}", self.base, suffix) |
| 31 | } |
| 32 | |
| 33 | fn crumbs(&self, trail: &str) -> String { |
| 34 | let mut out = format!( |
| 35 | "<a href=\"{}\">{}</a>", |
| 36 | self.link(""), |
| 37 | escape(&self.record.display_name) |
| 38 | ); |
| 39 | if !trail.is_empty() { |
| 40 | out.push_str(&format!("<span class=\"sep\">/</span>{trail}")); |
| 41 | } |
| 42 | out |
| 43 | } |
| 44 | |
| 45 | fn title(&self, extra: &str) -> String { |
| 46 | if extra.is_empty() { |
| 47 | format!("{}/{}", self.record.account, self.record.display_name) |
| 48 | } else { |
| 49 | format!( |
| 50 | "{} · {}/{}", |
| 51 | extra, self.record.account, self.record.display_name |
| 52 | ) |
| 53 | } |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | /// Names tried, in order, when looking for a README to render. |
| 58 | const README_NAMES: &[&str] = &["README.md", "readme.md", "README", "README.markdown"]; |
| 59 | |
| 60 | /// Files that are content but not code, and would be meaningless as text. |
| 61 | fn is_probably_binary(bytes: &[u8]) -> bool { |
| 62 | // A NUL in the first few KiB is what git itself uses to call a file binary. |
| 63 | bytes.iter().take(8000).any(|b| *b == 0) |
| 64 | } |
| 65 | |
| 66 | fn relative_time(then: u64) -> String { |
| 67 | let now = crate::account::token::now_secs(); |
| 68 | let delta = now.saturating_sub(then); |
| 69 | match delta { |
| 70 | 0..=59 => "just now".to_string(), |
| 71 | 60..=3599 => format!("{} min ago", delta / 60), |
| 72 | 3600..=86399 => format!("{} hr ago", delta / 3600), |
| 73 | 86400..=2591999 => format!("{} days ago", delta / 86400), |
| 74 | 2592000..=31535999 => format!("{} months ago", delta / 2592000), |
| 75 | _ => format!("{} years ago", delta / 31536000), |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | fn human_size(bytes: u64) -> String { |
| 80 | const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"]; |
| 81 | let mut value = bytes as f64; |
| 82 | let mut unit = 0; |
| 83 | while value >= 1024.0 && unit < UNITS.len() - 1 { |
| 84 | value /= 1024.0; |
| 85 | unit += 1; |
| 86 | } |
| 87 | if unit == 0 { |
| 88 | format!("{bytes} {}", UNITS[0]) |
| 89 | } else { |
| 90 | format!("{value:.1} {}", UNITS[unit]) |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | fn short_sha(sha: &str) -> &str { |
| 95 | sha.get(..8).unwrap_or(sha) |
| 96 | } |
| 97 | |
| 98 | /// The header block every repository page carries. |
| 99 | fn repo_header(ctx: &Context, refs: &[(String, String)], current: &str) -> String { |
| 100 | let mut out = String::new(); |
| 101 | out.push_str("<div class=\"repo-head\">\n"); |
| 102 | |
| 103 | let badge = if ctx.record.visibility.is_public() { |
| 104 | "<span class=\"badge public\">public</span>" |
| 105 | } else { |
| 106 | "<span class=\"badge\">private</span>" |
| 107 | }; |
| 108 | out.push_str(&format!( |
| 109 | "<h1 class=\"repo-title\">{}{}</h1>\n", |
| 110 | escape(&ctx.record.display_name), |
| 111 | badge |
| 112 | )); |
| 113 | |
| 114 | if let Some(description) = &ctx.record.description { |
| 115 | out.push_str(&format!( |
| 116 | "<p class=\"repo-desc\">{}</p>\n", |
| 117 | escape(description) |
| 118 | )); |
| 119 | } |
| 120 | |
| 121 | // Only shown for public repositories: printing a clone URL that a reader cannot |
| 122 | // use without a credential they have not got is an invitation to a failure. |
| 123 | if ctx.record.visibility.is_public() { |
| 124 | let http = format!( |
| 125 | "{}/{}/{}.git", |
| 126 | ctx.state.config.public_host(), |
| 127 | ctx.record.account, |
| 128 | ctx.record.name |
| 129 | ); |
| 130 | out.push_str("<div class=\"clone\">\n"); |
| 131 | out.push_str(&format!("<code>git clone {}</code>\n", escape(&http))); |
| 132 | out.push_str("</div>\n"); |
| 133 | } |
| 134 | |
| 135 | out.push_str("</div>\n"); |
| 136 | |
| 137 | // Ref picker. A plain link list rather than a control, because there is no |
| 138 | // JavaScript on this surface and a <select> without it does nothing. |
| 139 | if !refs.is_empty() { |
| 140 | out.push_str("<div class=\"toolbar\">\n"); |
| 141 | for (name, short) in refs.iter().take(12) { |
| 142 | let _ = name; |
| 143 | let mark = if short == current { " badge" } else { "" }; |
| 144 | out.push_str(&format!( |
| 145 | "<a class=\"picker{}\" href=\"{}/tree/{}/\">{}</a>\n", |
| 146 | mark, |
| 147 | ctx.link(""), |
| 148 | url_escape(short), |
| 149 | escape(short) |
| 150 | )); |
| 151 | } |
| 152 | out.push_str("<span class=\"spacer\"></span>\n"); |
| 153 | out.push_str(&format!( |
| 154 | "<a class=\"picker\" href=\"{}/commits/{}\">history</a>\n", |
| 155 | ctx.link(""), |
| 156 | url_escape(current) |
| 157 | )); |
| 158 | out.push_str("</div>\n"); |
| 159 | } |
| 160 | out |
| 161 | } |
| 162 | |
| 163 | /// Branch and tag short names, branches first. |
| 164 | fn ref_list(git_dir: &Path) -> Vec<(String, String)> { |
| 165 | let Ok(value) = discover::refs(git_dir, None) else { |
| 166 | return Vec::new(); |
| 167 | }; |
| 168 | let mut out = Vec::new(); |
| 169 | for item in value |
| 170 | .get("items") |
| 171 | .and_then(|i| i.as_array()) |
| 172 | .into_iter() |
| 173 | .flatten() |
| 174 | { |
| 175 | let Some(name) = item.get("name").and_then(|n| n.as_str()) else { |
| 176 | continue; |
| 177 | }; |
| 178 | if let Some(short) = name.strip_prefix("refs/heads/") { |
| 179 | out.push((name.to_string(), short.to_string())); |
| 180 | } |
| 181 | } |
| 182 | out |
| 183 | } |
| 184 | |
| 185 | // ── pages ─────────────────────────────────────────────────────────────────── |
| 186 | |
| 187 | pub async fn home(ctx: Context, git_dir: PathBuf) -> Result<Response<Body>> { |
| 188 | let reference = default_short_ref(&ctx.record.default_branch).to_string(); |
| 189 | tree(ctx, git_dir, reference, "").await |
| 190 | } |
| 191 | |
| 192 | pub async fn tree( |
| 193 | ctx: Context, |
| 194 | git_dir: PathBuf, |
| 195 | reference: String, |
| 196 | path: &str, |
| 197 | ) -> Result<Response<Body>> { |
| 198 | let path = path.trim_end_matches('/').to_string(); |
| 199 | let dir = git_dir.clone(); |
| 200 | let wanted = reference.clone(); |
| 201 | let sub = path.clone(); |
| 202 | |
| 203 | let rendered = crate::git::exec::blocking(move || { |
| 204 | let refs = ref_list(&dir); |
| 205 | // An empty repository has no resolvable ref at all, which is a normal state |
| 206 | // straight after `repo_create` and must not read as an error. |
| 207 | let Ok(resolved) = resolve_ref(&dir, &wanted) else { |
| 208 | return Ok::<_, Error>(None); |
| 209 | }; |
| 210 | let listing = discover::tree( |
| 211 | &dir, |
| 212 | &resolved, |
| 213 | if sub.is_empty() { None } else { Some(&sub) }, |
| 214 | )?; |
| 215 | let readme = find_readme(&dir, &resolved, &sub); |
| 216 | Ok(Some((refs, listing, readme))) |
| 217 | }) |
| 218 | .await?; |
| 219 | |
| 220 | let Some((refs, listing, readme)) = rendered else { |
| 221 | return Ok(ok_html(html::document(&Page { |
| 222 | title: ctx.title(""), |
| 223 | heading: ctx.crumbs(""), |
| 224 | body: format!( |
| 225 | "{}<div class=\"panel\"><div class=\"notice\"><strong>Nothing here yet</strong>\ |
| 226 | This repository has no commits.</div></div>", |
| 227 | repo_header(&ctx, &[], &reference) |
| 228 | ), |
| 229 | }))); |
| 230 | }; |
| 231 | |
| 232 | let mut body = repo_header(&ctx, &refs, &reference); |
| 233 | body.push_str(&path_crumbs(&ctx, &reference, &path)); |
| 234 | body.push_str("<div class=\"panel\">\n"); |
| 235 | body.push_str(&format!( |
| 236 | "<div class=\"panel-head\">{}</div>\n", |
| 237 | if path.is_empty() { |
| 238 | escape(&reference) |
| 239 | } else { |
| 240 | escape(&path) |
| 241 | } |
| 242 | )); |
| 243 | |
| 244 | let items = listing |
| 245 | .get("items") |
| 246 | .and_then(|i| i.as_array()) |
| 247 | .cloned() |
| 248 | .unwrap_or_default(); |
| 249 | |
| 250 | if items.is_empty() { |
| 251 | body.push_str("<div class=\"empty\">This directory is empty.</div>\n"); |
| 252 | } else { |
| 253 | body.push_str("<table class=\"listing\">\n"); |
| 254 | |
| 255 | if !path.is_empty() { |
| 256 | let parent = path.rsplit_once('/').map(|(p, _)| p).unwrap_or(""); |
| 257 | body.push_str(&format!( |
| 258 | "<tr><td class=\"name\" colspan=\"2\">\ |
| 259 | <span class=\"icon dir\">↰</span><a href=\"{}/tree/{}/{}\">..</a></td></tr>\n", |
| 260 | ctx.link(""), |
| 261 | url_escape(&reference), |
| 262 | url_escape(parent) |
| 263 | )); |
| 264 | } |
| 265 | |
| 266 | // Directories first, then files, each alphabetically — the order a person |
| 267 | // expects, which git's own output does not guarantee. |
| 268 | let mut rows: Vec<&Value> = items.iter().collect(); |
| 269 | rows.sort_by_key(|item| { |
| 270 | let kind = item.get("type").and_then(|t| t.as_str()).unwrap_or("file"); |
| 271 | let name = item.get("name").and_then(|n| n.as_str()).unwrap_or(""); |
| 272 | (kind != "dir", name.to_lowercase()) |
| 273 | }); |
| 274 | |
| 275 | for item in rows { |
| 276 | let name = item.get("name").and_then(|n| n.as_str()).unwrap_or(""); |
| 277 | let kind = item.get("type").and_then(|t| t.as_str()).unwrap_or("file"); |
| 278 | let full = item.get("path").and_then(|p| p.as_str()).unwrap_or(name); |
| 279 | let size = item.get("size").and_then(|s| s.as_u64()); |
| 280 | |
| 281 | let (verb, icon) = match kind { |
| 282 | "dir" => ("tree", "<span class=\"icon dir\">▸</span>"), |
| 283 | "submodule" => ("tree", "<span class=\"icon\">⧉</span>"), |
| 284 | _ => ("blob", "<span class=\"icon\">·</span>"), |
| 285 | }; |
| 286 | |
| 287 | body.push_str(&format!( |
| 288 | "<tr><td class=\"name\">{}<a href=\"{}/{}/{}/{}\">{}</a></td>\ |
| 289 | <td class=\"size\">{}</td></tr>\n", |
| 290 | icon, |
| 291 | ctx.link(""), |
| 292 | verb, |
| 293 | url_escape(&reference), |
| 294 | url_escape(full), |
| 295 | escape(name), |
| 296 | size.filter(|_| kind == "file") |
| 297 | .map(human_size) |
| 298 | .unwrap_or_default() |
| 299 | )); |
| 300 | } |
| 301 | body.push_str("</table>\n"); |
| 302 | } |
| 303 | body.push_str("</div>\n"); |
| 304 | |
| 305 | if let Some((name, source)) = readme { |
| 306 | body.push_str("<div class=\"panel\">\n"); |
| 307 | body.push_str(&format!( |
| 308 | "<div class=\"panel-head\">{}</div>\n", |
| 309 | escape(&name) |
| 310 | )); |
| 311 | body.push_str(&format!( |
| 312 | "<div class=\"prose\">{}</div>\n", |
| 313 | html::markdown(&source) |
| 314 | )); |
| 315 | body.push_str("</div>\n"); |
| 316 | } |
| 317 | |
| 318 | Ok(ok_html(html::document(&Page { |
| 319 | title: ctx.title(if path.is_empty() { "" } else { &path }), |
| 320 | heading: ctx.crumbs(&escape(&reference)), |
| 321 | body, |
| 322 | }))) |
| 323 | } |
| 324 | |
| 325 | fn find_readme(git_dir: &Path, resolved: &str, dir: &str) -> Option<(String, String)> { |
| 326 | // A README is rendered inline, so a huge one is a page nobody can load. This is |
| 327 | // a display cap, not the API's blob limit. |
| 328 | let limits = discover::Limits { |
| 329 | max_blob_bytes: 1024 * 1024, |
| 330 | log_walk_budget: 0, |
| 331 | }; |
| 332 | for name in README_NAMES { |
| 333 | let path = if dir.is_empty() { |
| 334 | (*name).to_string() |
| 335 | } else { |
| 336 | format!("{dir}/{name}") |
| 337 | }; |
| 338 | if let Ok(blob) = discover::blob(git_dir, resolved, &path, limits) { |
| 339 | if let Ok(text) = String::from_utf8(blob.bytes) { |
| 340 | return Some(((*name).to_string(), text)); |
| 341 | } |
| 342 | } |
| 343 | } |
| 344 | None |
| 345 | } |
| 346 | |
| 347 | fn path_crumbs(ctx: &Context, reference: &str, path: &str) -> String { |
| 348 | if path.is_empty() { |
| 349 | return String::new(); |
| 350 | } |
| 351 | let mut out = String::from("<div class=\"toolbar\"><div class=\"crumbs\">"); |
| 352 | out.push_str(&format!( |
| 353 | "<a href=\"{}/tree/{}/\">{}</a>", |
| 354 | ctx.link(""), |
| 355 | url_escape(reference), |
| 356 | escape(&ctx.record.display_name) |
| 357 | )); |
| 358 | |
| 359 | let mut walked = String::new(); |
| 360 | let parts: Vec<&str> = path.split('/').collect(); |
| 361 | for (i, part) in parts.iter().enumerate() { |
| 362 | if !walked.is_empty() { |
| 363 | walked.push('/'); |
| 364 | } |
| 365 | walked.push_str(part); |
| 366 | out.push_str("<span class=\"sep\">/</span>"); |
| 367 | if i + 1 == parts.len() { |
| 368 | out.push_str(&escape(part)); |
| 369 | } else { |
| 370 | out.push_str(&format!( |
| 371 | "<a href=\"{}/tree/{}/{}\">{}</a>", |
| 372 | ctx.link(""), |
| 373 | url_escape(reference), |
| 374 | url_escape(&walked), |
| 375 | escape(part) |
| 376 | )); |
| 377 | } |
| 378 | } |
| 379 | out.push_str("</div></div>"); |
| 380 | out |
| 381 | } |
| 382 | |
| 383 | pub async fn blob( |
| 384 | ctx: Context, |
| 385 | git_dir: PathBuf, |
| 386 | reference: String, |
| 387 | path: &str, |
| 388 | ) -> Result<Response<Body>> { |
| 389 | let dir = git_dir.clone(); |
| 390 | let wanted = reference.clone(); |
| 391 | let target = path.to_string(); |
| 392 | let limits = ctx.state.config.read_limits(); |
| 393 | |
| 394 | let (refs, blob) = crate::git::exec::blocking(move || { |
| 395 | let refs = ref_list(&dir); |
| 396 | let resolved = resolve_ref(&dir, &wanted)?; |
| 397 | let blob = discover::blob(&dir, &resolved, &target, limits)?; |
| 398 | Ok::<_, Error>((refs, blob)) |
| 399 | }) |
| 400 | .await?; |
| 401 | |
| 402 | let mut body = repo_header(&ctx, &refs, &reference); |
| 403 | body.push_str(&path_crumbs(&ctx, &reference, path)); |
| 404 | body.push_str("<div class=\"panel\">\n"); |
| 405 | body.push_str(&format!( |
| 406 | "<div class=\"panel-head\"><span>{}</span><span class=\"spacer\"></span>\ |
| 407 | <code class=\"sha\">{}</code>\ |
| 408 | <a href=\"/v1/repos/{}/{}/raw/{}?ref={}\">raw</a></div>\n", |
| 409 | escape(&human_size(blob.bytes.len() as u64)), |
| 410 | escape(short_sha(&blob.sha)), |
| 411 | url_escape(&ctx.record.account), |
| 412 | url_escape(&ctx.record.name), |
| 413 | url_escape(path), |
| 414 | url_escape(&reference), |
| 415 | )); |
| 416 | |
| 417 | if is_probably_binary(&blob.bytes) { |
| 418 | body.push_str( |
| 419 | "<div class=\"empty\">This is a binary file and is not shown.</div>\n</div>\n", |
| 420 | ); |
| 421 | } else { |
| 422 | let text = String::from_utf8_lossy(&blob.bytes); |
| 423 | body.push_str("<div class=\"code\">\n<table class=\"code-table\">\n"); |
| 424 | for (i, line) in text.lines().enumerate() { |
| 425 | let n = i + 1; |
| 426 | body.push_str(&format!( |
| 427 | "<tr id=\"L{n}\"><td class=\"ln\"><a href=\"#L{n}\">{n}</a></td>\ |
| 428 | <td class=\"src\">{}</td></tr>\n", |
| 429 | escape(line) |
| 430 | )); |
| 431 | } |
| 432 | body.push_str("</table>\n</div>\n</div>\n"); |
| 433 | } |
| 434 | |
| 435 | Ok(ok_html(html::document(&Page { |
| 436 | title: ctx.title(path), |
| 437 | heading: ctx.crumbs(&escape(path)), |
| 438 | body, |
| 439 | }))) |
| 440 | } |
| 441 | |
| 442 | pub async fn commits(ctx: Context, git_dir: PathBuf, reference: String) -> Result<Response<Body>> { |
| 443 | let dir = git_dir.clone(); |
| 444 | let wanted = reference.clone(); |
| 445 | let limits = ctx.state.config.read_limits(); |
| 446 | |
| 447 | let (refs, log) = crate::git::exec::blocking(move || { |
| 448 | let refs = ref_list(&dir); |
| 449 | let resolved = resolve_ref(&dir, &wanted)?; |
| 450 | let log = discover::log(&dir, &resolved, 100, None, limits)?; |
| 451 | Ok::<_, Error>((refs, log)) |
| 452 | }) |
| 453 | .await?; |
| 454 | |
| 455 | let mut body = repo_header(&ctx, &refs, &reference); |
| 456 | body.push_str("<div class=\"panel\">\n<div class=\"panel-head\">commits</div>\n"); |
| 457 | body.push_str("<table class=\"listing\">\n"); |
| 458 | |
| 459 | for item in log |
| 460 | .get("items") |
| 461 | .and_then(|i| i.as_array()) |
| 462 | .into_iter() |
| 463 | .flatten() |
| 464 | { |
| 465 | let sha = item.get("sha").and_then(|s| s.as_str()).unwrap_or(""); |
| 466 | let message = item.get("message").and_then(|m| m.as_str()).unwrap_or(""); |
| 467 | let subject = message.lines().next().unwrap_or(""); |
| 468 | let author = item |
| 469 | .get("author_name") |
| 470 | .and_then(|a| a.as_str()) |
| 471 | .unwrap_or("unknown"); |
| 472 | let at = item |
| 473 | .get("authored_at") |
| 474 | .and_then(|a| a.as_u64()) |
| 475 | .unwrap_or(0); |
| 476 | |
| 477 | body.push_str(&format!( |
| 478 | "<tr><td class=\"name\">\ |
| 479 | <a class=\"commit-subject\" href=\"{}/commit/{}\">{}</a>\ |
| 480 | <div class=\"commit-meta\">{} · <code class=\"sha\">{}</code></div></td>\ |
| 481 | <td class=\"when\">{}</td></tr>\n", |
| 482 | ctx.link(""), |
| 483 | url_escape(sha), |
| 484 | escape(subject), |
| 485 | escape(author), |
| 486 | escape(short_sha(sha)), |
| 487 | escape(&relative_time(at)) |
| 488 | )); |
| 489 | } |
| 490 | |
| 491 | body.push_str("</table>\n</div>\n"); |
| 492 | |
| 493 | Ok(ok_html(html::document(&Page { |
| 494 | title: ctx.title("commits"), |
| 495 | heading: ctx.crumbs("commits"), |
| 496 | body, |
| 497 | }))) |
| 498 | } |
| 499 | |
| 500 | pub async fn commit(ctx: Context, git_dir: PathBuf, sha: String) -> Result<Response<Body>> { |
| 501 | let dir = git_dir.clone(); |
| 502 | let wanted = sha.clone(); |
| 503 | |
| 504 | let detail = crate::git::exec::blocking(move || { |
| 505 | let resolved = discover::revision(&wanted)?; |
| 506 | discover::commit(&dir, &resolved) |
| 507 | }) |
| 508 | .await?; |
| 509 | |
| 510 | let info = detail.get("commit").cloned().unwrap_or(Value::Null); |
| 511 | let message = info.get("message").and_then(|m| m.as_str()).unwrap_or(""); |
| 512 | let subject = message.lines().next().unwrap_or(""); |
| 513 | let author = info |
| 514 | .get("author_name") |
| 515 | .and_then(|a| a.as_str()) |
| 516 | .unwrap_or("unknown"); |
| 517 | let at = info |
| 518 | .get("authored_at") |
| 519 | .and_then(|a| a.as_u64()) |
| 520 | .unwrap_or(0); |
| 521 | |
| 522 | let mut body = repo_header(&ctx, &[], default_short_ref(&ctx.record.default_branch)); |
| 523 | body.push_str("<div class=\"panel\">\n"); |
| 524 | body.push_str(&format!( |
| 525 | "<div class=\"panel-head\"><code class=\"sha\">{}</code>\ |
| 526 | <span class=\"spacer\"></span><span>{} · {}</span></div>\n", |
| 527 | escape(short_sha(&sha)), |
| 528 | escape(author), |
| 529 | escape(&relative_time(at)) |
| 530 | )); |
| 531 | body.push_str(&format!( |
| 532 | "<pre class=\"message\">{}</pre>\n</div>\n", |
| 533 | escape(message) |
| 534 | )); |
| 535 | |
| 536 | let changed = detail |
| 537 | .get("changed") |
| 538 | .and_then(|c| c.as_array()) |
| 539 | .cloned() |
| 540 | .unwrap_or_default(); |
| 541 | |
| 542 | if !changed.is_empty() { |
| 543 | body.push_str("<div class=\"panel\">\n"); |
| 544 | body.push_str(&format!( |
| 545 | "<div class=\"panel-head\">{} changed</div>\n", |
| 546 | changed.len() |
| 547 | )); |
| 548 | body.push_str("<table class=\"listing\">\n"); |
| 549 | for entry in &changed { |
| 550 | let status = entry |
| 551 | .get("status") |
| 552 | .and_then(|s| s.as_str()) |
| 553 | .unwrap_or("M") |
| 554 | .chars() |
| 555 | .next() |
| 556 | .unwrap_or('M'); |
| 557 | let path = entry.get("path").and_then(|p| p.as_str()).unwrap_or(""); |
| 558 | body.push_str(&format!( |
| 559 | "<tr><td class=\"name\"><span class=\"status {status}\">{status}</span>\ |
| 560 | <a href=\"{}/blob/{}/{}\">{}</a></td></tr>\n", |
| 561 | ctx.link(""), |
| 562 | url_escape(&sha), |
| 563 | url_escape(path), |
| 564 | escape(path) |
| 565 | )); |
| 566 | } |
| 567 | body.push_str("</table>\n</div>\n"); |
| 568 | } |
| 569 | |
| 570 | Ok(ok_html(html::document(&Page { |
| 571 | title: ctx.title(subject), |
| 572 | heading: ctx.crumbs(&escape(short_sha(&sha))), |
| 573 | body, |
| 574 | }))) |
| 575 | } |
| 576 | |
| 577 | pub async fn refs(ctx: Context, git_dir: PathBuf) -> Result<Response<Body>> { |
| 578 | let dir = git_dir.clone(); |
| 579 | let value = crate::git::exec::blocking(move || discover::refs(&dir, None)).await?; |
| 580 | |
| 581 | let mut body = repo_header(&ctx, &[], ""); |
| 582 | body.push_str("<div class=\"panel\">\n<div class=\"panel-head\">refs</div>\n"); |
| 583 | body.push_str("<table class=\"listing\">\n"); |
| 584 | for item in value |
| 585 | .get("items") |
| 586 | .and_then(|i| i.as_array()) |
| 587 | .into_iter() |
| 588 | .flatten() |
| 589 | { |
| 590 | let name = item.get("name").and_then(|n| n.as_str()).unwrap_or(""); |
| 591 | let sha = item.get("sha").and_then(|s| s.as_str()).unwrap_or(""); |
| 592 | body.push_str(&format!( |
| 593 | "<tr><td class=\"name\">{}</td><td class=\"size\"><code class=\"sha\">{}</code></td></tr>\n", |
| 594 | escape(name), |
| 595 | escape(short_sha(sha)) |
| 596 | )); |
| 597 | } |
| 598 | body.push_str("</table>\n</div>\n"); |
| 599 | |
| 600 | Ok(ok_html(html::document(&Page { |
| 601 | title: ctx.title("refs"), |
| 602 | heading: ctx.crumbs("refs"), |
| 603 | body, |
| 604 | }))) |
| 605 | } |
| 606 | |
| 607 | /// Shown at `/` when the host has not named a repository to feature. |
| 608 | pub fn welcome() -> Response<Body> { |
| 609 | let body = format!( |
| 610 | "<div class=\"panel\" style=\"margin-top:40px\"><div class=\"notice\">\ |
| 611 | <strong>{} is running</strong>\ |
| 612 | No repository is published at this address. Set {}HOME_REPO to \ |
| 613 | <code>account/repo</code> to feature one here.</div></div>", |
| 614 | escape(crate::brand::NAME), |
| 615 | escape(crate::brand::env_prefix()), |
| 616 | ); |
| 617 | response_html(html::document(&Page { |
| 618 | title: crate::brand::NAME.to_string(), |
| 619 | heading: String::new(), |
| 620 | body, |
| 621 | })) |
| 622 | } |
| 623 | |
| 624 | fn response_html(body: String) -> Response<Body> { |
| 625 | crate::http::response::html(StatusCode::OK, body) |
| 626 | } |
| 627 | |
| 628 | #[cfg(test)] |
| 629 | mod tests { |
| 630 | use super::*; |
| 631 | |
| 632 | #[test] |
| 633 | fn a_file_with_a_nul_byte_is_treated_as_binary() { |
| 634 | assert!(is_probably_binary(b"\x7fELF\0\0\0")); |
| 635 | assert!(!is_probably_binary(b"fn main() {}\n")); |
| 636 | // Only the head is inspected, so a large text file stays cheap to classify. |
| 637 | let mut long = vec![b'a'; 9000]; |
| 638 | long.push(0); |
| 639 | assert!(!is_probably_binary(&long)); |
| 640 | } |
| 641 | |
| 642 | #[test] |
| 643 | fn sizes_read_the_way_a_person_expects() { |
| 644 | assert_eq!(human_size(0), "0 B"); |
| 645 | assert_eq!(human_size(999), "999 B"); |
| 646 | assert_eq!(human_size(1024), "1.0 KB"); |
| 647 | assert_eq!(human_size(1536), "1.5 KB"); |
| 648 | assert_eq!(human_size(1024 * 1024 * 3), "3.0 MB"); |
| 649 | } |
| 650 | |
| 651 | #[test] |
| 652 | fn a_short_sha_never_panics_on_an_odd_length() { |
| 653 | assert_eq!(short_sha("abcdef0123456789"), "abcdef01"); |
| 654 | // Slicing would panic here; a truncated value must degrade, not crash. |
| 655 | assert_eq!(short_sha("abc"), "abc"); |
| 656 | assert_eq!(short_sha(""), ""); |
| 657 | } |
| 658 | } |