602505d2
Fix four web UI defects a browser found and the tests could not
a73x 2026-08-12 17:25
Commit message
src/patch.rs
| Old | New | ||
|---|---|---|---|
| @@ -217,13 +217,21 @@ pub fn list_to_writer( | |||
| 217 | }; | 217 | }; |
| 218 | // `merged?` — not `merged` — for a patch that only looks merged. | 218 | // `merged?` — not `merged` — for a patch that only looks merged. |
| 219 | // Reachability is a hint here, never the recorded status. | 219 | // Reachability is a hint here, never the recorded status. |
| 220 | // | ||
| 221 | // `→ base_ref` is what the web list shows in the column that used to | ||
| 222 | // hold `branch`, and the two surfaces have to agree about what a patch | ||
| 223 | // list says — the argument `10bb2d84` made about ids. It is the target | ||
| 224 | // rather than the originating branch because the originating branch is | ||
| 225 | // provenance only since `659f0350`, and for worktree-created patches it | ||
| 226 | // has usually been deleted. `patch show` still prints it. See 850f3b8e. | ||
| 220 | writeln!( | 227 | writeln!( |
| 221 | writer, | 228 | writer, |
| 222 | "{} {:7} {}{} (by {}){}{}", | 229 | "{} {:7} {}{} → {} (by {}){}{}", |
| 223 | abbrev.of(&p.id), | 230 | abbrev.of(&p.id), |
| 224 | p.status_display(repo), | 231 | p.status_display(repo), |
| 225 | p.title, | 232 | p.title, |
| 226 | labels, | 233 | labels, |
| 234 | p.base_ref, | ||
| 227 | p.author.name, | 235 | p.author.name, |
| 228 | stale, | 236 | stale, |
| 229 | unread | 237 | unread |
src/server/http/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,7 @@ | |||
| 1 | pub mod git_http; | 1 | pub mod git_http; |
| 2 | pub mod repo; | 2 | pub mod repo; |
| 3 | pub mod repo_list; | 3 | pub mod repo_list; |
| 4 | pub mod timestamp; | ||
| 4 | 5 | ||
| 5 | use axum::extract::{DefaultBodyLimit, Request, State}; | 6 | use axum::extract::{DefaultBodyLimit, Request, State}; |
| 6 | use axum::http::uri::{PathAndQuery, Uri}; | 7 | use axum::http::uri::{PathAndQuery, Uri}; |
src/server/http/repo/issues.rs
| Old | New | ||
|---|---|---|---|
| @@ -4,6 +4,7 @@ use axum::extract::{Path, Query, State}; | |||
| 4 | use axum::response::{IntoResponse, Response}; | 4 | use axum::response::{IntoResponse, Response}; |
| 5 | 5 | ||
| 6 | use super::{collab_counts, internal_error, not_found, open_repo, AppState, CommentView}; | 6 | use super::{collab_counts, internal_error, not_found, open_repo, AppState, CommentView}; |
| 7 | use crate::http::timestamp::Timestamp; | ||
| 7 | 8 | ||
| 8 | /// Which issues to include in the list view. Defaults to `Open` so the list | 9 | /// Which issues to include in the list view. Defaults to `Open` so the list |
| 9 | /// agrees with the nav badge, which only ever counts open issues. Closed and | 10 | /// agrees with the nav badge, which only ever counts open issues. Closed and |
| @@ -46,7 +47,7 @@ pub struct IssueListItem { | |||
| 46 | pub title: String, | 47 | pub title: String, |
| 47 | pub author: String, | 48 | pub author: String, |
| 48 | pub labels: String, | 49 | pub labels: String, |
| 49 | pub updated: String, | 50 | pub updated: Timestamp, |
| 50 | } | 51 | } |
| 51 | 52 | ||
| 52 | #[derive(Debug)] | 53 | #[derive(Debug)] |
| @@ -71,6 +72,9 @@ pub struct IssuesTemplate { | |||
| 71 | pub open_issues: usize, | 72 | pub open_issues: usize, |
| 72 | pub filter: &'static str, | 73 | pub filter: &'static str, |
| 73 | pub issues: Vec<IssueListItem>, | 74 | pub issues: Vec<IssueListItem>, |
| 75 | /// How many issues the repository holds regardless of filter, used only to | ||
| 76 | /// explain an empty list. See `patches.rs` and issue `c43c459d`. | ||
| 77 | pub total: usize, | ||
| 74 | } | 78 | } |
| 75 | 79 | ||
| 76 | #[derive(askama::Template, askama_web::WebTemplate)] | 80 | #[derive(askama::Template, askama_web::WebTemplate)] |
| @@ -124,7 +128,7 @@ pub async fn issues( | |||
| 124 | // `Issue:` trailer, a branch name, a script. See `abbrev`. | 128 | // `Issue:` trailer, a branch name, a script. See `abbrev`. |
| 125 | let abbrev = git_collab::abbrev::for_issues(&repo); | 129 | let abbrev = git_collab::abbrev::for_issues(&repo); |
| 126 | 130 | ||
| 127 | let issues = filtered_issues | 131 | let issues: Vec<IssueListItem> = filtered_issues |
| 128 | .into_iter() | 132 | .into_iter() |
| 129 | .map(|i| { | 133 | .map(|i| { |
| 130 | let id = i.id.clone(); | 134 | let id = i.id.clone(); |
| @@ -136,11 +140,21 @@ pub async fn issues( | |||
| 136 | title: i.title, | 140 | title: i.title, |
| 137 | author: i.author.name, | 141 | author: i.author.name, |
| 138 | labels: i.labels.join(", "), | 142 | labels: i.labels.join(", "), |
| 139 | updated: i.last_updated, | 143 | updated: Timestamp::new(i.last_updated), |
| 140 | } | 144 | } |
| 141 | }) | 145 | }) |
| 142 | .collect(); | 146 | .collect(); |
| 143 | 147 | ||
| 148 | // Only looked up when the list is empty; see the matching comment in | ||
| 149 | // `patches.rs`. | ||
| 150 | let total = if issues.is_empty() { | ||
| 151 | git_collab::state::list_issues_with_archived(&repo) | ||
| 152 | .unwrap_or_default() | ||
| 153 | .len() | ||
| 154 | } else { | ||
| 155 | issues.len() | ||
| 156 | }; | ||
| 157 | |||
| 144 | IssuesTemplate { | 158 | IssuesTemplate { |
| 145 | site_title: state.site_title.clone(), | 159 | site_title: state.site_title.clone(), |
| 146 | repo_name, | 160 | repo_name, |
| @@ -149,6 +163,7 @@ pub async fn issues( | |||
| 149 | open_issues, | 163 | open_issues, |
| 150 | filter: filter.as_str(), | 164 | filter: filter.as_str(), |
| 151 | issues, | 165 | issues, |
| 166 | total, | ||
| 152 | } | 167 | } |
| 153 | .into_response() | 168 | .into_response() |
| 154 | } | 169 | } |
src/server/http/repo/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -19,6 +19,7 @@ use axum::http::StatusCode; | |||
| 19 | use axum::response::{IntoResponse, Response}; | 19 | use axum::response::{IntoResponse, Response}; |
| 20 | use chrono::{TimeZone, Utc}; | 20 | use chrono::{TimeZone, Utc}; |
| 21 | 21 | ||
| 22 | use super::timestamp::Timestamp; | ||
| 22 | use super::AppState; | 23 | use super::AppState; |
| 23 | 24 | ||
| 24 | #[derive(askama::Template, askama_web::WebTemplate)] | 25 | #[derive(askama::Template, askama_web::WebTemplate)] |
| @@ -130,7 +131,7 @@ pub struct CommentView { | |||
| 130 | /// deleted text is dropped in the fold and never reaches this far, so | 131 | /// deleted text is dropped in the fold and never reaches this far, so |
| 131 | /// there is nothing here for a template bug to leak. | 132 | /// there is nothing here for a template bug to leak. |
| 132 | pub body: String, | 133 | pub body: String, |
| 133 | pub timestamp: String, | 134 | pub timestamp: Timestamp, |
| 134 | pub edited: bool, | 135 | pub edited: bool, |
| 135 | pub deleted: bool, | 136 | pub deleted: bool, |
| 136 | } | 137 | } |
| @@ -140,7 +141,7 @@ impl CommentView { | |||
| 140 | CommentView { | 141 | CommentView { |
| 141 | author: c.author.name, | 142 | author: c.author.name, |
| 142 | body: c.body, | 143 | body: c.body, |
| 143 | timestamp: c.timestamp, | 144 | timestamp: Timestamp::new(c.timestamp), |
| 144 | edited: c.edited, | 145 | edited: c.edited, |
| 145 | deleted: c.deleted, | 146 | deleted: c.deleted, |
| 146 | } | 147 | } |
src/server/http/repo/patches.rs
| Old | New | ||
|---|---|---|---|
| @@ -4,6 +4,7 @@ use axum::extract::{Path, Query, State}; | |||
| 4 | use axum::response::{IntoResponse, Response}; | 4 | use axum::response::{IntoResponse, Response}; |
| 5 | 5 | ||
| 6 | use super::{collab_counts, internal_error, not_found, open_repo, AppState, CommentView}; | 6 | use super::{collab_counts, internal_error, not_found, open_repo, AppState, CommentView}; |
| 7 | use crate::http::timestamp::Timestamp; | ||
| 7 | 8 | ||
| 8 | /// Which patches to include in the list view. Defaults to `Open` so the list | 9 | /// Which patches to include in the list view. Defaults to `Open` so the list |
| 9 | /// agrees with the nav badge, which only ever counts open patches. Merged | 10 | /// agrees with the nav badge, which only ever counts open patches. Merged |
| @@ -51,8 +52,16 @@ pub struct PatchListItem { | |||
| 51 | pub title: String, | 52 | pub title: String, |
| 52 | pub author: String, | 53 | pub author: String, |
| 53 | pub labels: String, | 54 | pub labels: String, |
| 54 | pub branch: String, | 55 | /// What the patch targets. This column used to carry `PatchState.branch`, |
| 55 | pub updated: String, | 56 | /// which since `659f0350` is provenance only: nothing resolves through it, |
| 57 | /// and for work done in an agent worktree the branch was deleted with the | ||
| 58 | /// worktree, so the column filled up with names for things that no longer | ||
| 59 | /// exist. The base ref is the question a reader of a *list* actually has — | ||
| 60 | /// "is this targeting main?" — and it names a branch that is still there. | ||
| 61 | /// The originating branch is still on the detail page, where provenance | ||
| 62 | /// belongs. See issue `850f3b8e`. | ||
| 63 | pub base_ref: String, | ||
| 64 | pub updated: Timestamp, | ||
| 56 | } | 65 | } |
| 57 | 66 | ||
| 58 | #[derive(askama::Template, askama_web::WebTemplate)] | 67 | #[derive(askama::Template, askama_web::WebTemplate)] |
| @@ -65,6 +74,16 @@ pub struct PatchesTemplate { | |||
| 65 | pub open_issues: usize, | 74 | pub open_issues: usize, |
| 66 | pub filter: &'static str, | 75 | pub filter: &'static str, |
| 67 | pub patches: Vec<PatchListItem>, | 76 | pub patches: Vec<PatchListItem>, |
| 77 | /// How many patches the repository holds regardless of filter. | ||
| 78 | /// | ||
| 79 | /// Read *only* by the empty-list message, which is the only place the page | ||
| 80 | /// has anything to say about a number it is not showing. A list with rows | ||
| 81 | /// on it therefore does not go looking for one: the archived namespace is | ||
| 82 | /// the expensive half of the listing, and the `Open` filter deliberately | ||
| 83 | /// avoids it. When rows are present this holds their count instead, which | ||
| 84 | /// is the true total under the `All` filter and unread under the others. | ||
| 85 | /// See issue `c43c459d`. | ||
| 86 | pub total: usize, | ||
| 68 | } | 87 | } |
| 69 | 88 | ||
| 70 | #[derive(Debug)] | 89 | #[derive(Debug)] |
| @@ -77,7 +96,7 @@ pub struct RevisionView { | |||
| 77 | /// the request handler and drop the connection. The `None` case renders as | 96 | /// the request handler and drop the connection. The `None` case renders as |
| 78 | /// `state::UNKNOWN_COMMIT`, spelled out in the template. | 97 | /// `state::UNKNOWN_COMMIT`, spelled out in the template. |
| 79 | pub short_commit: Option<String>, | 98 | pub short_commit: Option<String>, |
| 80 | pub timestamp: String, | 99 | pub timestamp: Timestamp, |
| 81 | pub body: Option<String>, | 100 | pub body: Option<String>, |
| 82 | } | 101 | } |
| 83 | 102 | ||
| @@ -86,7 +105,7 @@ pub struct ReviewView { | |||
| 86 | pub author: String, | 105 | pub author: String, |
| 87 | pub verdict: String, | 106 | pub verdict: String, |
| 88 | pub body: String, | 107 | pub body: String, |
| 89 | pub timestamp: String, | 108 | pub timestamp: Timestamp, |
| 90 | pub revision: Option<u32>, | 109 | pub revision: Option<u32>, |
| 91 | pub edited: bool, | 110 | pub edited: bool, |
| 92 | } | 111 | } |
| @@ -97,7 +116,7 @@ pub struct InlineCommentView { | |||
| 97 | pub file: String, | 116 | pub file: String, |
| 98 | pub line: u32, | 117 | pub line: u32, |
| 99 | pub body: String, | 118 | pub body: String, |
| 100 | pub timestamp: String, | 119 | pub timestamp: Timestamp, |
| 101 | pub revision: Option<u32>, | 120 | pub revision: Option<u32>, |
| 102 | pub edited: bool, | 121 | pub edited: bool, |
| 103 | pub deleted: bool, | 122 | pub deleted: bool, |
| @@ -123,6 +142,11 @@ pub struct PatchDetailView { | |||
| 123 | /// away from where the code actually went. Recorded only — never inferred | 142 | /// away from where the code actually went. Recorded only — never inferred |
| 124 | /// from reachability, which points at whatever happens to be reachable. | 143 | /// from reachability, which points at whatever happens to be reachable. |
| 125 | pub merge_commit: Option<String>, | 144 | pub merge_commit: Option<String>, |
| 145 | /// The same oid at the width the Revisions table beside it uses. The link | ||
| 146 | /// still carries `merge_commit` in full; only the text is abbreviated, so | ||
| 147 | /// the two commit oids on this page stop being displayed at 40 and 8 | ||
| 148 | /// characters respectively. See issue `b2a57996`. | ||
| 149 | pub short_merge_commit: Option<String>, | ||
| 126 | pub author: String, | 150 | pub author: String, |
| 127 | pub labels: String, | 151 | pub labels: String, |
| 128 | pub branch: String, | 152 | pub branch: String, |
| @@ -187,7 +211,7 @@ pub async fn patches( | |||
| 187 | // see the matching comment in `issues.rs`. | 211 | // see the matching comment in `issues.rs`. |
| 188 | let abbrev = git_collab::abbrev::for_patches(&repo); | 212 | let abbrev = git_collab::abbrev::for_patches(&repo); |
| 189 | 213 | ||
| 190 | let patches = filtered_patches | 214 | let patches: Vec<PatchListItem> = filtered_patches |
| 191 | .into_iter() | 215 | .into_iter() |
| 192 | .map(|p| { | 216 | .map(|p| { |
| 193 | let id = p.id.clone(); | 217 | let id = p.id.clone(); |
| @@ -199,12 +223,24 @@ pub async fn patches( | |||
| 199 | title: p.title, | 223 | title: p.title, |
| 200 | author: p.author.name, | 224 | author: p.author.name, |
| 201 | labels: p.labels.join(", "), | 225 | labels: p.labels.join(", "), |
| 202 | branch: p.branch, | 226 | base_ref: p.base_ref, |
| 203 | updated: p.last_updated, | 227 | updated: Timestamp::new(p.last_updated), |
| 204 | } | 228 | } |
| 205 | }) | 229 | }) |
| 206 | .collect(); | 230 | .collect(); |
| 207 | 231 | ||
| 232 | // Only an empty list needs to know the repository's total: "No open | ||
| 233 | // patches" is a claim about the filter, and it is only honest if the page | ||
| 234 | // can also say what the filter is hiding. A page with rows on it says | ||
| 235 | // nothing about the total, so it does not go looking for one. | ||
| 236 | let total = if patches.is_empty() { | ||
| 237 | git_collab::state::list_patches_with_archived(&repo) | ||
| 238 | .unwrap_or_default() | ||
| 239 | .len() | ||
| 240 | } else { | ||
| 241 | patches.len() | ||
| 242 | }; | ||
| 243 | |||
| 208 | PatchesTemplate { | 244 | PatchesTemplate { |
| 209 | site_title: state.site_title.clone(), | 245 | site_title: state.site_title.clone(), |
| 210 | repo_name, | 246 | repo_name, |
| @@ -213,6 +249,7 @@ pub async fn patches( | |||
| 213 | open_issues, | 249 | open_issues, |
| 214 | filter: filter.as_str(), | 250 | filter: filter.as_str(), |
| 215 | patches, | 251 | patches, |
| 252 | total, | ||
| 216 | } | 253 | } |
| 217 | .into_response() | 254 | .into_response() |
| 218 | } | 255 | } |
| @@ -242,6 +279,10 @@ pub async fn patch_detail( | |||
| 242 | title: ps.title, | 279 | title: ps.title, |
| 243 | body: ps.body, | 280 | body: ps.body, |
| 244 | status: ps.status.as_str().to_string(), | 281 | status: ps.status.as_str().to_string(), |
| 282 | short_merge_commit: ps | ||
| 283 | .merge_commit | ||
| 284 | .as_deref() | ||
| 285 | .and_then(git_collab::state::short_commit_oid), | ||
| 245 | merge_commit: ps.merge_commit, | 286 | merge_commit: ps.merge_commit, |
| 246 | author: ps.author.name, | 287 | author: ps.author.name, |
| 247 | labels: ps.labels.join(", "), | 288 | labels: ps.labels.join(", "), |
| @@ -254,7 +295,7 @@ pub async fn patch_detail( | |||
| 254 | number: r.number, | 295 | number: r.number, |
| 255 | short_commit: r.short_commit(), | 296 | short_commit: r.short_commit(), |
| 256 | commit: r.commit, | 297 | commit: r.commit, |
| 257 | timestamp: r.timestamp, | 298 | timestamp: Timestamp::new(r.timestamp), |
| 258 | body: r.body, | 299 | body: r.body, |
| 259 | }) | 300 | }) |
| 260 | .collect(), | 301 | .collect(), |
| @@ -265,7 +306,7 @@ pub async fn patch_detail( | |||
| 265 | author: r.author.name, | 306 | author: r.author.name, |
| 266 | verdict: r.verdict.as_str().to_string(), | 307 | verdict: r.verdict.as_str().to_string(), |
| 267 | body: r.body, | 308 | body: r.body, |
| 268 | timestamp: r.timestamp, | 309 | timestamp: Timestamp::new(r.timestamp), |
| 269 | revision: r.revision, | 310 | revision: r.revision, |
| 270 | edited: r.edited, | 311 | edited: r.edited, |
| 271 | }) | 312 | }) |
| @@ -278,7 +319,7 @@ pub async fn patch_detail( | |||
| 278 | file: ic.file, | 319 | file: ic.file, |
| 279 | line: ic.line, | 320 | line: ic.line, |
| 280 | body: ic.body, | 321 | body: ic.body, |
| 281 | timestamp: ic.timestamp, | 322 | timestamp: Timestamp::new(ic.timestamp), |
| 282 | revision: ic.revision, | 323 | revision: ic.revision, |
| 283 | non_blocking: ic.non_blocking, | 324 | non_blocking: ic.non_blocking, |
| 284 | edited: ic.edited, | 325 | edited: ic.edited, |
src/server/http/templates/base.html
| Old | New | ||
|---|---|---|---|
| @@ -13,6 +13,16 @@ | |||
| 13 | th { font-weight: normal; color: #666; } | 13 | th { font-weight: normal; color: #666; } |
| 14 | td { border-top: 1px solid #eee; } | 14 | td { border-top: 1px solid #eee; } |
| 15 | pre { padding: 8px; background: #f8f8f8; overflow-x: auto; } | 15 | pre { padding: 8px; background: #f8f8f8; overflow-x: auto; } |
| 16 | /* A table wider than the viewport scrolls inside its own block, exactly as | ||
| 17 | a wide diff already does in `pre` above. Without this the table pushes | ||
| 18 | the body out and the whole page scrolls sideways, which takes the header | ||
| 19 | and every other row's leading columns off-screen with it. See issue | ||
| 20 | 8e716471; the property is measured in a real browser by | ||
| 21 | tests/web_layout_test.rs. */ | ||
| 22 | .table-scroll { overflow-x: auto; } | ||
| 23 | /* A timestamp is rendered short with the exact value on hover, so it must | ||
| 24 | not be the thing that wraps a row onto two lines. See issue b2a57996. */ | ||
| 25 | .timestamp { white-space: nowrap; } | ||
| 16 | .badge { font-size: 11px; color: #666; } | 26 | .badge { font-size: 11px; color: #666; } |
| 17 | .status-open { color: green; } | 27 | .status-open { color: green; } |
| 18 | .status-closed { color: red; } | 28 | .status-closed { color: red; } |
| @@ -22,10 +32,10 @@ | |||
| 22 | .diff-file { font-weight: bold; border-top: 1px solid #ccc; padding-top: 8px; } | 32 | .diff-file { font-weight: bold; border-top: 1px solid #ccc; padding-top: 8px; } |
| 23 | .diff-ctx { color: #24292f; background: #fff; } | 33 | .diff-ctx { color: #24292f; background: #fff; } |
| 24 | .diff-empty { background: #f6f8fa; color: transparent; } | 34 | .diff-empty { background: #f6f8fa; color: transparent; } |
| 25 | .header { padding: 8px 16px; border-bottom: 1px solid #ccc; display: flex; align-items: baseline; gap: 16px; } | 35 | .header { padding: 8px 16px; border-bottom: 1px solid #ccc; display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; } |
| 26 | .header a { text-decoration: none; } | 36 | .header a { text-decoration: none; } |
| 27 | .header .site { font-weight: bold; } | 37 | .header .site { font-weight: bold; } |
| 28 | .header nav { display: flex; gap: 12px; } | 38 | .header nav { display: flex; gap: 12px; flex-wrap: wrap; } |
| 29 | .header nav a.active { font-weight: bold; } | 39 | .header nav a.active { font-weight: bold; } |
| 30 | .filter-bar { color: #666; margin-bottom: 12px; } | 40 | .filter-bar { color: #666; margin-bottom: 12px; } |
| 31 | .filter-bar a { margin-left: 8px; } | 41 | .filter-bar a { margin-left: 8px; } |
src/server/http/templates/commits.html
| Old | New | ||
|---|---|---|---|
| @@ -13,6 +13,7 @@ | |||
| 13 | {% if commits.is_empty() %} | 13 | {% if commits.is_empty() %} |
| 14 | <p style="color: #666;">No commits yet.</p> | 14 | <p style="color: #666;">No commits yet.</p> |
| 15 | {% else %} | 15 | {% else %} |
| 16 | <div class="table-scroll"> | ||
| 16 | <table> | 17 | <table> |
| 17 | <thead> | 18 | <thead> |
| 18 | <tr> | 19 | <tr> |
| @@ -33,5 +34,6 @@ | |||
| 33 | {% endfor %} | 34 | {% endfor %} |
| 34 | </tbody> | 35 | </tbody> |
| 35 | </table> | 36 | </table> |
| 37 | </div> | ||
| 36 | {% endif %} | 38 | {% endif %} |
| 37 | {% endblock %} | 39 | {% endblock %} |
src/server/http/templates/issue_detail.html
| Old | New | ||
|---|---|---|---|
| @@ -27,7 +27,7 @@ | |||
| 27 | <div style="border: 1px solid #ddd; border-radius: 4px; padding: 12px; margin-bottom: 12px;"> | 27 | <div style="border: 1px solid #ddd; border-radius: 4px; padding: 12px; margin-bottom: 12px;"> |
| 28 | <p style="margin: 0 0 8px 0;"> | 28 | <p style="margin: 0 0 8px 0;"> |
| 29 | <strong>{{ comment.author }}</strong> | 29 | <strong>{{ comment.author }}</strong> |
| 30 | <span class="mono" style="color: #666; font-size: 0.85em;">{{ comment.timestamp }}</span> | 30 | <span class="mono timestamp" style="color: #666; font-size: 0.85em;" title="{{ comment.timestamp.full }}">{{ comment.timestamp.short }}</span> |
| 31 | {% if comment.edited %} <span style="color: #666; font-size: 0.85em;">(edited)</span>{% endif %} | 31 | {% if comment.edited %} <span style="color: #666; font-size: 0.85em;">(edited)</span>{% endif %} |
| 32 | </p> | 32 | </p> |
| 33 | {% if comment.deleted %} | 33 | {% if comment.deleted %} |
src/server/http/templates/issues.html
| Old | New | ||
|---|---|---|---|
| @@ -11,8 +11,15 @@ | |||
| 11 | <a href="/{{ repo_name }}/issues?filter=all"{% if filter == "all" %} class="active"{% endif %}>all</a> | 11 | <a href="/{{ repo_name }}/issues?filter=all"{% if filter == "all" %} class="active"{% endif %}>all</a> |
| 12 | </p> | 12 | </p> |
| 13 | {% if issues.is_empty() %} | 13 | {% if issues.is_empty() %} |
| 14 | <p style="color: #666;">No issues.</p> | 14 | {# See the matching comment in patches.html, and issue c43c459d. #} |
| 15 | {% if total == 0 %} | ||
| 16 | <p style="color: #666;">No issues yet.</p> | ||
| 15 | {% else %} | 17 | {% else %} |
| 18 | <p style="color: #666;">No {{ filter }} issues. ({{ total }} total — | ||
| 19 | <a href="/{{ repo_name }}/issues?filter=all">show all</a>)</p> | ||
| 20 | {% endif %} | ||
| 21 | {% else %} | ||
| 22 | <div class="table-scroll"> | ||
| 16 | <table> | 23 | <table> |
| 17 | <thead> | 24 | <thead> |
| 18 | <tr> | 25 | <tr> |
| @@ -32,10 +39,11 @@ | |||
| 32 | <td><a href="/{{ repo_name }}/issues/{{ i.id }}">{{ i.title }}</a></td> | 39 | <td><a href="/{{ repo_name }}/issues/{{ i.id }}">{{ i.title }}</a></td> |
| 33 | <td style="color: #666;">{{ i.author }}</td> | 40 | <td style="color: #666;">{{ i.author }}</td> |
| 34 | <td style="color: #666;">{{ i.labels }}</td> | 41 | <td style="color: #666;">{{ i.labels }}</td> |
| 35 | <td class="mono" style="color: #666;">{{ i.updated }}</td> | 42 | <td class="mono timestamp" style="color: #666;" title="{{ i.updated.full }}">{{ i.updated.short }}</td> |
| 36 | </tr> | 43 | </tr> |
| 37 | {% endfor %} | 44 | {% endfor %} |
| 38 | </tbody> | 45 | </tbody> |
| 39 | </table> | 46 | </table> |
| 47 | </div> | ||
| 40 | {% endif %} | 48 | {% endif %} |
| 41 | {% endblock %} | 49 | {% endblock %} |
src/server/http/templates/patch_detail.html
| Old | New | ||
|---|---|---|---|
| @@ -10,7 +10,9 @@ | |||
| 10 | <span class="mono" style="color: #666;">{{ patch.branch }} → {{ patch.base_ref }}</span> | 10 | <span class="mono" style="color: #666;">{{ patch.branch }} → {{ patch.base_ref }}</span> |
| 11 | </p> | 11 | </p> |
| 12 | {% if let Some(merge_commit) = patch.merge_commit %} | 12 | {% if let Some(merge_commit) = patch.merge_commit %} |
| 13 | <p>Merged in <a class="mono" href="/{{ repo_name }}/diff/{{ merge_commit }}">{{ merge_commit }}</a></p> | 13 | {# The link resolves the full oid; the text matches the Revisions table below. |
| 14 | See issue b2a57996. #} | ||
| 15 | <p>Merged in <a class="mono" href="/{{ repo_name }}/diff/{{ merge_commit }}" title="{{ merge_commit }}">{% if let Some(short) = patch.short_merge_commit %}{{ short }}{% else %}{{ merge_commit }}{% endif %}</a></p> | ||
| 14 | {% endif %} | 16 | {% endif %} |
| 15 | {% if !patch.labels.is_empty() %} | 17 | {% if !patch.labels.is_empty() %} |
| 16 | <p style="color: #666;">Labels: {{ patch.labels }}</p> | 18 | <p style="color: #666;">Labels: {{ patch.labels }}</p> |
| @@ -23,6 +25,7 @@ | |||
| 23 | {% if patch.revisions.is_empty() %} | 25 | {% if patch.revisions.is_empty() %} |
| 24 | <p style="color: #666;">No revisions.</p> | 26 | <p style="color: #666;">No revisions.</p> |
| 25 | {% else %} | 27 | {% else %} |
| 28 | <div class="table-scroll"> | ||
| 26 | <table> | 29 | <table> |
| 27 | <thead> | 30 | <thead> |
| 28 | <tr> | 31 | <tr> |
| @@ -37,12 +40,13 @@ | |||
| 37 | <tr> | 40 | <tr> |
| 38 | <td>{{ rev.number }}</td> | 41 | <td>{{ rev.number }}</td> |
| 39 | <td class="mono">{% if let Some(short) = rev.short_commit %}<a href="/{{ repo_name }}/diff/{{ rev.commit }}">{{ short }}</a>{% else %}<span style="color: #666;">unknown</span>{% endif %}</td> | 42 | <td class="mono">{% if let Some(short) = rev.short_commit %}<a href="/{{ repo_name }}/diff/{{ rev.commit }}">{{ short }}</a>{% else %}<span style="color: #666;">unknown</span>{% endif %}</td> |
| 40 | <td class="mono" style="color: #666;">{{ rev.timestamp }}</td> | 43 | <td class="mono timestamp" style="color: #666;" title="{{ rev.timestamp.full }}">{{ rev.timestamp.short }}</td> |
| 41 | <td>{% if let Some(body) = rev.body %}{{ body }}{% endif %}</td> | 44 | <td>{% if let Some(body) = rev.body %}{{ body }}{% endif %}</td> |
| 42 | </tr> | 45 | </tr> |
| 43 | {% endfor %} | 46 | {% endfor %} |
| 44 | </tbody> | 47 | </tbody> |
| 45 | </table> | 48 | </table> |
| 49 | </div> | ||
| 46 | {% endif %} | 50 | {% endif %} |
| 47 | 51 | ||
| 48 | {% if !patch.reviews.is_empty() %} | 52 | {% if !patch.reviews.is_empty() %} |
| @@ -52,7 +56,7 @@ | |||
| 52 | <p style="margin: 0 0 8px 0;"> | 56 | <p style="margin: 0 0 8px 0;"> |
| 53 | <strong>{{ review.author }}</strong> | 57 | <strong>{{ review.author }}</strong> |
| 54 | <span class="status-{{ review.verdict }}">{{ review.verdict }}</span> | 58 | <span class="status-{{ review.verdict }}">{{ review.verdict }}</span> |
| 55 | <span class="mono" style="color: #666; font-size: 0.85em;">{{ review.timestamp }}</span> | 59 | <span class="mono timestamp" style="color: #666; font-size: 0.85em;" title="{{ review.timestamp.full }}">{{ review.timestamp.short }}</span> |
| 56 | {% if let Some(rev) = review.revision %} rev {{ rev }}{% endif %} | 60 | {% if let Some(rev) = review.revision %} rev {{ rev }}{% endif %} |
| 57 | {% if review.edited %} <span style="color: #666; font-size: 0.85em;">(edited)</span>{% endif %} | 61 | {% if review.edited %} <span style="color: #666; font-size: 0.85em;">(edited)</span>{% endif %} |
| 58 | </p> | 62 | </p> |
| @@ -70,7 +74,7 @@ | |||
| 70 | <p style="margin: 0 0 8px 0;"> | 74 | <p style="margin: 0 0 8px 0;"> |
| 71 | <strong>{{ ic.author }}</strong> | 75 | <strong>{{ ic.author }}</strong> |
| 72 | <span class="mono" style="color: #666;">{{ ic.file }}:{{ ic.line }}</span> | 76 | <span class="mono" style="color: #666;">{{ ic.file }}:{{ ic.line }}</span> |
| 73 | <span class="mono" style="color: #666; font-size: 0.85em;">{{ ic.timestamp }}</span> | 77 | <span class="mono timestamp" style="color: #666; font-size: 0.85em;" title="{{ ic.timestamp.full }}">{{ ic.timestamp.short }}</span> |
| 74 | {% if let Some(rev) = ic.revision %} rev {{ rev }}{% endif %} | 78 | {% if let Some(rev) = ic.revision %} rev {{ rev }}{% endif %} |
| 75 | {% if ic.edited %} <span style="color: #666; font-size: 0.85em;">(edited)</span>{% endif %} | 79 | {% if ic.edited %} <span style="color: #666; font-size: 0.85em;">(edited)</span>{% endif %} |
| 76 | {% if ic.non_blocking %} <span style="color: #666; font-size: 0.85em;">[non-blocking]</span>{% endif %} | 80 | {% if ic.non_blocking %} <span style="color: #666; font-size: 0.85em;">[non-blocking]</span>{% endif %} |
| @@ -91,7 +95,7 @@ | |||
| 91 | <div style="border: 1px solid #ddd; border-radius: 4px; padding: 12px; margin-bottom: 12px;"> | 95 | <div style="border: 1px solid #ddd; border-radius: 4px; padding: 12px; margin-bottom: 12px;"> |
| 92 | <p style="margin: 0 0 8px 0;"> | 96 | <p style="margin: 0 0 8px 0;"> |
| 93 | <strong>{{ comment.author }}</strong> | 97 | <strong>{{ comment.author }}</strong> |
| 94 | <span class="mono" style="color: #666; font-size: 0.85em;">{{ comment.timestamp }}</span> | 98 | <span class="mono timestamp" style="color: #666; font-size: 0.85em;" title="{{ comment.timestamp.full }}">{{ comment.timestamp.short }}</span> |
| 95 | {% if comment.edited %} <span style="color: #666; font-size: 0.85em;">(edited)</span>{% endif %} | 99 | {% if comment.edited %} <span style="color: #666; font-size: 0.85em;">(edited)</span>{% endif %} |
| 96 | </p> | 100 | </p> |
| 97 | {% if comment.deleted %} | 101 | {% if comment.deleted %} |
src/server/http/templates/patches.html
| Old | New | ||
|---|---|---|---|
| @@ -12,8 +12,18 @@ | |||
| 12 | <a href="/{{ repo_name }}/patches?filter=all"{% if filter == "all" %} class="active"{% endif %}>all</a> | 12 | <a href="/{{ repo_name }}/patches?filter=all"{% if filter == "all" %} class="active"{% endif %}>all</a> |
| 13 | </p> | 13 | </p> |
| 14 | {% if patches.is_empty() %} | 14 | {% if patches.is_empty() %} |
| 15 | <p style="color: #666;">No patches.</p> | 15 | {# An empty *filtered* list is not an empty repository. Saying "No patches." |
| 16 | under the default `open` filter was simply false on a repo whose work had | ||
| 17 | all landed, and the only clue was one bold word among four links. See | ||
| 18 | issue c43c459d. #} | ||
| 19 | {% if total == 0 %} | ||
| 20 | <p style="color: #666;">No patches yet.</p> | ||
| 16 | {% else %} | 21 | {% else %} |
| 22 | <p style="color: #666;">No {{ filter }} patches. ({{ total }} total — | ||
| 23 | <a href="/{{ repo_name }}/patches?filter=all">show all</a>)</p> | ||
| 24 | {% endif %} | ||
| 25 | {% else %} | ||
| 26 | <div class="table-scroll"> | ||
| 17 | <table> | 27 | <table> |
| 18 | <thead> | 28 | <thead> |
| 19 | <tr> | 29 | <tr> |
| @@ -22,7 +32,7 @@ | |||
| 22 | <th>Title</th> | 32 | <th>Title</th> |
| 23 | <th>Author</th> | 33 | <th>Author</th> |
| 24 | <th>Labels</th> | 34 | <th>Labels</th> |
| 25 | <th>Branch</th> | 35 | <th>Base</th> |
| 26 | <th>Updated</th> | 36 | <th>Updated</th> |
| 27 | </tr> | 37 | </tr> |
| 28 | </thead> | 38 | </thead> |
| @@ -34,11 +44,12 @@ | |||
| 34 | <td><a href="/{{ repo_name }}/patches/{{ p.id }}">{{ p.title }}</a></td> | 44 | <td><a href="/{{ repo_name }}/patches/{{ p.id }}">{{ p.title }}</a></td> |
| 35 | <td style="color: #666;">{{ p.author }}</td> | 45 | <td style="color: #666;">{{ p.author }}</td> |
| 36 | <td style="color: #666;">{{ p.labels }}</td> | 46 | <td style="color: #666;">{{ p.labels }}</td> |
| 37 | <td class="mono" style="color: #666;">{{ p.branch }}</td> | 47 | <td class="mono" style="color: #666;">{{ p.base_ref }}</td> |
| 38 | <td class="mono" style="color: #666;">{{ p.updated }}</td> | 48 | <td class="mono timestamp" style="color: #666;" title="{{ p.updated.full }}">{{ p.updated.short }}</td> |
| 39 | </tr> | 49 | </tr> |
| 40 | {% endfor %} | 50 | {% endfor %} |
| 41 | </tbody> | 51 | </tbody> |
| 42 | </table> | 52 | </table> |
| 53 | </div> | ||
| 43 | {% endif %} | 54 | {% endif %} |
| 44 | {% endblock %} | 55 | {% endblock %} |
src/server/http/templates/releases.html
| Old | New | ||
|---|---|---|---|
| @@ -14,7 +14,9 @@ | |||
| 14 | <section> | 14 | <section> |
| 15 | <h3>{{ v.version }}</h3> | 15 | <h3>{{ v.version }}</h3> |
| 16 | <p style="color: #666;">{{ v.published }}</p> | 16 | <p style="color: #666;">{{ v.published }}</p> |
| 17 | <ul> | 17 | {# A sha256 is 64 unbreakable characters, wider than a phone. It is allowed |
| 18 | to wrap mid-string rather than push the page sideways — see 8e716471. #} | ||
| 19 | <ul style="overflow-wrap: anywhere;"> | ||
| 18 | {% for f in v.files %} | 20 | {% for f in v.files %} |
| 19 | <li> | 21 | <li> |
| 20 | {% if downloads_available %} | 22 | {% if downloads_available %} |
src/server/http/templates/repo_list.html
| Old | New | ||
|---|---|---|---|
| @@ -6,6 +6,7 @@ | |||
| 6 | {% if repos.is_empty() %} | 6 | {% if repos.is_empty() %} |
| 7 | <p style="color: #666;">No repositories found.</p> | 7 | <p style="color: #666;">No repositories found.</p> |
| 8 | {% else %} | 8 | {% else %} |
| 9 | <div class="table-scroll"> | ||
| 9 | <table> | 10 | <table> |
| 10 | <thead> | 11 | <thead> |
| 11 | <tr> | 12 | <tr> |
| @@ -24,6 +25,7 @@ | |||
| 24 | {% endfor %} | 25 | {% endfor %} |
| 25 | </tbody> | 26 | </tbody> |
| 26 | </table> | 27 | </table> |
| 28 | </div> | ||
| 27 | {% endif %} | 29 | {% endif %} |
| 28 | </div> | 30 | </div> |
| 29 | {% endblock %} | 31 | {% endblock %} |
src/server/http/templates/repo_overview.html
| Old | New | ||
|---|---|---|---|
| @@ -9,12 +9,16 @@ | |||
| 9 | <div class="readme-body">{{ r.html|safe }}</div> | 9 | <div class="readme-body">{{ r.html|safe }}</div> |
| 10 | </div> | 10 | </div> |
| 11 | {% endif %} | 11 | {% endif %} |
| 12 | <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 32px;"> | 12 | {# `minmax(min(100%, 22rem), 1fr)` rather than `1fr 1fr`: two fixed columns are |
| 13 | narrower than their content on a phone, and each half then pushed the page | ||
| 14 | sideways on its own. This collapses to one column below ~44rem. #} | ||
| 15 | <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); gap: 24px; margin-bottom: 32px;"> | ||
| 13 | <div> | 16 | <div> |
| 14 | <h3 style="margin-top: 0;">Open Patches</h3> | 17 | <h3 style="margin-top: 0;">Open Patches</h3> |
| 15 | {% if patches.is_empty() %} | 18 | {% if patches.is_empty() %} |
| 16 | <p style="color: #666;">No open patches.</p> | 19 | <p style="color: #666;">No open patches.</p> |
| 17 | {% else %} | 20 | {% else %} |
| 21 | <div class="table-scroll"> | ||
| 18 | <table> | 22 | <table> |
| 19 | <thead> | 23 | <thead> |
| 20 | <tr> | 24 | <tr> |
| @@ -33,6 +37,7 @@ | |||
| 33 | {% endfor %} | 37 | {% endfor %} |
| 34 | </tbody> | 38 | </tbody> |
| 35 | </table> | 39 | </table> |
| 40 | </div> | ||
| 36 | {% endif %} | 41 | {% endif %} |
| 37 | </div> | 42 | </div> |
| 38 | 43 | ||
| @@ -41,6 +46,7 @@ | |||
| 41 | {% if issues.is_empty() %} | 46 | {% if issues.is_empty() %} |
| 42 | <p style="color: #666;">No open issues.</p> | 47 | <p style="color: #666;">No open issues.</p> |
| 43 | {% else %} | 48 | {% else %} |
| 49 | <div class="table-scroll"> | ||
| 44 | <table> | 50 | <table> |
| 45 | <thead> | 51 | <thead> |
| 46 | <tr> | 52 | <tr> |
| @@ -59,6 +65,7 @@ | |||
| 59 | {% endfor %} | 65 | {% endfor %} |
| 60 | </tbody> | 66 | </tbody> |
| 61 | </table> | 67 | </table> |
| 68 | </div> | ||
| 62 | {% endif %} | 69 | {% endif %} |
| 63 | </div> | 70 | </div> |
| 64 | </div> | 71 | </div> |
| @@ -68,6 +75,7 @@ | |||
| 68 | {% if commits.is_empty() %} | 75 | {% if commits.is_empty() %} |
| 69 | <p style="color: #666;">No commits yet.</p> | 76 | <p style="color: #666;">No commits yet.</p> |
| 70 | {% else %} | 77 | {% else %} |
| 78 | <div class="table-scroll"> | ||
| 71 | <table> | 79 | <table> |
| 72 | <thead> | 80 | <thead> |
| 73 | <tr> | 81 | <tr> |
| @@ -88,6 +96,7 @@ | |||
| 88 | {% endfor %} | 96 | {% endfor %} |
| 89 | </tbody> | 97 | </tbody> |
| 90 | </table> | 98 | </table> |
| 99 | </div> | ||
| 91 | {% endif %} | 100 | {% endif %} |
| 92 | </div> | 101 | </div> |
| 93 | {% endblock %} | 102 | {% endblock %} |
src/server/http/templates/tree.html
| Old | New | ||
|---|---|---|---|
| @@ -11,6 +11,7 @@ | |||
| 11 | </select> | 11 | </select> |
| 12 | {% if !path_display.is_empty() %} / {{ path_display }}{% endif %} | 12 | {% if !path_display.is_empty() %} / {{ path_display }}{% endif %} |
| 13 | </h2> | 13 | </h2> |
| 14 | <div class="table-scroll"> | ||
| 14 | <table> | 15 | <table> |
| 15 | <thead> | 16 | <thead> |
| 16 | <tr> | 17 | <tr> |
| @@ -38,4 +39,5 @@ | |||
| 38 | {% endfor %} | 39 | {% endfor %} |
| 39 | </tbody> | 40 | </tbody> |
| 40 | </table> | 41 | </table> |
| 42 | </div> | ||
| 41 | {% endblock %} | 43 | {% endblock %} |
src/server/http/timestamp.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,117 @@ | |||
| 1 | //! How a stored timestamp goes onto a page. | ||
| 2 | //! | ||
| 3 | //! Events store `chrono::Utc::now().to_rfc3339()`, which is 35 characters of | ||
| 4 | //! which nine are nanoseconds: | ||
| 5 | //! | ||
| 6 | //! ```text | ||
| 7 | //! 2026-08-11T16:08:31.458124482+00:00 | ||
| 8 | //! ``` | ||
| 9 | //! | ||
| 10 | //! That is the right thing to *store* — it is exact, sortable and unambiguous | ||
| 11 | //! about its offset — and the wrong thing to put in a table cell. At 35 | ||
| 12 | //! characters it was the widest column after the title, it wrapped every row | ||
| 13 | //! onto two lines, and the nine digits of precision are not a number any | ||
| 14 | //! reader has ever needed. See issue `b2a57996`. | ||
| 15 | //! | ||
| 16 | //! So a timestamp reaching a template is split in two: a short form for the | ||
| 17 | //! cell and the stored form for `title=`, which browsers surface on hover. | ||
| 18 | //! Nothing is discarded — the exact value is still on the page, one hover | ||
| 19 | //! away, and `--json` is untouched and still carries it in full. | ||
| 20 | //! | ||
| 21 | //! # Why minutes, and why absolute | ||
| 22 | //! | ||
| 23 | //! Minute precision is the coarsest rendering that still orders two events on | ||
| 24 | //! the same day, which is the common case for a review and the comment | ||
| 25 | //! answering it. Seconds add three characters and settle nothing a reader | ||
| 26 | //! cares about. | ||
| 27 | //! | ||
| 28 | //! Absolute rather than relative ("3 days ago") because a relative rendering | ||
| 29 | //! is a function of when the page was built, so the same URL says different | ||
| 30 | //! things at different times: it cannot be cached, cannot be compared between | ||
| 31 | //! two rows rendered from different requests, and cannot be asserted by a test | ||
| 32 | //! without freezing a clock. The exact instant is what was recorded; the page | ||
| 33 | //! should say it. | ||
| 34 | //! | ||
| 35 | //! # Why UTC | ||
| 36 | //! | ||
| 37 | //! The server cannot know the reader's timezone, and a page rendered in the | ||
| 38 | //! *server's* zone would silently disagree with the same page rendered | ||
| 39 | //! elsewhere. Every timestamp this project writes is already `+00:00`, so | ||
| 40 | //! normalizing to UTC changes nothing in practice and fixes the display for a | ||
| 41 | //! value that arrived with some other offset from a repository synced in. | ||
| 42 | |||
| 43 | /// A stored timestamp, split into what a cell shows and what it stands for. | ||
| 44 | #[derive(Debug, Clone)] | ||
| 45 | pub struct Timestamp { | ||
| 46 | /// Exactly as stored — the `title` attribute, so nothing is lost. | ||
| 47 | pub full: String, | ||
| 48 | /// `YYYY-MM-DD HH:MM` in UTC. | ||
| 49 | pub short: String, | ||
| 50 | } | ||
| 51 | |||
| 52 | impl Timestamp { | ||
| 53 | /// Split a stored RFC3339 timestamp for display. | ||
| 54 | /// | ||
| 55 | /// An unparseable value renders as itself. A timestamp this project did | ||
| 56 | /// not write is still a fact about the object, and a page that dropped it | ||
| 57 | /// or printed a placeholder would be hiding the one thing that could | ||
| 58 | /// explain the row. Ugly beats absent. | ||
| 59 | pub fn new(stored: impl Into<String>) -> Self { | ||
| 60 | let full = stored.into(); | ||
| 61 | let short = chrono::DateTime::parse_from_rfc3339(&full) | ||
| 62 | .map(|dt| { | ||
| 63 | dt.with_timezone(&chrono::Utc) | ||
| 64 | .format("%Y-%m-%d %H:%M") | ||
| 65 | .to_string() | ||
| 66 | }) | ||
| 67 | .unwrap_or_else(|_| full.clone()); | ||
| 68 | Timestamp { full, short } | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | #[cfg(test)] | ||
| 73 | mod tests { | ||
| 74 | use super::*; | ||
| 75 | |||
| 76 | #[test] | ||
| 77 | fn nanoseconds_and_the_offset_are_dropped_from_the_short_form() { | ||
| 78 | let t = Timestamp::new("2026-08-11T16:08:31.458124482+00:00"); | ||
| 79 | assert_eq!(t.short, "2026-08-11 16:08"); | ||
| 80 | assert_eq!(t.short.len(), 16); | ||
| 81 | } | ||
| 82 | |||
| 83 | #[test] | ||
| 84 | fn the_stored_value_survives_for_the_title_attribute() { | ||
| 85 | let stored = "2026-08-11T16:08:31.458124482+00:00"; | ||
| 86 | assert_eq!(Timestamp::new(stored).full, stored); | ||
| 87 | } | ||
| 88 | |||
| 89 | /// A value that arrived with a non-UTC offset must not read as though its | ||
| 90 | /// wall-clock digits were UTC — two rows in one table have to be | ||
| 91 | /// comparable by eye. | ||
| 92 | #[test] | ||
| 93 | fn a_foreign_offset_is_normalized_to_utc() { | ||
| 94 | let t = Timestamp::new("2026-08-11T18:08:31+02:00"); | ||
| 95 | assert_eq!(t.short, "2026-08-11 16:08"); | ||
| 96 | } | ||
| 97 | |||
| 98 | #[test] | ||
| 99 | fn a_second_precision_timestamp_shortens_too() { | ||
| 100 | assert_eq!( | ||
| 101 | Timestamp::new("2026-08-11T16:08:31Z").short, | ||
| 102 | "2026-08-11 16:08" | ||
| 103 | ); | ||
| 104 | } | ||
| 105 | |||
| 106 | #[test] | ||
| 107 | fn an_unparseable_timestamp_renders_as_itself_rather_than_vanishing() { | ||
| 108 | let t = Timestamp::new("not a date"); | ||
| 109 | assert_eq!(t.short, "not a date"); | ||
| 110 | assert_eq!(t.full, "not a date"); | ||
| 111 | } | ||
| 112 | |||
| 113 | #[test] | ||
| 114 | fn an_empty_timestamp_stays_empty() { | ||
| 115 | assert_eq!(Timestamp::new("").short, ""); | ||
| 116 | } | ||
| 117 | } | ||
src/state.rs
| Old | New | ||
|---|---|---|---|
| @@ -393,13 +393,30 @@ pub struct Revision { | |||
| 393 | 393 | ||
| 394 | impl Revision { | 394 | impl Revision { |
| 395 | /// Abbreviated commit for display, or `None` when none was recorded. | 395 | /// Abbreviated commit for display, or `None` when none was recorded. |
| 396 | /// Truncating by chars rather than bytes keeps this total for any input. | ||
| 397 | pub fn short_commit(&self) -> Option<String> { | 396 | pub fn short_commit(&self) -> Option<String> { |
| 398 | if self.commit.is_empty() { | 397 | short_commit_oid(&self.commit) |
| 399 | return None; | 398 | } |
| 400 | } | 399 | } |
| 401 | Some(self.commit.chars().take(8).collect()) | 400 | |
| 401 | /// Abbreviate a commit oid for display, or `None` for an empty one. | ||
| 402 | /// | ||
| 403 | /// A *commit* oid, not a collab id: [`crate::abbrev`] sizes collab ids against | ||
| 404 | /// the set of ids in the repository, and git already abbreviates its own | ||
| 405 | /// objects by its own rule. What this shares with that module is only the | ||
| 406 | /// floor, [`crate::abbrev::MIN_WIDTH`], which is what every commit in this | ||
| 407 | /// project's output has always been printed at. | ||
| 408 | /// | ||
| 409 | /// Shared rather than duplicated because the alternative is what issue | ||
| 410 | /// `b2a57996` reported: a patch detail page rendering `Merged in` at 40 | ||
| 411 | /// characters beside a Revisions table rendering 8, with no rule anywhere | ||
| 412 | /// saying which was right. | ||
| 413 | /// | ||
| 414 | /// Truncating by chars rather than bytes keeps this total for any input. | ||
| 415 | pub fn short_commit_oid(commit: &str) -> Option<String> { | ||
| 416 | if commit.is_empty() { | ||
| 417 | return None; | ||
| 402 | } | 418 | } |
| 419 | Some(commit.chars().take(crate::abbrev::MIN_WIDTH).collect()) | ||
| 403 | } | 420 | } |
| 404 | 421 | ||
| 405 | /// What to show in place of a commit that was never recorded. Shared so the | 422 | /// What to show in place of a commit that was never recorded. Shared so the |
tests/web_layout_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,238 @@ | |||
| 1 | //! The web UI's pages must not scroll sideways at a narrow viewport. | ||
| 2 | //! | ||
| 3 | //! This is a *layout* property, not a markup one: the HTML that overflows is | ||
| 4 | //! perfectly well-formed, every content assertion about it passes, and the | ||
| 5 | //! defect only exists once a browser has laid the page out at a width. So the | ||
| 6 | //! primary test here renders real server output in a real headless browser and | ||
| 7 | //! compares `documentElement.scrollWidth` with `window.innerWidth` — the same | ||
| 8 | //! measurement a human made by hand when this was filed. | ||
| 9 | //! | ||
| 10 | //! The browser is an *external oracle*: it is not part of this project and has | ||
| 11 | //! no idea what the test expects, so it cannot be talked into agreeing. A test | ||
| 12 | //! that only asserted "the template contains a wrapper div" would be blessing | ||
| 13 | //! our own guess about what makes a page fit. | ||
| 14 | //! | ||
| 15 | //! The page is measured from a `file://` copy rather than over HTTP because the | ||
| 16 | //! server's pages are entirely self-contained — the stylesheet is inlined in | ||
| 17 | //! `base.html` and nothing else is fetched — so the two lay out identically, | ||
| 18 | //! and the copy avoids handing a browser a URL to a live test server. | ||
| 19 | //! | ||
| 20 | //! If no browser is installed the measurement is skipped loudly, and | ||
| 21 | //! `structural_fallback` below still guards the invariant that produces the | ||
| 22 | //! property. Set `GIT_COLLAB_REQUIRE_BROWSER=1` to turn the skip into a | ||
| 23 | //! failure, which is what CI should do once a browser is provisioned. | ||
| 24 | |||
| 25 | mod common; | ||
| 26 | |||
| 27 | use common::ServerHarness; | ||
| 28 | use std::path::PathBuf; | ||
| 29 | use std::process::Command; | ||
| 30 | |||
| 31 | /// Viewport width to measure at. Narrow enough to be a real phone and to be | ||
| 32 | /// the width the defect was reported at. | ||
| 33 | const NARROW_WIDTH: u32 = 500; | ||
| 34 | |||
| 35 | /// A branch name of the shape agent worktrees actually produce, which is what | ||
| 36 | /// made the patch list overflow in the first place. | ||
| 37 | const EPHEMERAL_BRANCH: &str = "worktree-agent-ab8e151c29539ab74"; | ||
| 38 | |||
| 39 | /// Locate a Chromium-family browser, or `None` if this machine has none. | ||
| 40 | fn find_browser() -> Option<PathBuf> { | ||
| 41 | if let Ok(explicit) = std::env::var("CHROME") { | ||
| 42 | let path = PathBuf::from(explicit); | ||
| 43 | if path.exists() { | ||
| 44 | return Some(path); | ||
| 45 | } | ||
| 46 | } | ||
| 47 | for name in [ | ||
| 48 | "chromium", | ||
| 49 | "chromium-browser", | ||
| 50 | "google-chrome-stable", | ||
| 51 | "google-chrome", | ||
| 52 | "chrome", | ||
| 53 | ] { | ||
| 54 | if let Ok(output) = Command::new("which").arg(name).output() { | ||
| 55 | if output.status.success() { | ||
| 56 | let found = String::from_utf8_lossy(&output.stdout).trim().to_string(); | ||
| 57 | if !found.is_empty() { | ||
| 58 | return Some(PathBuf::from(found)); | ||
| 59 | } | ||
| 60 | } | ||
| 61 | } | ||
| 62 | } | ||
| 63 | None | ||
| 64 | } | ||
| 65 | |||
| 66 | /// Lay `html` out at `width` pixels and report | ||
| 67 | /// `(documentElement.scrollWidth, window.innerWidth)`. | ||
| 68 | /// | ||
| 69 | /// The probe script appends its measurement to the DOM and `--dump-dom` prints | ||
| 70 | /// the DOM after load, which is how a number gets back out of a browser that | ||
| 71 | /// has no other channel to talk to us. | ||
| 72 | fn measure(browser: &PathBuf, html: &str, width: u32) -> (u32, u32) { | ||
| 73 | let dir = tempfile::TempDir::new().unwrap(); | ||
| 74 | let page = dir.path().join("page.html"); | ||
| 75 | // The result is keyed on an *attribute* rather than a text marker: the | ||
| 76 | // dumped DOM includes this script's own source, so any literal the script | ||
| 77 | // prints would be found in the script before it was found in the result. | ||
| 78 | let probe = "\n<script>window.addEventListener('load',function(){\ | ||
| 79 | var d=document.createElement('div');\ | ||
| 80 | d.setAttribute('data-measure','');\ | ||
| 81 | d.textContent=document.documentElement.scrollWidth+' '+window.innerWidth;\ | ||
| 82 | document.body.appendChild(d);});</script>\n"; | ||
| 83 | std::fs::write(&page, format!("{html}{probe}")).unwrap(); | ||
| 84 | |||
| 85 | let profile = dir.path().join("profile"); | ||
| 86 | let output = Command::new(browser) | ||
| 87 | .args([ | ||
| 88 | "--headless", | ||
| 89 | "--disable-gpu", | ||
| 90 | "--no-sandbox", | ||
| 91 | "--no-first-run", | ||
| 92 | "--disable-extensions", | ||
| 93 | "--dump-dom", | ||
| 94 | "--virtual-time-budget=4000", | ||
| 95 | ]) | ||
| 96 | .arg(format!("--window-size={width},900")) | ||
| 97 | .arg(format!("--user-data-dir={}", profile.display())) | ||
| 98 | .arg(format!("file://{}", page.display())) | ||
| 99 | .output() | ||
| 100 | .expect("failed to run headless browser"); | ||
| 101 | |||
| 102 | let dom = String::from_utf8_lossy(&output.stdout); | ||
| 103 | let after = dom | ||
| 104 | .split("data-measure=\"\">") | ||
| 105 | .nth(1) | ||
| 106 | .unwrap_or_else(|| panic!("browser produced no measurement; DOM was:\n{dom}")); | ||
| 107 | let text = after | ||
| 108 | .split('<') | ||
| 109 | .next() | ||
| 110 | .expect("measurement div has a closing tag"); | ||
| 111 | let mut parts = text.split_whitespace(); | ||
| 112 | let mut next = || { | ||
| 113 | parts | ||
| 114 | .next() | ||
| 115 | .unwrap_or_else(|| panic!("malformed measurement '{text}'")) | ||
| 116 | .parse::<u32>() | ||
| 117 | .unwrap_or_else(|_| panic!("malformed measurement '{text}'")) | ||
| 118 | }; | ||
| 119 | let scroll_width = next(); | ||
| 120 | let inner_width = next(); | ||
| 121 | (scroll_width, inner_width) | ||
| 122 | } | ||
| 123 | |||
| 124 | /// Seed a repository whose lists are as wide as the real ones: a patch created | ||
| 125 | /// from an ephemeral worktree branch, an issue, a commit and a release. | ||
| 126 | fn seed(harness: &ServerHarness) { | ||
| 127 | let repo = harness.work_repo(); | ||
| 128 | repo.commit_file( | ||
| 129 | "src/rendering.rs", | ||
| 130 | "pub fn render() {}\n", | ||
| 131 | "Render the patch list without forcing the page sideways", | ||
| 132 | ); | ||
| 133 | repo.git(&["checkout", "-b", EPHEMERAL_BRANCH]); | ||
| 134 | repo.commit_file( | ||
| 135 | "src/wide.rs", | ||
| 136 | "pub fn wide() {}\n", | ||
| 137 | "commit on an agent worktree branch", | ||
| 138 | ); | ||
| 139 | repo.run_ok(&[ | ||
| 140 | "patch", | ||
| 141 | "create", | ||
| 142 | "-t", | ||
| 143 | "Wrap list tables so a narrow viewport does not scroll sideways", | ||
| 144 | "-B", | ||
| 145 | EPHEMERAL_BRANCH, | ||
| 146 | ]); | ||
| 147 | repo.git(&["checkout", "main"]); | ||
| 148 | repo.issue_open("Timestamps render as full RFC3339 with nanoseconds throughout"); | ||
| 149 | |||
| 150 | harness.push_head(); | ||
| 151 | harness.push_collab_refs(); | ||
| 152 | |||
| 153 | // A release too: its listing carries a 64-character sha256, which is wider | ||
| 154 | // than the viewport all by itself. | ||
| 155 | let content: Vec<u8> = (0u32..64).flat_map(|i| i.to_le_bytes()).collect(); | ||
| 156 | harness.ssh_exec_with_stdin( | ||
| 157 | &format!( | ||
| 158 | "collab-release upload '{}.git' 'v1.0.0' 'git-collab-x86_64-unknown-linux-gnu.tar.gz'", | ||
| 159 | harness.repo_name() | ||
| 160 | ), | ||
| 161 | &content, | ||
| 162 | ); | ||
| 163 | } | ||
| 164 | |||
| 165 | /// Every page a reader lands on, at the width a phone gives them. | ||
| 166 | fn pages(repo_name: &str) -> Vec<String> { | ||
| 167 | vec![ | ||
| 168 | format!("/{repo_name}"), | ||
| 169 | format!("/{repo_name}/patches"), | ||
| 170 | format!("/{repo_name}/patches?filter=all"), | ||
| 171 | format!("/{repo_name}/issues"), | ||
| 172 | format!("/{repo_name}/issues?filter=all"), | ||
| 173 | format!("/{repo_name}/commits"), | ||
| 174 | format!("/{repo_name}/releases"), | ||
| 175 | format!("/{repo_name}/tree"), | ||
| 176 | ] | ||
| 177 | } | ||
| 178 | |||
| 179 | #[test] | ||
| 180 | fn no_page_scrolls_sideways_at_a_narrow_viewport() { | ||
| 181 | let browser = match find_browser() { | ||
| 182 | Some(b) => b, | ||
| 183 | None => { | ||
| 184 | let message = "SKIPPED no_page_scrolls_sideways_at_a_narrow_viewport: no \ | ||
| 185 | Chromium-family browser found. Install chromium or set $CHROME. \ | ||
| 186 | Set GIT_COLLAB_REQUIRE_BROWSER=1 to make this a failure."; | ||
| 187 | if std::env::var("GIT_COLLAB_REQUIRE_BROWSER").is_ok() { | ||
| 188 | panic!("{message}"); | ||
| 189 | } | ||
| 190 | eprintln!("{message}"); | ||
| 191 | return; | ||
| 192 | } | ||
| 193 | }; | ||
| 194 | |||
| 195 | let harness = ServerHarness::new("layout-narrow"); | ||
| 196 | seed(&harness); | ||
| 197 | |||
| 198 | for path in pages(harness.repo_name()) { | ||
| 199 | let page = harness.get_ok(&path); | ||
| 200 | let (scroll_width, inner_width) = measure(&browser, &page.body, NARROW_WIDTH); | ||
| 201 | assert!( | ||
| 202 | scroll_width <= inner_width, | ||
| 203 | "{path} scrolls the page sideways at {NARROW_WIDTH}px: \ | ||
| 204 | documentElement.scrollWidth = {scroll_width}, window.innerWidth = {inner_width}" | ||
| 205 | ); | ||
| 206 | } | ||
| 207 | } | ||
| 208 | |||
| 209 | /// The invariant that produces the property above, asserted without a browser | ||
| 210 | /// so the guard survives on a machine that has none. | ||
| 211 | /// | ||
| 212 | /// Deliberately weaker than the measurement: it says every table is inside | ||
| 213 | /// something that can scroll on its own, which is *how* the pages fit, not | ||
| 214 | /// *that* they fit. If these two ever disagree, believe the browser. | ||
| 215 | #[test] | ||
| 216 | fn every_table_sits_inside_a_horizontal_scroll_container() { | ||
| 217 | let harness = ServerHarness::new("layout-structure"); | ||
| 218 | seed(&harness); | ||
| 219 | |||
| 220 | let base_css = harness.get_ok(&format!("/{}/patches", harness.repo_name())); | ||
| 221 | assert!( | ||
| 222 | base_css.body.contains(".table-scroll") && base_css.body.contains("overflow-x: auto"), | ||
| 223 | "the stylesheet defines no scrollable table container" | ||
| 224 | ); | ||
| 225 | |||
| 226 | for path in pages(harness.repo_name()) { | ||
| 227 | let page = harness.get_ok(&path); | ||
| 228 | for (index, _) in page.body.match_indices("<table") { | ||
| 229 | let before = &page.body[..index]; | ||
| 230 | let wrapper = before.rfind("table-scroll"); | ||
| 231 | let closing = before.rfind("</div>"); | ||
| 232 | assert!( | ||
| 233 | wrapper.is_some() && wrapper > closing, | ||
| 234 | "{path} has a <table> that is not inside a .table-scroll container" | ||
| 235 | ); | ||
| 236 | } | ||
| 237 | } | ||
| 238 | } | ||
tests/web_rendering_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,423 @@ | |||
| 1 | //! What the web UI actually puts on the page, driven through a live server. | ||
| 2 | //! | ||
| 3 | //! These cover three defects found by reading the rendered pages rather than | ||
| 4 | //! the templates — none of them is a malformed-HTML bug, so every existing | ||
| 5 | //! content assertion passed while all three were live: | ||
| 6 | //! | ||
| 7 | //! * `b2a57996` — timestamps printed as raw RFC3339 with nanoseconds, and a | ||
| 8 | //! `Merged in` oid printed at 40 characters beside revisions printed at 8. | ||
| 9 | //! * `c43c459d` — an empty *filtered* list claiming the repository is empty. | ||
| 10 | //! * `850f3b8e` — a Branch column naming worktree branches that were deleted | ||
| 11 | //! with the worktree. | ||
| 12 | //! | ||
| 13 | //! `--json` is asserted alongside each, because the fix is a *display* policy: | ||
| 14 | //! the HTML gets to be short, and the scripted surface must keep the full id | ||
| 15 | //! and the full timestamp it has always carried. | ||
| 16 | |||
| 17 | mod common; | ||
| 18 | |||
| 19 | use common::ServerHarness; | ||
| 20 | use serde_json::Value; | ||
| 21 | use std::process::Command; | ||
| 22 | |||
| 23 | /// A branch name of the shape agent worktrees produce and then delete. | ||
| 24 | const EPHEMERAL_BRANCH: &str = "worktree-agent-ab8e151c29539ab74"; | ||
| 25 | |||
| 26 | /// The text a reader sees, with every tag — and so every attribute — removed. | ||
| 27 | /// | ||
| 28 | /// The full timestamp is deliberately still in the page, inside `title=`, so a | ||
| 29 | /// substring search over the raw HTML cannot tell the fix from the defect. The | ||
| 30 | /// question is only ever what is rendered *between* the tags. | ||
| 31 | fn visible_text(body: &str) -> String { | ||
| 32 | let mut text = String::with_capacity(body.len()); | ||
| 33 | let mut in_tag = false; | ||
| 34 | for c in body.chars() { | ||
| 35 | match c { | ||
| 36 | '<' => in_tag = true, | ||
| 37 | '>' => in_tag = false, | ||
| 38 | _ if !in_tag => text.push(c), | ||
| 39 | _ => {} | ||
| 40 | } | ||
| 41 | } | ||
| 42 | text | ||
| 43 | } | ||
| 44 | |||
| 45 | /// Whether any visible text is an RFC3339 timestamp with a fractional second — | ||
| 46 | /// the 35-character shape issue `b2a57996` reported. | ||
| 47 | fn has_nanosecond_timestamp(body: &str) -> bool { | ||
| 48 | visible_text(body).split('T').skip(1).any(|rest| { | ||
| 49 | let head: String = rest.chars().take(16).collect(); | ||
| 50 | head.len() >= 10 && head.as_bytes()[2] == b':' && head.contains('.') | ||
| 51 | }) | ||
| 52 | } | ||
| 53 | |||
| 54 | fn patch_on_ephemeral_branch(harness: &ServerHarness, title: &str, branch: &str) -> String { | ||
| 55 | let repo = harness.work_repo(); | ||
| 56 | repo.git(&["checkout", "-b", branch]); | ||
| 57 | repo.commit_file( | ||
| 58 | &format!("{branch}.rs"), | ||
| 59 | "pub fn work() {}\n", | ||
| 60 | &format!("work on {branch}"), | ||
| 61 | ); | ||
| 62 | let out = repo.run_ok(&["patch", "create", "-t", title, "-B", branch]); | ||
| 63 | repo.git(&["checkout", "main"]); | ||
| 64 | out.trim() | ||
| 65 | .strip_prefix("Created patch ") | ||
| 66 | .expect("patch create prints the id") | ||
| 67 | .to_string() | ||
| 68 | } | ||
| 69 | |||
| 70 | fn patch_json(harness: &ServerHarness, id: &str) -> Value { | ||
| 71 | serde_json::from_str(&harness.work_repo().run_ok(&["patch", "show", id, "--json"])).unwrap() | ||
| 72 | } | ||
| 73 | |||
| 74 | fn issue_json(harness: &ServerHarness, id: &str) -> Value { | ||
| 75 | serde_json::from_str(&harness.work_repo().run_ok(&["issue", "show", id, "--json"])).unwrap() | ||
| 76 | } | ||
| 77 | |||
| 78 | // --------------------------------------------------------------------------- | ||
| 79 | // b2a57996 — timestamps and the `Merged in` oid | ||
| 80 | // --------------------------------------------------------------------------- | ||
| 81 | |||
| 82 | #[test] | ||
| 83 | fn list_timestamps_are_short_with_the_full_value_on_hover() { | ||
| 84 | let harness = ServerHarness::new("render-timestamps"); | ||
| 85 | let patch_id = patch_on_ephemeral_branch(&harness, "A patch with a timestamp", "feat/stamped"); | ||
| 86 | let issue_id = harness.work_repo().issue_open("An issue with a timestamp"); | ||
| 87 | harness.push_head(); | ||
| 88 | harness.push_collab_refs(); | ||
| 89 | |||
| 90 | let full_patch_stamp = patch_json(&harness, &patch_id)["last_updated"] | ||
| 91 | .as_str() | ||
| 92 | .unwrap() | ||
| 93 | .to_string(); | ||
| 94 | let full_issue_stamp = issue_json(&harness, &issue_id)["last_updated"] | ||
| 95 | .as_str() | ||
| 96 | .unwrap() | ||
| 97 | .to_string(); | ||
| 98 | |||
| 99 | for (path, full) in [ | ||
| 100 | ( | ||
| 101 | format!("/{}/patches", harness.repo_name()), | ||
| 102 | &full_patch_stamp, | ||
| 103 | ), | ||
| 104 | (format!("/{}/issues", harness.repo_name()), &full_issue_stamp), | ||
| 105 | ] { | ||
| 106 | let body = harness.get_ok(&path).body; | ||
| 107 | assert!( | ||
| 108 | !has_nanosecond_timestamp(&body), | ||
| 109 | "{path} still renders a nanosecond timestamp:\n{body}" | ||
| 110 | ); | ||
| 111 | assert!( | ||
| 112 | body.contains(&format!("title=\"{full}\"")), | ||
| 113 | "{path} drops the full timestamp instead of keeping it on hover" | ||
| 114 | ); | ||
| 115 | // The short rendering: date and minute, no seconds, no nanoseconds. | ||
| 116 | let short = &full[..16].replace('T', " "); | ||
| 117 | assert!( | ||
| 118 | body.contains(&format!(">{short}<")), | ||
| 119 | "{path} does not render the short timestamp '{short}'" | ||
| 120 | ); | ||
| 121 | } | ||
| 122 | } | ||
| 123 | |||
| 124 | #[test] | ||
| 125 | fn patch_detail_timestamps_are_short_with_the_full_value_on_hover() { | ||
| 126 | let harness = ServerHarness::new("render-detail-stamps"); | ||
| 127 | let patch_id = patch_on_ephemeral_branch(&harness, "Reviewed patch", "feat/reviewed"); | ||
| 128 | harness | ||
| 129 | .work_repo() | ||
| 130 | .run_ok(&["patch", "review", &patch_id, "-v", "approve", "-b", "Looks ok"]); | ||
| 131 | harness | ||
| 132 | .work_repo() | ||
| 133 | .run_ok(&["patch", "comment", &patch_id, "-b", "A thread comment"]); | ||
| 134 | harness.push_head(); | ||
| 135 | harness.push_collab_refs(); | ||
| 136 | |||
| 137 | let body = harness | ||
| 138 | .get_ok(&format!( | ||
| 139 | "/{}/patches/{}", | ||
| 140 | harness.repo_name(), | ||
| 141 | patch_id | ||
| 142 | )) | ||
| 143 | .body; | ||
| 144 | assert!( | ||
| 145 | !has_nanosecond_timestamp(&body), | ||
| 146 | "the patch detail page still renders nanosecond timestamps:\n{body}" | ||
| 147 | ); | ||
| 148 | |||
| 149 | let json = patch_json(&harness, &patch_id); | ||
| 150 | let revision_stamp = json["revisions"][0]["timestamp"].as_str().unwrap(); | ||
| 151 | assert!( | ||
| 152 | body.contains(&format!("title=\"{revision_stamp}\"")), | ||
| 153 | "the revision timestamp is not available on hover" | ||
| 154 | ); | ||
| 155 | } | ||
| 156 | |||
| 157 | #[test] | ||
| 158 | fn merged_in_is_abbreviated_like_the_revision_commits_beside_it() { | ||
| 159 | let harness = ServerHarness::new("render-merged-in"); | ||
| 160 | let patch_id = patch_on_ephemeral_branch(&harness, "A landed patch", "feat/landed"); | ||
| 161 | let repo = harness.work_repo(); | ||
| 162 | repo.git(&["merge", "--ff-only", "feat/landed"]); | ||
| 163 | repo.run_ok(&["patch", "merge", &patch_id]); | ||
| 164 | harness.push_head(); | ||
| 165 | harness.push_collab_refs(); | ||
| 166 | |||
| 167 | let merge_commit = patch_json(&harness, &patch_id)["merge_commit"] | ||
| 168 | .as_str() | ||
| 169 | .unwrap() | ||
| 170 | .to_string(); | ||
| 171 | assert_eq!(merge_commit.len(), 40, "the event records the full oid"); | ||
| 172 | |||
| 173 | let body = harness | ||
| 174 | .get_ok(&format!( | ||
| 175 | "/{}/patches/{}", | ||
| 176 | harness.repo_name(), | ||
| 177 | patch_id | ||
| 178 | )) | ||
| 179 | .body; | ||
| 180 | |||
| 181 | // The link still goes to the full oid; only the text is abbreviated. | ||
| 182 | assert!( | ||
| 183 | body.contains(&format!("/diff/{merge_commit}")), | ||
| 184 | "the merge link no longer resolves the full commit" | ||
| 185 | ); | ||
| 186 | assert!( | ||
| 187 | body.contains(&format!(">{}<", &merge_commit[..8])), | ||
| 188 | "'Merged in' does not render the 8-character oid the revisions use" | ||
| 189 | ); | ||
| 190 | assert!( | ||
| 191 | !body.contains(&format!(">{merge_commit}<")), | ||
| 192 | "'Merged in' still renders the full 40-character oid" | ||
| 193 | ); | ||
| 194 | } | ||
| 195 | |||
| 196 | // --------------------------------------------------------------------------- | ||
| 197 | // c43c459d — an empty filtered list must not claim the repository is empty | ||
| 198 | // --------------------------------------------------------------------------- | ||
| 199 | |||
| 200 | #[test] | ||
| 201 | fn an_empty_open_list_names_the_filter_and_offers_the_way_out() { | ||
| 202 | let harness = ServerHarness::new("render-empty-filtered"); | ||
| 203 | let patch_id = patch_on_ephemeral_branch(&harness, "A closed patch", "feat/closed"); | ||
| 204 | harness.work_repo().patch_close(&patch_id); | ||
| 205 | let issue_id = harness.work_repo().issue_open("A closed issue"); | ||
| 206 | harness.work_repo().issue_close(&issue_id); | ||
| 207 | harness.push_head(); | ||
| 208 | harness.push_collab_refs(); | ||
| 209 | |||
| 210 | let patches = harness | ||
| 211 | .get_ok(&format!("/{}/patches", harness.repo_name())) | ||
| 212 | .body; | ||
| 213 | assert!( | ||
| 214 | !patches.contains("No patches."), | ||
| 215 | "the patch list still claims the repository has no patches" | ||
| 216 | ); | ||
| 217 | assert!( | ||
| 218 | patches.contains("No open patches"), | ||
| 219 | "the patch list does not say which filter was empty:\n{patches}" | ||
| 220 | ); | ||
| 221 | assert!( | ||
| 222 | patches.contains("1 total"), | ||
| 223 | "the patch list does not say how many patches there are:\n{patches}" | ||
| 224 | ); | ||
| 225 | assert!( | ||
| 226 | patches.contains("patches?filter=all"), | ||
| 227 | "the patch list offers no way to see the rest" | ||
| 228 | ); | ||
| 229 | |||
| 230 | let issues = harness | ||
| 231 | .get_ok(&format!("/{}/issues", harness.repo_name())) | ||
| 232 | .body; | ||
| 233 | assert!( | ||
| 234 | !issues.contains("No issues."), | ||
| 235 | "the issue list still claims the repository has no issues" | ||
| 236 | ); | ||
| 237 | assert!( | ||
| 238 | issues.contains("No open issues") && issues.contains("1 total"), | ||
| 239 | "the issue list does not say which filter was empty:\n{issues}" | ||
| 240 | ); | ||
| 241 | assert!( | ||
| 242 | issues.contains("issues?filter=all"), | ||
| 243 | "the issue list offers no way to see the rest" | ||
| 244 | ); | ||
| 245 | } | ||
| 246 | |||
| 247 | #[test] | ||
| 248 | fn a_genuinely_empty_repository_still_says_so_plainly() { | ||
| 249 | let harness = ServerHarness::new("render-empty-repo"); | ||
| 250 | harness.push_head(); | ||
| 251 | |||
| 252 | let patches = harness | ||
| 253 | .get_ok(&format!("/{}/patches", harness.repo_name())) | ||
| 254 | .body; | ||
| 255 | assert!( | ||
| 256 | patches.contains("No patches yet."), | ||
| 257 | "an empty repository should say so without offering a filter:\n{patches}" | ||
| 258 | ); | ||
| 259 | assert!( | ||
| 260 | !patches.contains("total"), | ||
| 261 | "an empty repository should not offer to show zero more patches" | ||
| 262 | ); | ||
| 263 | |||
| 264 | let issues = harness | ||
| 265 | .get_ok(&format!("/{}/issues", harness.repo_name())) | ||
| 266 | .body; | ||
| 267 | assert!( | ||
| 268 | issues.contains("No issues yet."), | ||
| 269 | "an empty repository should say so without offering a filter:\n{issues}" | ||
| 270 | ); | ||
| 271 | } | ||
| 272 | |||
| 273 | // --------------------------------------------------------------------------- | ||
| 274 | // 850f3b8e — the Branch column | ||
| 275 | // --------------------------------------------------------------------------- | ||
| 276 | |||
| 277 | #[test] | ||
| 278 | fn the_patch_list_shows_the_base_ref_not_the_ephemeral_branch() { | ||
| 279 | let harness = ServerHarness::new("render-branch-column"); | ||
| 280 | patch_on_ephemeral_branch(&harness, "Work from a worktree", EPHEMERAL_BRANCH); | ||
| 281 | harness.push_head(); | ||
| 282 | harness.push_collab_refs(); | ||
| 283 | |||
| 284 | let body = harness | ||
| 285 | .get_ok(&format!("/{}/patches?filter=all", harness.repo_name())) | ||
| 286 | .body; | ||
| 287 | assert!( | ||
| 288 | !body.contains(EPHEMERAL_BRANCH), | ||
| 289 | "the patch list still names a branch that no longer exists:\n{body}" | ||
| 290 | ); | ||
| 291 | assert!( | ||
| 292 | body.contains("<th>Base</th>"), | ||
| 293 | "the patch list has no Base column:\n{body}" | ||
| 294 | ); | ||
| 295 | assert!( | ||
| 296 | body.contains(">main<"), | ||
| 297 | "the patch list does not say what the patch targets:\n{body}" | ||
| 298 | ); | ||
| 299 | } | ||
| 300 | |||
| 301 | /// Provenance still belongs somewhere — `patch show` is where, on both | ||
| 302 | /// surfaces. Dropping it from the list is not dropping it from the tool. | ||
| 303 | #[test] | ||
| 304 | fn the_patch_detail_page_still_records_the_branch_it_came_from() { | ||
| 305 | let harness = ServerHarness::new("render-branch-detail"); | ||
| 306 | let patch_id = patch_on_ephemeral_branch(&harness, "Work from a worktree", EPHEMERAL_BRANCH); | ||
| 307 | harness.push_head(); | ||
| 308 | harness.push_collab_refs(); | ||
| 309 | |||
| 310 | let body = harness | ||
| 311 | .get_ok(&format!( | ||
| 312 | "/{}/patches/{}", | ||
| 313 | harness.repo_name(), | ||
| 314 | patch_id | ||
| 315 | )) | ||
| 316 | .body; | ||
| 317 | assert!( | ||
| 318 | body.contains(EPHEMERAL_BRANCH), | ||
| 319 | "the detail page lost the branch the patch came from" | ||
| 320 | ); | ||
| 321 | } | ||
| 322 | |||
| 323 | #[test] | ||
| 324 | fn the_cli_patch_list_agrees_with_the_web_list_about_the_base_ref() { | ||
| 325 | let harness = ServerHarness::new("render-cli-agreement"); | ||
| 326 | patch_on_ephemeral_branch(&harness, "Work from a worktree", EPHEMERAL_BRANCH); | ||
| 327 | |||
| 328 | let listing = harness.work_repo().run_ok(&["patch", "list"]); | ||
| 329 | assert!( | ||
| 330 | !listing.contains(EPHEMERAL_BRANCH), | ||
| 331 | "`patch list` names a branch that no longer exists:\n{listing}" | ||
| 332 | ); | ||
| 333 | assert!( | ||
| 334 | listing.contains("main"), | ||
| 335 | "`patch list` does not say what the patch targets:\n{listing}" | ||
| 336 | ); | ||
| 337 | } | ||
| 338 | |||
| 339 | // --------------------------------------------------------------------------- | ||
| 340 | // The scripted surface is untouched by all of the above | ||
| 341 | // --------------------------------------------------------------------------- | ||
| 342 | |||
| 343 | #[test] | ||
| 344 | fn json_keeps_full_ids_timestamps_and_the_branch_the_html_no_longer_shows() { | ||
| 345 | let harness = ServerHarness::new("render-json-intact"); | ||
| 346 | let patch_id = patch_on_ephemeral_branch(&harness, "Work from a worktree", EPHEMERAL_BRANCH); | ||
| 347 | |||
| 348 | let listing: Value = | ||
| 349 | serde_json::from_str(&harness.work_repo().run_ok(&["patch", "list", "--json"])).unwrap(); | ||
| 350 | let entry = &listing.as_array().unwrap()[0]; | ||
| 351 | |||
| 352 | assert_eq!( | ||
| 353 | entry["id"].as_str().unwrap().len(), | ||
| 354 | 40, | ||
| 355 | "--json abbreviated an id" | ||
| 356 | ); | ||
| 357 | assert_eq!( | ||
| 358 | entry["branch"].as_str().unwrap(), | ||
| 359 | EPHEMERAL_BRANCH, | ||
| 360 | "--json dropped the branch, which is provenance a script may still want" | ||
| 361 | ); | ||
| 362 | let stamp = entry["last_updated"].as_str().unwrap(); | ||
| 363 | assert!( | ||
| 364 | stamp.contains('.') && stamp.len() > 25, | ||
| 365 | "--json truncated a timestamp to the display format: {stamp}" | ||
| 366 | ); | ||
| 367 | |||
| 368 | let detail = patch_json(&harness, &patch_id); | ||
| 369 | assert_eq!(detail["id"].as_str().unwrap().len(), 40); | ||
| 370 | assert!(detail["revisions"][0]["timestamp"] | ||
| 371 | .as_str() | ||
| 372 | .unwrap() | ||
| 373 | .contains('.')); | ||
| 374 | } | ||
| 375 | |||
| 376 | // --------------------------------------------------------------------------- | ||
| 377 | // Rendering a page is a read | ||
| 378 | // --------------------------------------------------------------------------- | ||
| 379 | |||
| 380 | /// Every ref in the served repository, as `<name> <oid>` lines. | ||
| 381 | fn ref_snapshot(harness: &ServerHarness) -> String { | ||
| 382 | let bare = harness | ||
| 383 | .repos_dir() | ||
| 384 | .join(format!("{}.git", harness.repo_name())); | ||
| 385 | let output = Command::new("git") | ||
| 386 | .args(["for-each-ref", "--format=%(refname) %(objectname)"]) | ||
| 387 | .current_dir(&bare) | ||
| 388 | .output() | ||
| 389 | .expect("failed to list refs"); | ||
| 390 | assert!(output.status.success()); | ||
| 391 | String::from_utf8(output.stdout).unwrap() | ||
| 392 | } | ||
| 393 | |||
| 394 | #[test] | ||
| 395 | fn rendering_pages_moves_no_refs_and_appends_no_events() { | ||
| 396 | let harness = ServerHarness::new("render-read-only"); | ||
| 397 | let patch_id = patch_on_ephemeral_branch(&harness, "Work from a worktree", EPHEMERAL_BRANCH); | ||
| 398 | let issue_id = harness.work_repo().issue_open("An issue to read"); | ||
| 399 | harness.push_head(); | ||
| 400 | harness.push_collab_refs(); | ||
| 401 | |||
| 402 | let before = ref_snapshot(&harness); | ||
| 403 | let name = harness.repo_name(); | ||
| 404 | for path in [ | ||
| 405 | format!("/{name}"), | ||
| 406 | format!("/{name}/patches"), | ||
| 407 | format!("/{name}/patches?filter=all"), | ||
| 408 | format!("/{name}/patches/{patch_id}"), | ||
| 409 | format!("/{name}/issues"), | ||
| 410 | format!("/{name}/issues?filter=all"), | ||
| 411 | format!("/{name}/issues/{issue_id}"), | ||
| 412 | format!("/{name}/commits"), | ||
| 413 | format!("/{name}/releases"), | ||
| 414 | ] { | ||
| 415 | harness.get_ok(&path); | ||
| 416 | } | ||
| 417 | let after = ref_snapshot(&harness); | ||
| 418 | |||
| 419 | assert_eq!( | ||
| 420 | before, after, | ||
| 421 | "rendering pages changed the repository's refs" | ||
| 422 | ); | ||
| 423 | } | ||