| 1 | //! `/{repo}/log/{rev}[/{path…}]` — history, newest first, grouped by day. |
| 2 | //! `?after=<oid>` pages, `?q=` filters by message and author. |
| 3 | |
| 4 | use askama::Template; |
| 5 | use axum::http::StatusCode; |
| 6 | use axum::response::Response; |
| 7 | use gix::bstr::ByteSlice; |
| 8 | |
| 9 | use super::repo::{Active, Crumb, Ctx, RepoNav, crumbs, set_cache_headers, short}; |
| 10 | use super::{Layout, render}; |
| 11 | use crate::cache::Key; |
| 12 | use crate::git::ObjectId; |
| 13 | use crate::git::log::{self, LogError, LogQuery, Newer}; |
| 14 | use crate::render::{encode_path, time}; |
| 15 | |
| 16 | pub struct LogRow { |
| 17 | pub subject: String, |
| 18 | pub url: String, |
| 19 | pub author: String, |
| 20 | pub age: String, |
| 21 | pub iso: String, |
| 22 | pub id: String, |
| 23 | } |
| 24 | |
| 25 | pub struct Day { |
| 26 | pub label: String, |
| 27 | pub rows: Vec<LogRow>, |
| 28 | } |
| 29 | |
| 30 | #[derive(Template)] |
| 31 | #[template(path = "log.html")] |
| 32 | pub struct LogPage { |
| 33 | pub layout: Layout, |
| 34 | pub nav: RepoNav, |
| 35 | pub crumbs: Vec<Crumb>, |
| 36 | pub here: Option<String>, |
| 37 | pub days: Vec<Day>, |
| 38 | /// The form posts here (the log URL without query). |
| 39 | pub action: String, |
| 40 | pub q: String, |
| 41 | pub newer_url: Option<String>, |
| 42 | /// `Newer`, or `Newest` when the link jumps back to the first page. |
| 43 | pub newer_label: &'static str, |
| 44 | pub older_url: Option<String>, |
| 45 | pub limit_reached: bool, |
| 46 | pub scan_limit: usize, |
| 47 | } |
| 48 | |
| 49 | pub fn page(ctx: &Ctx, segments: &[Vec<u8>]) -> Response { |
| 50 | let (rev, path) = match ctx.resolve(segments) { |
| 51 | Ok(found) => found, |
| 52 | Err(res) => return *res, |
| 53 | }; |
| 54 | let after = match ctx.query.after.as_deref().filter(|a| !a.is_empty()) { |
| 55 | None => None, |
| 56 | Some(hex) => match ObjectId::from_hex(hex.as_bytes()) { |
| 57 | Ok(id) => Some(id), |
| 58 | Err(_) => return ctx.not_found("INVALID CURSOR"), |
| 59 | }, |
| 60 | }; |
| 61 | let grep = ctx |
| 62 | .query |
| 63 | .q |
| 64 | .as_deref() |
| 65 | .map(str::trim) |
| 66 | .filter(|q| !q.is_empty()) |
| 67 | .map(str::to_string); |
| 68 | let query = LogQuery { |
| 69 | path: path.clone(), |
| 70 | grep, |
| 71 | after, |
| 72 | page: ctx.config.ui.log_page, |
| 73 | scan_limit: ctx.config.limits.log_scan, |
| 74 | }; |
| 75 | let etag = ctx.etag(("log", rev.commit, &query)); |
| 76 | let immutable = rev.is_immutable(); |
| 77 | if let Some(res) = ctx.not_modified(&etag, immutable) { |
| 78 | return res; |
| 79 | } |
| 80 | let key = Key::Log { |
| 81 | tip: rev.commit, |
| 82 | query: query.clone(), |
| 83 | }; |
| 84 | let result = ctx |
| 85 | .state |
| 86 | .cache |
| 87 | .log(key, || log::page(&ctx.git, rev.commit, &query)); |
| 88 | let found = match result { |
| 89 | Ok(found) => found, |
| 90 | Err(LogError::UnknownCursor) => return ctx.not_found("COMMIT NOT IN THIS HISTORY"), |
| 91 | Err(LogError::Git(e)) => return ctx.internal_error(e), |
| 92 | }; |
| 93 | |
| 94 | let action = ctx.url("log", &rev.name, &path); |
| 95 | let link = |after: Option<ObjectId>| { |
| 96 | let mut params = Vec::new(); |
| 97 | if let Some(q) = &query.grep { |
| 98 | params.push(format!("q={}", encode_path(q))); |
| 99 | } |
| 100 | if let Some(id) = after { |
| 101 | params.push(format!("after={id}")); |
| 102 | } |
| 103 | if params.is_empty() { |
| 104 | action.clone() |
| 105 | } else { |
| 106 | format!("{action}?{}", params.join("&")) |
| 107 | } |
| 108 | }; |
| 109 | let (newer_url, newer_label) = match found.newer { |
| 110 | Newer::None => (None, "Newer"), |
| 111 | Newer::First => (Some(link(None)), "Newest"), |
| 112 | Newer::After(id) => (Some(link(Some(id))), "Newer"), |
| 113 | }; |
| 114 | |
| 115 | let mailmap = ctx.mailmap(); |
| 116 | let now = time::now(); |
| 117 | let mut days: Vec<Day> = Vec::new(); |
| 118 | for commit in &found.commits { |
| 119 | let label = time::day(commit.committer_time); |
| 120 | let id = commit.id.to_string(); |
| 121 | let row = LogRow { |
| 122 | subject: if commit.subject.is_empty() { |
| 123 | "(no message)".into() |
| 124 | } else { |
| 125 | commit.subject.clone() |
| 126 | }, |
| 127 | url: ctx.commit_url(&commit.id), |
| 128 | author: mailmap.resolve(&commit.author_name, &commit.author_email).0, |
| 129 | age: time::relative(commit.committer_time, now), |
| 130 | iso: time::iso(commit.committer_time), |
| 131 | id: short(&id), |
| 132 | }; |
| 133 | match days.last_mut() { |
| 134 | Some(day) if day.label == label => day.rows.push(row), |
| 135 | _ => days.push(Day { |
| 136 | label, |
| 137 | rows: vec![row], |
| 138 | }), |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | let (crumbs, last) = crumbs(ctx, &rev.name, &path); |
| 143 | let title = if path.is_empty() { |
| 144 | format!("Log · {}", ctx.repo.name) |
| 145 | } else { |
| 146 | format!("History of {} · {}", path.to_str_lossy(), ctx.repo.name) |
| 147 | }; |
| 148 | let older_url = found.older.map(|id| link(Some(id))); |
| 149 | let page = LogPage { |
| 150 | layout: Layout { |
| 151 | feed: Some(ctx.feed_url()), |
| 152 | ..Layout::new(ctx.state, ctx.config, Some(&title)) |
| 153 | }, |
| 154 | nav: ctx.nav(Active::Log, Some(&rev), |name| ctx.url("log", name, &path)), |
| 155 | crumbs, |
| 156 | here: last, |
| 157 | days, |
| 158 | action, |
| 159 | q: query.grep.clone().unwrap_or_default(), |
| 160 | newer_url, |
| 161 | newer_label, |
| 162 | older_url, |
| 163 | limit_reached: found.limit_reached, |
| 164 | scan_limit: query.scan_limit, |
| 165 | }; |
| 166 | let mut res = render(StatusCode::OK, &page); |
| 167 | set_cache_headers(&mut res, &etag, immutable); |
| 168 | res |
| 169 | } |