crates/cli/ents-web/src/pages/commits.rs
commits.rshistorycomment on this file
| 1 | //! `GET /commits`, `GET /commit/{oid}`: a read-only commit history and |
| 2 | //! per-commit unified diff over `HEAD` -- a tab of its own (both routes |
| 3 | //! render with `super::Tab::Commits` active; see [`super`]'s own doc), |
| 4 | //! also reached from [`super::files`]'s "history" link. |
| 5 | //! |
| 6 | //! Reads go through `gix`'s high-level `Repository`/`Commit`/`Tree` types, |
| 7 | //! opened fresh per request from `state.path`, exactly as |
| 8 | //! [`super::files`]/[`super::dashboard`] browse `HEAD` -- `facet-git-tree`'s |
| 9 | //! typed-tree convention is for meta-ref entities, not browsing arbitrary |
| 10 | //! repository history. The unified diff itself is built directly on top of |
| 11 | //! `gix::diff::blob` (`gix_diff`'s own re-export through the `gix` |
| 12 | //! facade): [`gix::diff::blob::InternedInput`] interns each side's lines, |
| 13 | //! [`gix::diff::blob::diff_with_slider_heuristics`] computes the hunks, and |
| 14 | //! [`gix::diff::blob::unified_diff::ConsumeBinaryHunk`] renders them as the |
| 15 | //! same textual unified-diff format `git diff` itself produces, which |
| 16 | //! `diff_class` then colorizes line by line -- no new dependency, since |
| 17 | //! `gix`'s default features already enable `blob-diff`. |
| 18 | //! |
| 19 | //! `GET /commit/{oid}` also lists a "conversation": every comment whose |
| 20 | //! anchor was captured against that exact commit |
| 21 | //! (`crate::pages::comments::for_commit`), rendered below the diff via the |
| 22 | //! same `crate::pages::comments::comment_card` a blob view uses, each |
| 23 | //! naming its `path#lines` and linking into `crate::pages::files`'s own |
| 24 | //! `#L<n>` gutter. A "comment on this commit" link beside the parents list |
| 25 | //! reaches `crate::pages::comments::list`'s add form with `rev` prefilled |
| 26 | //! to this commit's own oid. |
| 27 | |
| 28 | use std::sync::Arc; |
| 29 | |
| 30 | use axum::Form; |
| 31 | use axum::extract::{Path, Query, State}; |
| 32 | use axum::response::{IntoResponse, Redirect}; |
| 33 | use gix::bstr::ByteSlice as _; |
| 34 | use gix::diff::blob::unified_diff::{ConsumeBinaryHunk, ContextSize}; |
| 35 | use gix::diff::blob::{Algorithm, InternedInput, UnifiedDiff, diff_with_slider_heuristics}; |
| 36 | use gix::object::tree::diff::Change; |
| 37 | use gix_hash::ObjectId; |
| 38 | use gix_object::{Find, Write}; |
| 39 | use maud::{Markup, html}; |
| 40 | use serde::Deserialize; |
| 41 | |
| 42 | use crate::error::{Error, Result}; |
| 43 | use crate::session::Session; |
| 44 | use crate::state::AppState; |
| 45 | |
| 46 | /// One page of `GET /commits`. |
| 47 | const PAGE_SIZE: usize = 50; |
| 48 | |
| 49 | /// The largest total diff rendered in full on `GET /commit/{oid}` -- past |
| 50 | /// it, reading every changed blob into memory and diffing it would be |
| 51 | /// unbounded, so the page shows a truncation notice instead (mirrors |
| 52 | /// `pre-redo:crates/git-ents-server/src/web/pages.rs`'s own |
| 53 | /// `MAX_RENDER_BYTES`, at this page's own, smaller budget). |
| 54 | const MAX_DIFF_BYTES: usize = 1024 * 1024; |
| 55 | |
| 56 | /// The query parameters `GET /commits` accepts. |
| 57 | #[derive(Debug, Deserialize)] |
| 58 | pub struct ListQuery { |
| 59 | /// Continue the walk just past this previously shown commit (the |
| 60 | /// "older" link) -- omitted for the first page. |
| 61 | from: Option<String>, |
| 62 | } |
| 63 | |
| 64 | /// One row of `GET /commits` -- also what [`super::dashboard`]'s History |
| 65 | /// card renders, at its own smaller limit, so the two pages share one |
| 66 | /// history read. |
| 67 | pub(crate) struct CommitRow { |
| 68 | /// The full commit id, the `/commit/{oid}` link target. |
| 69 | pub(crate) oid: ObjectId, |
| 70 | /// [`super::short_oid`] of `oid`, the row's displayed, mono id. |
| 71 | pub(crate) short: String, |
| 72 | /// The commit message's title line. |
| 73 | pub(crate) subject: String, |
| 74 | /// The commit author's display name. |
| 75 | pub(crate) author: String, |
| 76 | /// [`super::ago`] of the commit author's time. |
| 77 | pub(crate) ago: String, |
| 78 | } |
| 79 | |
| 80 | /// `GET /commits`: the repository's commit history, newest first, 50 per |
| 81 | /// page. |
| 82 | /// |
| 83 | /// # Errors |
| 84 | /// |
| 85 | /// Never fails on an unopenable repository or an unborn `HEAD` -- both |
| 86 | /// degrade to a blankslate. |
| 87 | pub async fn list<O>( |
| 88 | State(state): State<Arc<AppState<O>>>, |
| 89 | Query(params): Query<ListQuery>, |
| 90 | ) -> Result<Markup> |
| 91 | where |
| 92 | O: Find + Write + Send + 'static, |
| 93 | { |
| 94 | let (rows, older) = commit_rows(&state, params.from.as_deref(), PAGE_SIZE); |
| 95 | Ok(super::layout( |
| 96 | &super::RepoHeader::from_state(&state), |
| 97 | &super::identity_label(&state), |
| 98 | super::Tab::Commits, |
| 99 | "Commits", |
| 100 | html! { |
| 101 | @if rows.is_empty() { |
| 102 | (blankslate()) |
| 103 | } @else { |
| 104 | div.card.history { |
| 105 | @for row in &rows { |
| 106 | (commit_row(row)) |
| 107 | } |
| 108 | } |
| 109 | @if let Some(from) = older { |
| 110 | nav.crumbs { |
| 111 | a href={ "/commits?from=" (from) } { "older" } |
| 112 | } |
| 113 | } |
| 114 | } |
| 115 | }, |
| 116 | )) |
| 117 | } |
| 118 | |
| 119 | /// Up to `limit` rows starting at `from` (or `HEAD` when `from` is |
| 120 | /// `None`), newest first, plus the oid to continue from for an "older" |
| 121 | /// link when more commits remain -- [`list`] passes [`PAGE_SIZE`], |
| 122 | /// [`super::dashboard`]'s History card its own smaller cap. Best-effort: |
| 123 | /// an unopenable repository, an unborn `HEAD`, or an |
| 124 | /// unparsable/unresolvable `from` all degrade to an empty page rather |
| 125 | /// than an error. |
| 126 | pub(crate) fn commit_rows<O>( |
| 127 | state: &AppState<O>, |
| 128 | from: Option<&str>, |
| 129 | limit: usize, |
| 130 | ) -> (Vec<CommitRow>, Option<String>) { |
| 131 | let Ok(repo) = gix::open(&state.path) else { |
| 132 | return (Vec::new(), None); |
| 133 | }; |
| 134 | let continuing = from.and_then(|hex| ObjectId::from_hex(hex.as_bytes()).ok()); |
| 135 | let tip = match continuing { |
| 136 | Some(oid) => oid, |
| 137 | None => { |
| 138 | let Ok(head) = repo.head_id() else { |
| 139 | return (Vec::new(), None); |
| 140 | }; |
| 141 | head.detach() |
| 142 | } |
| 143 | }; |
| 144 | let Ok(walk) = repo |
| 145 | .rev_walk([tip]) |
| 146 | .sorting(gix::revision::walk::Sorting::ByCommitTime( |
| 147 | gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, |
| 148 | )) |
| 149 | .all() |
| 150 | else { |
| 151 | return (Vec::new(), None); |
| 152 | }; |
| 153 | |
| 154 | let skip = if continuing.is_some() { 1 } else { 0 }; |
| 155 | let mut rows: Vec<CommitRow> = Vec::new(); |
| 156 | let mut has_more = false; |
| 157 | for info in walk.skip(skip) { |
| 158 | let Ok(info) = info else { break }; |
| 159 | if rows.len() == limit { |
| 160 | has_more = true; |
| 161 | break; |
| 162 | } |
| 163 | let Ok(commit) = info.object() else { continue }; |
| 164 | let Ok(message) = commit.message() else { |
| 165 | continue; |
| 166 | }; |
| 167 | let Ok(author) = commit.author() else { |
| 168 | continue; |
| 169 | }; |
| 170 | let seconds = author.time().map(|time| time.seconds).unwrap_or(0); |
| 171 | let oid = info.id().detach(); |
| 172 | rows.push(CommitRow { |
| 173 | oid, |
| 174 | short: super::short_oid(&oid), |
| 175 | subject: message.title.to_str_lossy().into_owned(), |
| 176 | author: author.name.to_str_lossy().into_owned(), |
| 177 | ago: super::ago(seconds), |
| 178 | }); |
| 179 | } |
| 180 | let older = has_more |
| 181 | .then(|| rows.last().map(|row| row.oid.to_string())) |
| 182 | .flatten(); |
| 183 | (rows, older) |
| 184 | } |
| 185 | |
| 186 | /// One [`CommitRow`] as a `.card-row` (the design's `CommitRow` component, |
| 187 | /// README's Commits/Commit-detail screens): its short oid as an accent mono |
| 188 | /// link, a `.scope` chip ([`super::split_scope`]/[`super::scope_class`]) |
| 189 | /// when the subject carries a Scoped-Commits prefix, the (possibly |
| 190 | /// stripped) description ellipsized in the remaining space, the author, |
| 191 | /// and its relative age -- the one place a commit row's markup is spelled, |
| 192 | /// so [`list`]'s pager reads the same row [`super::dashboard`]'s History |
| 193 | /// card already renders. |
| 194 | fn commit_row(row: &CommitRow) -> Markup { |
| 195 | html! { |
| 196 | div.card-row { |
| 197 | a href={ "/commit/" (row.oid) } { code { (row.short) } } |
| 198 | @match super::split_scope(&row.subject) { |
| 199 | Some((scope, rest)) => { |
| 200 | span class={ "scope " (super::scope_class(scope)) } { (scope) } |
| 201 | span.desk-subject { (rest) } |
| 202 | }, |
| 203 | None => { span.desk-subject { (row.subject) } }, |
| 204 | } |
| 205 | span.row-author { (row.author) } |
| 206 | span.row-when { (row.ago) } |
| 207 | } |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | /// The empty-history placeholder ([`super::blankslate`]): an unborn |
| 212 | /// `HEAD`, or a repository this page could not open at all. |
| 213 | fn blankslate() -> Markup { |
| 214 | super::blankslate( |
| 215 | "No commits yet", |
| 216 | html! { "This repository has no history to show." }, |
| 217 | ) |
| 218 | } |
| 219 | |
| 220 | /// `GET /commit/{oid}`: a single commit's full message, metadata, and a |
| 221 | /// unified diff against its first parent (or the empty tree, for a root |
| 222 | /// commit). |
| 223 | /// |
| 224 | /// # Errors |
| 225 | /// |
| 226 | /// [`Error::NotFound`] if `oid` is not a well-formed object id or does not |
| 227 | /// name a commit in the served repository. |
| 228 | pub async fn show<O>( |
| 229 | State(state): State<Arc<AppState<O>>>, |
| 230 | axum::Extension(session): axum::Extension<Session>, |
| 231 | Path(oid): Path<String>, |
| 232 | ) -> Result<Markup> |
| 233 | where |
| 234 | O: Find + Write + Send + 'static, |
| 235 | { |
| 236 | let object_id = parse_oid(&oid)?; |
| 237 | let repo = gix::open(&state.path).map_err(|source| Error::Repo(source.to_string()))?; |
| 238 | let commit = repo |
| 239 | .find_commit(object_id) |
| 240 | .ok() |
| 241 | .ok_or_else(|| Error::NotFound { what: oid.clone() })?; |
| 242 | let message = commit |
| 243 | .message() |
| 244 | .map_err(|source| Error::Repo(source.to_string()))?; |
| 245 | let subject = message.title.to_str_lossy().into_owned(); |
| 246 | let body = message |
| 247 | .body |
| 248 | .map(|body| body.to_str_lossy().into_owned()) |
| 249 | .filter(|body| !body.is_empty()); |
| 250 | let author = commit |
| 251 | .author() |
| 252 | .map_err(|source| Error::Repo(source.to_string()))?; |
| 253 | let author_name = author.name.to_str_lossy().into_owned(); |
| 254 | let ago = author.time().map(|time| super::ago(time.seconds)).ok(); |
| 255 | let parents: Vec<ObjectId> = commit.parent_ids().map(|id| id.detach()).collect(); |
| 256 | let new_tree = commit |
| 257 | .tree() |
| 258 | .map_err(|source| Error::Repo(source.to_string()))?; |
| 259 | |
| 260 | let old_tree = match parents.first() { |
| 261 | Some(parent) => Some( |
| 262 | repo.find_commit(*parent) |
| 263 | .map_err(|source| Error::Repo(source.to_string()))? |
| 264 | .tree() |
| 265 | .map_err(|source| Error::Repo(source.to_string()))?, |
| 266 | ), |
| 267 | None => None, |
| 268 | }; |
| 269 | let empty_tree = repo.empty_tree(); |
| 270 | let old_tree_ref = old_tree.as_ref().unwrap_or(&empty_tree); |
| 271 | let (diff, truncated) = diff_sections(&state, &repo, old_tree_ref, &new_tree); |
| 272 | let comments = super::comments::for_commit(&state, object_id); |
| 273 | let checks = checks_section(&state, object_id); |
| 274 | let reviews = reviews_section(&state, &session, object_id, &oid); |
| 275 | let (sidebar_rows, _older) = commit_rows(&state, None, PAGE_SIZE); |
| 276 | let commit_context = format!("commits/{oid}"); |
| 277 | let commit_thread = ents_forge::comment::thread( |
| 278 | state.refs.as_ref(), |
| 279 | &*state.objects(), |
| 280 | &commit_context, |
| 281 | ) |
| 282 | .unwrap_or_default(); |
| 283 | |
| 284 | Ok(super::layout_split( |
| 285 | &super::RepoHeader::from_state(&state), |
| 286 | &super::identity_label(&state), |
| 287 | super::Tab::Commits, |
| 288 | &subject, |
| 289 | false, |
| 290 | commits_sidebar(&sidebar_rows, object_id), |
| 291 | html! { |
| 292 | (super::child_crumbs("commits", "/commits", &super::short_oid(&object_id))) |
| 293 | // The commit card, its reviews, and the conversation are |
| 294 | // single-column reading content, capped at `.readable`'s |
| 295 | // narrow width; only the diff sections between them keep the |
| 296 | // shell's full width (see `ents.css`'s own `.readable` note). |
| 297 | div.readable { |
| 298 | div.card { |
| 299 | div.card-header { "commit " code { (super::short_oid(&object_id)) } } |
| 300 | div.commit { |
| 301 | div.commit-subject { (subject) } |
| 302 | @if let Some(body) = &body { |
| 303 | div.commit-msg.doc-body { |
| 304 | (crate::asciidoc::to_html(body).unwrap_or_else(|_| html! { p { (body) } })) |
| 305 | } |
| 306 | } |
| 307 | div.commit-meta { |
| 308 | (author_name) |
| 309 | @if let Some(ago) = &ago { " \u{b7} " (ago) } |
| 310 | } |
| 311 | div.commit-meta { |
| 312 | "tree " a href={ "/files" } { "browse at HEAD" } |
| 313 | @if !parents.is_empty() { |
| 314 | " \u{b7} parents: " |
| 315 | @for (index, parent) in parents.iter().enumerate() { |
| 316 | @if index > 0 { ", " } |
| 317 | a href={ "/commit/" (parent) } { code { (super::short_oid(parent)) } } |
| 318 | } |
| 319 | } @else { |
| 320 | " \u{b7} root commit" |
| 321 | } |
| 322 | " \u{b7} " |
| 323 | a.composer-trigger data-composer="commit-composer-template" |
| 324 | href={ "/comments?rev=" (object_id) } { "comment on this commit" } |
| 325 | } |
| 326 | (commit_comment_template(&oid, &session)) |
| 327 | } |
| 328 | } |
| 329 | (checks) |
| 330 | (reviews) |
| 331 | } |
| 332 | (diff) |
| 333 | @if truncated { |
| 334 | div.card { div.binary { "Diff truncated (over " (MAX_DIFF_BYTES / (1024 * 1024)) " MiB)." } } |
| 335 | } |
| 336 | @if !comments.is_empty() || !commit_thread.is_empty() { |
| 337 | div.readable { |
| 338 | h2 { "Conversation" } |
| 339 | @for (index, comment) in comments.iter().enumerate() { |
| 340 | (super::comments::comment_card(index, comment, super::comments::LinkMode::CrossFile)) |
| 341 | } |
| 342 | (super::comments::thread_section(&state, &session, &commit_thread, &format!("/commit/{oid}"))) |
| 343 | } |
| 344 | } |
| 345 | }, |
| 346 | )) |
| 347 | } |
| 348 | |
| 349 | /// The Review split's `.tree` sidebar (`crate::pages::layout_split`): the |
| 350 | /// most recent commits, the viewed one active, each row its short oid and |
| 351 | /// subject on one ellipsized line, closed by a link into the full pager. |
| 352 | /// A commit older than the newest [`PAGE_SIZE`] simply highlights nothing |
| 353 | /// -- the sidebar is a recency lane, not a second pager. |
| 354 | fn commits_sidebar(rows: &[CommitRow], current: ObjectId) -> Markup { |
| 355 | html! { |
| 356 | @if rows.is_empty() { |
| 357 | span.tree-note { "No history to show." } |
| 358 | } |
| 359 | @for row in rows { |
| 360 | a.active[row.oid == current] href={ "/commit/" (row.oid) } { |
| 361 | (row.short) " " (row.subject) |
| 362 | } |
| 363 | } |
| 364 | a href="/commits" { "all commits \u{2192}" } |
| 365 | } |
| 366 | } |
| 367 | |
| 368 | /// One row of the commit page's "Checks" card: a recorded result targeting |
| 369 | /// the shown commit. |
| 370 | struct CheckRow { |
| 371 | /// The recording effect's name ([`ents_model::ResultRecord`]'s own |
| 372 | /// `effect` field), the row's `/effects/{name}` link. |
| 373 | effect: String, |
| 374 | /// The run's outcome, one of the closed taxonomy's three values. |
| 375 | status: ents_model::Status, |
| 376 | /// The self-run mirror's `<member>` segment when the result lives |
| 377 | /// there rather than the canonical namespace (`effect.self-run`). |
| 378 | self_run: Option<String>, |
| 379 | /// The result ref tip's author time, for [`super::ago`]. |
| 380 | seconds: Option<i64>, |
| 381 | } |
| 382 | |
| 383 | /// The "Checks" card on `GET /commit/{oid}`: every recorded result |
| 384 | /// (`model.result-identity`) whose stored `target` field names this |
| 385 | /// commit -- the canonical `refs/meta/results/<effect>/<short-oid>` |
| 386 | /// namespace and every member's self-run mirror |
| 387 | /// (`refs/meta/self/<member>/...`), matched on the tree's own `target` |
| 388 | /// field (the same binding the gate verifies), never the refname's |
| 389 | /// short-oid segment. Renders nothing at all when no result targets the |
| 390 | /// commit: a result is only ever written by a run |
| 391 | /// (`effect.result-taxonomy`), so "no checks" is the ordinary state of |
| 392 | /// most commits, not a pending one. Best effort: a result ref whose tree |
| 393 | /// cannot be read back is skipped from this card (it still lists on |
| 394 | /// `git ents effect log`). |
| 395 | // @relation(model.result-identity, model.result-taxonomy, scope=function) |
| 396 | fn checks_section<O: Find + Write>(state: &AppState<O>, commit_id: ObjectId) -> Markup { |
| 397 | let mut rows: Vec<CheckRow> = Vec::new(); |
| 398 | for prefix in ["refs/meta/results/", "refs/meta/self/"] { |
| 399 | let Ok(iter) = state.refs.iter_prefix(prefix) else { |
| 400 | continue; |
| 401 | }; |
| 402 | for entry in iter { |
| 403 | let Ok((name, tip)) = entry else { continue }; |
| 404 | // One `state.objects()` lock per read -- the same |
| 405 | // non-reentrant-`Mutex` care `crate::pages::effects::read_all` |
| 406 | // documents. |
| 407 | let record = { |
| 408 | let objects = state.objects(); |
| 409 | super::commit_tree(&*objects, tip).ok().and_then(|tree| { |
| 410 | facet_git_tree::deserialize::<ents_model::ResultRecord>(&tree, &*objects).ok() |
| 411 | }) |
| 412 | }; |
| 413 | let Some(record) = record else { continue }; |
| 414 | if record.target() != commit_id { |
| 415 | continue; |
| 416 | } |
| 417 | let path = name.as_bstr().to_string(); |
| 418 | let self_run = path |
| 419 | .strip_prefix("refs/meta/self/") |
| 420 | .and_then(|rest| rest.split('/').next()) |
| 421 | .map(str::to_owned); |
| 422 | let seconds = super::commit_authorship(&*state.objects(), tip) |
| 423 | .ok() |
| 424 | .map(|(_author, seconds)| seconds); |
| 425 | rows.push(CheckRow { |
| 426 | effect: record.effect, |
| 427 | status: record.status, |
| 428 | self_run, |
| 429 | seconds, |
| 430 | }); |
| 431 | } |
| 432 | } |
| 433 | if rows.is_empty() { |
| 434 | return html! {}; |
| 435 | } |
| 436 | rows.sort_by(|a, b| (&a.effect, &a.self_run).cmp(&(&b.effect, &b.self_run))); |
| 437 | html! { |
| 438 | div.card { |
| 439 | div.card-header { "Checks" } |
| 440 | @for row in &rows { |
| 441 | div.card-row { |
| 442 | (super::status_chip(row.status)) |
| 443 | " " |
| 444 | a href={ "/effects/" (row.effect) } { (row.effect) } |
| 445 | @if let Some(member) = &row.self_run { |
| 446 | span.muted { " \u{b7} self-run by " (member) } |
| 447 | } |
| 448 | @if let Some(seconds) = row.seconds { |
| 449 | span.entry-size { (super::ago(seconds)) } |
| 450 | } |
| 451 | } |
| 452 | } |
| 453 | } |
| 454 | } |
| 455 | } |
| 456 | |
| 457 | /// Every review targeting `commit_id` (`ents_forge::review::list` filtered |
| 458 | /// to this commit, `model.review`), each rendering its verdict prominently, |
| 459 | /// its body as AsciiDoc, and its reviewer (from the review ref's own tip |
| 460 | /// commit chain, `meta-ref.identity-binding` -- a review stores no author |
| 461 | /// field, only its composite `(target, member)` key), followed by its |
| 462 | /// discussion: the comments naming `reviews/<target>/<member>` as their |
| 463 | /// context (`ents_forge::comment::thread`, `model.comment-context`), |
| 464 | /// rendered through the same shared `super::comments::thread_section` an |
| 465 | /// issue's thread uses. A "start a review" form closes the section |
| 466 | /// (`POST /commit/{oid}/review`). Best effort: a review whose ref cannot be |
| 467 | /// listed degrades to just the start form rather than failing the page. |
| 468 | fn reviews_section<O: Find + Write>( |
| 469 | state: &AppState<O>, |
| 470 | session: &Session, |
| 471 | commit_id: ObjectId, |
| 472 | oid: &str, |
| 473 | ) -> Markup { |
| 474 | let mut reviews = ents_forge::review::list( |
| 475 | state.refs.as_ref(), |
| 476 | &*state.objects(), |
| 477 | &state.path, |
| 478 | Some(&commit_id.to_string()), |
| 479 | ) |
| 480 | .unwrap_or_default(); |
| 481 | // Withdrawn reviews stay in history (`model.review`, append-only) but |
| 482 | // drop out of this section, same as `crate::pages::reviews`'s own list. |
| 483 | reviews.retain(|(_, review)| review.state != ents_forge::review::ReviewState::Withdrawn); |
| 484 | let return_to = format!("/commit/{oid}"); |
| 485 | html! { |
| 486 | h2 { "Reviews" } |
| 487 | @if reviews.is_empty() { |
| 488 | p.muted { "No reviews of this commit yet \u{2014} record a verdict below." } |
| 489 | } |
| 490 | @for ((target, member), review) in &reviews { |
| 491 | div.card { |
| 492 | div.comment-meta { |
| 493 | (super::verdict_chip(review.verdict)) |
| 494 | (super::avatar(member.as_str())) |
| 495 | span.author { (member) } |
| 496 | @let reviewer = ents_model::namespace::review_ref(target, member) |
| 497 | .ok() |
| 498 | .and_then(|ref_name| state.refs.get(ref_name.as_ref()).ok().flatten()) |
| 499 | .and_then(|tip| super::commit_authorship(&*state.objects(), tip).ok()); |
| 500 | @if let Some((_author, seconds)) = &reviewer { |
| 501 | span { (super::ago(*seconds)) } |
| 502 | } |
| 503 | } |
| 504 | div.doc-body { |
| 505 | (crate::asciidoc::to_html(&review.body).unwrap_or_else(|_| html! { p { (review.body) } })) |
| 506 | } |
| 507 | @let thread = ents_forge::comment::thread( |
| 508 | state.refs.as_ref(), |
| 509 | &*state.objects(), |
| 510 | &format!("reviews/{target}/{member}"), |
| 511 | ).unwrap_or_default(); |
| 512 | (super::comments::thread_section(state, session, &thread, &return_to)) |
| 513 | (review_comment_form(session, target, member, &return_to)) |
| 514 | } |
| 515 | } |
| 516 | (start_review_form(session, oid)) |
| 517 | } |
| 518 | } |
| 519 | |
| 520 | /// The comment-on-this-review form (`POST /reviews/{target}/{member}/comment`): |
| 521 | /// a contextual comment naming `reviews/<target>/<member>` |
| 522 | /// (`model.comment-context`), so a review's discussion can start from the |
| 523 | /// web and not only the CLI or lens. Shared with |
| 524 | /// [`super::reviews::show`]'s own review detail page -- the same composer, |
| 525 | /// not a second one, whether the review's thread renders on its home |
| 526 | /// commit's page or on its own. |
| 527 | pub(crate) fn review_comment_form( |
| 528 | session: &Session, |
| 529 | target: &str, |
| 530 | member: &ents_model::MemberId, |
| 531 | return_to: &str, |
| 532 | ) -> Markup { |
| 533 | html! { |
| 534 | form method="post" action=(format!("/reviews/{target}/{member}/comment")) { |
| 535 | (super::csrf_input(session)) |
| 536 | input type="hidden" name="return_to" value=(return_to); |
| 537 | label { "Comment on this review" textarea name="body" {} } |
| 538 | button type="submit" { "Comment" } |
| 539 | } |
| 540 | } |
| 541 | } |
| 542 | |
| 543 | /// The form fields `POST /reviews/{target}/{member}/comment` accepts. |
| 544 | #[derive(Debug, Deserialize)] |
| 545 | pub struct ReviewCommentForm { |
| 546 | /// The comment's body text. |
| 547 | body: String, |
| 548 | /// The per-session CSRF token (`roots.web-session`). |
| 549 | csrf: String, |
| 550 | /// Where to send the browser back to -- the commit page rendering the |
| 551 | /// review; honored only when it is a same-origin path. |
| 552 | #[serde(default)] |
| 553 | return_to: String, |
| 554 | } |
| 555 | |
| 556 | /// `POST /reviews/{target}/{member}/comment`: a comment naming |
| 557 | /// `reviews/<target>/<member>` as its context (`model.comment-context`) -- |
| 558 | /// an ordinary [`ents_forge::comment::add`], contextual and unanchored, |
| 559 | /// joining the review's discussion thread the moment it lands. |
| 560 | /// |
| 561 | /// # Errors |
| 562 | /// |
| 563 | /// [`Error::BadCsrf`] if `form.csrf` does not match; otherwise propagates |
| 564 | /// [`ents_forge::comment::add`]'s own failures. |
| 565 | // @relation(model.comment-context, roots.web-signing, roots.web-session, scope=function) |
| 566 | pub async fn review_comment<O>( |
| 567 | State(state): State<Arc<AppState<O>>>, |
| 568 | axum::Extension(session): axum::Extension<Session>, |
| 569 | Path((target, member)): Path<(String, String)>, |
| 570 | Form(form): Form<ReviewCommentForm>, |
| 571 | ) -> Result<impl IntoResponse> |
| 572 | where |
| 573 | O: Find + Write + Send + 'static, |
| 574 | { |
| 575 | super::require_csrf(&session, &form.csrf)?; |
| 576 | let identity = state.identity.as_ref(); |
| 577 | let new = ents_forge::comment::NewComment { |
| 578 | body: form.body, |
| 579 | path: None, |
| 580 | lines: None, |
| 581 | rev: "HEAD".to_owned(), |
| 582 | worktree: false, |
| 583 | context: Some(format!("reviews/{target}/{member}")), |
| 584 | parent: None, |
| 585 | }; |
| 586 | let (_comment_id, outcome) = ents_forge::comment::add( |
| 587 | state.refs.as_ref(), |
| 588 | &*state.objects(), |
| 589 | state.events.as_ref(), |
| 590 | &state.path, |
| 591 | new, |
| 592 | &crate::receive_identity!(identity, crate::pages::member_author(&session)), |
| 593 | state.mode, |
| 594 | )?; |
| 595 | crate::error::outcome_to_result(outcome)?; |
| 596 | let target = if form.return_to.starts_with('/') { |
| 597 | form.return_to |
| 598 | } else { |
| 599 | "/commits".to_owned() |
| 600 | }; |
| 601 | Ok(Redirect::to(&target)) |
| 602 | } |
| 603 | |
| 604 | /// The commit-level comment composer's own hidden `<template>` |
| 605 | /// (`crate::pages::files::composer_template`'s counterpart for a commit, |
| 606 | /// which has no file of its own to anchor a path-based comment to): posts |
| 607 | /// to [`comment`], naming `commits/<oid>` as its context |
| 608 | /// (`model.comment-context`) rather than anchoring to a path, exactly the |
| 609 | /// way [`review_comment_form`] names `reviews/<target>/<member>`. Cloned |
| 610 | /// by `assets/ents.js`'s standalone-composer trigger, opened from the |
| 611 | /// "comment on this commit" link beside the parents list; with JS |
| 612 | /// disabled that link remains a real navigation to `/comments?rev=` |
| 613 | /// instead (a page-less no-JS fallback for this specific context would be |
| 614 | /// its own added surface, so it stays the plain path-anchored form for |
| 615 | /// now). |
| 616 | fn commit_comment_template(oid: &str, session: &Session) -> Markup { |
| 617 | html! { |
| 618 | template id="commit-composer-template" { |
| 619 | form.composer-form method="post" action=(format!("/commit/{oid}/comment")) { |
| 620 | (super::csrf_input(session)) |
| 621 | input type="hidden" name="return_to" value=(format!("/commit/{oid}")); |
| 622 | textarea name="body" placeholder="Leave a comment on this commit" {} |
| 623 | div.composer-buttons { |
| 624 | button type="submit" { "Comment" } |
| 625 | button.composer-cancel type="button" { "Cancel" } |
| 626 | } |
| 627 | } |
| 628 | } |
| 629 | } |
| 630 | } |
| 631 | |
| 632 | /// The form fields `POST /commit/{oid}/comment` accepts. |
| 633 | #[derive(Debug, Deserialize)] |
| 634 | pub struct CommitCommentForm { |
| 635 | /// The comment's body text. |
| 636 | body: String, |
| 637 | /// The per-session CSRF token (`roots.web-session`). |
| 638 | csrf: String, |
| 639 | /// Where to send the browser back to; honored only when it is a |
| 640 | /// same-origin path. |
| 641 | #[serde(default)] |
| 642 | return_to: String, |
| 643 | } |
| 644 | |
| 645 | /// `POST /commit/{oid}/comment`: a comment naming `commits/<oid>` as its |
| 646 | /// context (`model.comment-context`) -- unanchored, exactly like |
| 647 | /// [`review_comment`], since a comment about the commit as a whole has no |
| 648 | /// path to anchor to the way a file-anchored comment does. |
| 649 | /// |
| 650 | /// # Errors |
| 651 | /// |
| 652 | /// [`Error::BadCsrf`] if `form.csrf` does not match; otherwise propagates |
| 653 | /// [`ents_forge::comment::add`]'s own failures. |
| 654 | // @relation(model.comment-context, roots.web-signing, roots.web-session, scope=function) |
| 655 | pub async fn comment<O>( |
| 656 | State(state): State<Arc<AppState<O>>>, |
| 657 | axum::Extension(session): axum::Extension<Session>, |
| 658 | Path(oid): Path<String>, |
| 659 | Form(form): Form<CommitCommentForm>, |
| 660 | ) -> Result<impl IntoResponse> |
| 661 | where |
| 662 | O: Find + Write + Send + 'static, |
| 663 | { |
| 664 | super::require_csrf(&session, &form.csrf)?; |
| 665 | let identity = state.identity.as_ref(); |
| 666 | let new = ents_forge::comment::NewComment { |
| 667 | body: form.body, |
| 668 | path: None, |
| 669 | lines: None, |
| 670 | rev: "HEAD".to_owned(), |
| 671 | worktree: false, |
| 672 | context: Some(format!("commits/{oid}")), |
| 673 | parent: None, |
| 674 | }; |
| 675 | let (_comment_id, outcome) = ents_forge::comment::add( |
| 676 | state.refs.as_ref(), |
| 677 | &*state.objects(), |
| 678 | state.events.as_ref(), |
| 679 | &state.path, |
| 680 | new, |
| 681 | &crate::receive_identity!(identity, crate::pages::member_author(&session)), |
| 682 | state.mode, |
| 683 | )?; |
| 684 | crate::error::outcome_to_result(outcome)?; |
| 685 | let target = if form.return_to.starts_with('/') { |
| 686 | form.return_to |
| 687 | } else { |
| 688 | format!("/commit/{oid}") |
| 689 | }; |
| 690 | Ok(Redirect::to(&target)) |
| 691 | } |
| 692 | |
| 693 | /// The start-a-review form (`POST /commit/{oid}/review`), its fields |
| 694 | /// [`ents_forge::review::ReviewAction::New`]'s own |
| 695 | /// ([`crate::form::action_form`]): `target` is the page's commit, so no |
| 696 | /// control renders for it, and the verdict is a closed `.picker` |
| 697 | /// (README's `VerdictPicker`) of radio inputs over |
| 698 | /// [`ents_forge::review::Verdict`]'s three variants -- `model.review` |
| 699 | /// makes it a hard enum, unlike issue and comment states -- defaulting to |
| 700 | /// `approve`, the same default a bare `select`'s first option would |
| 701 | /// submit. The body stays the derived compose-field textarea. |
| 702 | fn start_review_form(session: &Session, oid: &str) -> Markup { |
| 703 | html! { |
| 704 | h3 { "Start a review" } |
| 705 | (crate::form::action_form::<ents_forge::review::ReviewAction>( |
| 706 | "New", |
| 707 | session, |
| 708 | &crate::form::Spec { |
| 709 | action: &format!("/commit/{oid}/review"), |
| 710 | submit: "Start a Review", |
| 711 | cancel: None, |
| 712 | values: &[], |
| 713 | overrides: &[ |
| 714 | ("target", html! {}), |
| 715 | ("verdict", html! { |
| 716 | p.muted { "verdict" } |
| 717 | div.picker { |
| 718 | label.opt { |
| 719 | input type="radio" name="verdict" value="approve" checked; |
| 720 | span.dot {} |
| 721 | "approve" |
| 722 | } |
| 723 | label.opt { |
| 724 | input type="radio" name="verdict" value="request-changes"; |
| 725 | span.dot {} |
| 726 | "request-changes" |
| 727 | } |
| 728 | label.opt { |
| 729 | input type="radio" name="verdict" value="comment"; |
| 730 | span.dot {} |
| 731 | "comment" |
| 732 | } |
| 733 | } |
| 734 | }), |
| 735 | ], |
| 736 | }, |
| 737 | )) |
| 738 | } |
| 739 | } |
| 740 | |
| 741 | /// `POST /commit/{oid}/review`: review the commit at `oid` |
| 742 | /// (`ents_forge::review::new`), which writes both the review's entity ref |
| 743 | /// and its retention pin (`model.review`, `model.review-pin`) -- the web is |
| 744 | /// another caller of that one library func, never a second review or |
| 745 | /// pin-writing path. Signed (`roots.web-signing`) on behalf of the current |
| 746 | /// session (`roots.web-session`). The posted fields are |
| 747 | /// [`ents_forge::review::ReviewAction::New`]'s own |
| 748 | /// ([`crate::form::parse_action`]), the path's `oid` standing in for the |
| 749 | /// CLI's `--target`. |
| 750 | /// |
| 751 | /// # Errors |
| 752 | /// |
| 753 | /// [`Error::BadCsrf`] if the posted token does not match; otherwise |
| 754 | /// propagates [`ents_forge::review::new`]'s own failures (including an |
| 755 | /// unresolvable target commit). |
| 756 | // @relation(model.review, model.review-pin, roots.web-signing, roots.web-session, scope=function) |
| 757 | pub async fn review<O>( |
| 758 | State(state): State<Arc<AppState<O>>>, |
| 759 | axum::Extension(session): axum::Extension<Session>, |
| 760 | Path(oid): Path<String>, |
| 761 | Form(mut pairs): Form<Vec<(String, String)>>, |
| 762 | ) -> Result<impl IntoResponse> |
| 763 | where |
| 764 | O: Find + Write + Send + 'static, |
| 765 | { |
| 766 | super::require_csrf(&session, crate::form::posted_csrf(&pairs))?; |
| 767 | pairs.retain(|(name, _)| name != "target"); |
| 768 | pairs.push(("target".to_owned(), oid.clone())); |
| 769 | let ents_forge::review::ReviewAction::New { |
| 770 | target, |
| 771 | verdict, |
| 772 | body, |
| 773 | key: _, |
| 774 | } = crate::form::parse_action("New", &pairs)? |
| 775 | else { |
| 776 | return Err(Error::InvalidArgument( |
| 777 | "not a form-backed review action".to_owned(), |
| 778 | )); |
| 779 | }; |
| 780 | let member = super::reviewer_member_id(&state); |
| 781 | let identity = state.identity.as_ref(); |
| 782 | let new = ents_forge::review::NewReview { |
| 783 | target, |
| 784 | verdict: verdict |
| 785 | .parse() |
| 786 | .map_err(|_unknown| Error::InvalidArgument(format!("unknown verdict: {verdict}")))?, |
| 787 | body: body.unwrap_or_default(), |
| 788 | }; |
| 789 | let (_target, outcome) = ents_forge::review::new( |
| 790 | state.refs.as_ref(), |
| 791 | &*state.objects(), |
| 792 | state.events.as_ref(), |
| 793 | &state.path, |
| 794 | new, |
| 795 | &member, |
| 796 | &crate::receive_identity!(identity, crate::pages::member_author(&session)), |
| 797 | state.mode, |
| 798 | )?; |
| 799 | crate::error::outcome_to_result(outcome)?; |
| 800 | Ok(Redirect::to(&format!("/commit/{oid}"))) |
| 801 | } |
| 802 | |
| 803 | /// Validate `text` as a full, well-formed object id -- hex characters only, |
| 804 | /// at the exact length the served repository's hash kind expects (this |
| 805 | /// page does not resolve abbreviated prefixes; [`super::commits::list`]'s |
| 806 | /// own links always carry the full id). |
| 807 | /// |
| 808 | /// # Errors |
| 809 | /// |
| 810 | /// [`Error::NotFound`] if `text` is empty, not hex, or the wrong length. |
| 811 | fn parse_oid(text: &str) -> Result<ObjectId> { |
| 812 | if text.is_empty() || text.len() > 64 || !text.bytes().all(|b| b.is_ascii_hexdigit()) { |
| 813 | return Err(Error::NotFound { |
| 814 | what: text.to_owned(), |
| 815 | }); |
| 816 | } |
| 817 | ObjectId::from_hex(text.as_bytes()) |
| 818 | .ok() |
| 819 | .ok_or_else(|| Error::NotFound { |
| 820 | what: text.to_owned(), |
| 821 | }) |
| 822 | } |
| 823 | |
| 824 | /// One `.diff` section per changed file between `old_tree` and `new_tree`, |
| 825 | /// plus whether the total rendered bytes exceeded [`MAX_DIFF_BYTES`] (in |
| 826 | /// which case the caller shows a truncation notice). Best-effort: a change |
| 827 | /// this function cannot read renders as a bare file header with no hunks |
| 828 | /// rather than failing the whole page. |
| 829 | fn diff_sections<O>( |
| 830 | state: &AppState<O>, |
| 831 | repo: &gix::Repository, |
| 832 | old_tree: &gix::Tree<'_>, |
| 833 | new_tree: &gix::Tree<'_>, |
| 834 | ) -> (Markup, bool) { |
| 835 | let Ok(mut platform) = old_tree.changes() else { |
| 836 | return (html! {}, false); |
| 837 | }; |
| 838 | let mut sections = Vec::new(); |
| 839 | let mut total: usize = 0; |
| 840 | let mut truncated = false; |
| 841 | let _outcome = platform.for_each_to_obtain_tree(new_tree, |change| { |
| 842 | if truncated { |
| 843 | return Ok::<_, std::convert::Infallible>(std::ops::ControlFlow::Break(())); |
| 844 | } |
| 845 | // The walk yields every intermediate directory as its own tree |
| 846 | // change; only blob (and link) entries are files a reader can |
| 847 | // diff, so a tree entry renders nothing rather than a bare |
| 848 | // header per subdirectory. |
| 849 | if is_tree_change(&change) { |
| 850 | return Ok(std::ops::ControlFlow::Continue(())); |
| 851 | } |
| 852 | let (section, bytes) = render_change(state, repo, &change); |
| 853 | total = total.saturating_add(bytes); |
| 854 | sections.push(section); |
| 855 | if total > MAX_DIFF_BYTES { |
| 856 | truncated = true; |
| 857 | } |
| 858 | Ok(std::ops::ControlFlow::Continue(())) |
| 859 | }); |
| 860 | (html! { @for section in §ions { (section) } }, truncated) |
| 861 | } |
| 862 | |
| 863 | /// Whether `change` is a tree (directory) entry rather than a blob or |
| 864 | /// link -- [`diff_sections`] skips these, since the walk names every |
| 865 | /// intermediate directory on the way to a changed file. |
| 866 | fn is_tree_change(change: &Change<'_, '_, '_>) -> bool { |
| 867 | match *change { |
| 868 | Change::Addition { entry_mode, .. } |
| 869 | | Change::Deletion { entry_mode, .. } |
| 870 | | Change::Modification { entry_mode, .. } |
| 871 | | Change::Rewrite { entry_mode, .. } => entry_mode.is_tree(), |
| 872 | } |
| 873 | } |
| 874 | |
| 875 | /// One changed file's `.diff` section: a `.file`-classed header naming the |
| 876 | /// path (and, on a rename, the old path it moved from) beside its own |
| 877 | /// [`super::editor_open`] pill -- the web↔editor handoff motif beside every |
| 878 | /// code location (README's `EditorPill` inventory entry names diff headers |
| 879 | /// explicitly) -- followed by either a `.meta`-classed "binary file |
| 880 | /// changed" notice or the colorized unified diff between its old and new |
| 881 | /// blob content. Returns the section's rendered byte cost, so |
| 882 | /// [`diff_sections`] can track the page's overall budget. |
| 883 | fn render_change<O>( |
| 884 | state: &AppState<O>, |
| 885 | repo: &gix::Repository, |
| 886 | change: &Change<'_, '_, '_>, |
| 887 | ) -> (Markup, usize) { |
| 888 | let (old_id, new_id, path, rename_from) = match *change { |
| 889 | Change::Addition { location, id, .. } => ( |
| 890 | None, |
| 891 | Some(id.detach()), |
| 892 | location.to_str_lossy().into_owned(), |
| 893 | None, |
| 894 | ), |
| 895 | Change::Deletion { location, id, .. } => ( |
| 896 | Some(id.detach()), |
| 897 | None, |
| 898 | location.to_str_lossy().into_owned(), |
| 899 | None, |
| 900 | ), |
| 901 | Change::Modification { |
| 902 | location, |
| 903 | previous_id, |
| 904 | id, |
| 905 | .. |
| 906 | } => ( |
| 907 | Some(previous_id.detach()), |
| 908 | Some(id.detach()), |
| 909 | location.to_str_lossy().into_owned(), |
| 910 | None, |
| 911 | ), |
| 912 | Change::Rewrite { |
| 913 | location, |
| 914 | source_location, |
| 915 | source_id, |
| 916 | id, |
| 917 | .. |
| 918 | } => ( |
| 919 | Some(source_id.detach()), |
| 920 | Some(id.detach()), |
| 921 | location.to_str_lossy().into_owned(), |
| 922 | Some(source_location.to_str_lossy().into_owned()), |
| 923 | ), |
| 924 | }; |
| 925 | |
| 926 | let old_bytes = old_id.and_then(|id| blob_bytes(repo, id)); |
| 927 | let new_bytes = new_id.and_then(|id| blob_bytes(repo, id)); |
| 928 | let cost = old_bytes |
| 929 | .as_ref() |
| 930 | .map_or(0, Vec::len) |
| 931 | .saturating_add(new_bytes.as_ref().map_or(0, Vec::len)); |
| 932 | let binary = |
| 933 | old_bytes.as_deref().is_some_and(is_binary) || new_bytes.as_deref().is_some_and(is_binary); |
| 934 | |
| 935 | let header = html! { |
| 936 | span.ln.file { |
| 937 | @if let Some(from) = &rename_from { (from) " \u{2192} " } |
| 938 | (path) |
| 939 | " " |
| 940 | (super::editor_open(state, &path, None)) |
| 941 | "\n" |
| 942 | } |
| 943 | }; |
| 944 | let body = if binary { |
| 945 | html! { span.ln.meta { "Binary file changed.\n" } } |
| 946 | } else { |
| 947 | let old_text = old_bytes.as_deref().map_or_else(String::new, |bytes| { |
| 948 | String::from_utf8_lossy(bytes).into_owned() |
| 949 | }); |
| 950 | let new_text = new_bytes.as_deref().map_or_else(String::new, |bytes| { |
| 951 | String::from_utf8_lossy(bytes).into_owned() |
| 952 | }); |
| 953 | unified_diff(&old_text, &new_text) |
| 954 | }; |
| 955 | (html! { div.diff { (header) (body) } }, cost) |
| 956 | } |
| 957 | |
| 958 | /// `id`'s blob content, or `None` when it cannot be read as a blob (a |
| 959 | /// submodule/gitlink entry, or a read failure) -- best-effort, mirroring |
| 960 | /// [`diff_sections`]'s own degrade-don't-fail stance. |
| 961 | fn blob_bytes(repo: &gix::Repository, id: ObjectId) -> Option<Vec<u8>> { |
| 962 | Some( |
| 963 | repo.find_object(id) |
| 964 | .ok()? |
| 965 | .try_into_blob() |
| 966 | .ok()? |
| 967 | .data |
| 968 | .clone(), |
| 969 | ) |
| 970 | } |
| 971 | |
| 972 | /// Whether `bytes` looks like binary content (a NUL byte in the leading |
| 973 | /// chunk, the same heuristic [`super::files::is_binary`] and pre-redo's own |
| 974 | /// `is_binary` use). |
| 975 | fn is_binary(bytes: &[u8]) -> bool { |
| 976 | bytes.iter().take(8000).any(|b| *b == 0) |
| 977 | } |
| 978 | |
| 979 | /// `old_text` and `new_text` rendered as a colorized unified diff: each |
| 980 | /// hunk built via [`gix::diff::blob::InternedInput`] and |
| 981 | /// [`diff_with_slider_heuristics`], then rendered to the textual unified |
| 982 | /// diff format through [`ConsumeBinaryHunk`] and colorized line by line via |
| 983 | /// [`diff_class`] -- mirrors `pre-redo:.../pages.rs`'s own `diff_view`, |
| 984 | /// its `git diff`-shelled-out patch text replaced with this page's own |
| 985 | /// `gix`-computed one. |
| 986 | fn unified_diff(old_text: &str, new_text: &str) -> Markup { |
| 987 | if old_text == new_text { |
| 988 | return html! {}; |
| 989 | } |
| 990 | let input = InternedInput::new(old_text, new_text); |
| 991 | let diff = diff_with_slider_heuristics(Algorithm::Histogram, &input); |
| 992 | let Ok(patch) = UnifiedDiff::new( |
| 993 | &diff, |
| 994 | &input, |
| 995 | ConsumeBinaryHunk::new(String::new(), "\n"), |
| 996 | ContextSize::symmetrical(3), |
| 997 | ) |
| 998 | .consume() else { |
| 999 | return html! {}; |
| 1000 | }; |
| 1001 | html! { |
| 1002 | @for line in patch.lines() { |
| 1003 | span class={ "ln " (diff_class(line)) } { (line) "\n" } |
| 1004 | } |
| 1005 | } |
| 1006 | } |
| 1007 | |
| 1008 | /// The CSS class for a unified-diff line, chosen from its leading marker |
| 1009 | /// (mirrors `pre-redo:.../pages.rs`'s own `diff_class`). |
| 1010 | fn diff_class(line: &str) -> &'static str { |
| 1011 | if line.starts_with("@@") { |
| 1012 | "hunk" |
| 1013 | } else if line.starts_with('+') { |
| 1014 | "add" |
| 1015 | } else if line.starts_with('-') { |
| 1016 | "del" |
| 1017 | } else { |
| 1018 | "ctx" |
| 1019 | } |
| 1020 | } |
| 1021 | |
| 1022 | #[cfg(test)] |
| 1023 | mod tests { |
| 1024 | #![allow(clippy::expect_used, reason = "unit test")] |
| 1025 | |
| 1026 | use rstest::rstest; |
| 1027 | |
| 1028 | use super::*; |
| 1029 | |
| 1030 | #[rstest] |
| 1031 | #[case::empty("", false)] |
| 1032 | #[case::not_hex("zzzzzzz", false)] |
| 1033 | #[case::too_long( |
| 1034 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", |
| 1035 | false |
| 1036 | )] |
| 1037 | #[case::sha1("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true)] |
| 1038 | fn parse_oid_accepts_only_well_formed_hex_ids(#[case] text: &str, #[case] valid: bool) { |
| 1039 | assert_eq!(parse_oid(text).is_ok(), valid); |
| 1040 | } |
| 1041 | |
| 1042 | #[test] |
| 1043 | fn diff_class_colors_hunk_add_del_and_context_lines() { |
| 1044 | assert_eq!(diff_class("@@ -1,2 +1,2 @@"), "hunk"); |
| 1045 | assert_eq!(diff_class("+added"), "add"); |
| 1046 | assert_eq!(diff_class("-removed"), "del"); |
| 1047 | assert_eq!(diff_class(" context"), "ctx"); |
| 1048 | } |
| 1049 | |
| 1050 | #[test] |
| 1051 | fn unified_diff_renders_colored_added_and_removed_lines() { |
| 1052 | let rendered = unified_diff("a\nb\n", "a\nc\n").into_string(); |
| 1053 | assert!(rendered.contains("class=\"ln del\"")); |
| 1054 | assert!(rendered.contains("class=\"ln add\"")); |
| 1055 | } |
| 1056 | |
| 1057 | #[test] |
| 1058 | fn unified_diff_of_identical_text_renders_nothing() { |
| 1059 | let rendered = unified_diff("same\n", "same\n").into_string(); |
| 1060 | assert!(rendered.is_empty()); |
| 1061 | } |
| 1062 | } |