8213031c
Let every surface print the same id
a73x 2026-08-11 10:12
Commit message
src/patch.rs
| Old | New | ||
|---|---|---|---|
| @@ -1374,7 +1374,14 @@ pub fn checkout(repo: &Repository, id_prefix: &str) -> Result<(), crate::error:: | |||
| 1374 | .find_commit(commit_oid) | 1374 | .find_commit(commit_oid) |
| 1375 | .map_err(|e| Error::Cmd(format!("commit {} not found: {}", &latest_rev.commit, e)))?; | 1375 | .map_err(|e| Error::Cmd(format!("commit {} not found: {}", &latest_rev.commit, e)))?; |
| 1376 | 1376 | ||
| 1377 | let short_id = &id[..std::cmp::min(8, id.len())]; | 1377 | // The branch name is a displayed id — the one that outlives the command |
| 1378 | // by construction, since the branch is left behind — so it is abbreviated | ||
| 1379 | // by the same policy as everything else printed. A hard-coded eight would | ||
| 1380 | // give two patches sharing eight characters the same candidate name, and | ||
| 1381 | // the suffix loop below would resolve that to `collab/<id>-1`, which reads | ||
| 1382 | // as a second checkout of one patch rather than a checkout of another. | ||
| 1383 | let abbrev = crate::abbrev::for_patches(repo); | ||
| 1384 | let short_id = abbrev.of(&id); | ||
| 1378 | // Reusing a branch that already stands at the wanted commit is what keeps a | 1385 | // Reusing a branch that already stands at the wanted commit is what keeps a |
| 1379 | // review session from piling up `collab/<id>-1`, `-2`, `-3`. A branch of the | 1386 | // review session from piling up `collab/<id>-1`, `-2`, `-3`. A branch of the |
| 1380 | // same name pointing somewhere else is someone's work, so that one is left | 1387 | // same name pointing somewhere else is someone's work, so that one is left |
src/server/http/repo/issues.rs
| Old | New | ||
|---|---|---|---|
| @@ -117,11 +117,18 @@ pub async fn issues( | |||
| 117 | } | 117 | } |
| 118 | }; | 118 | }; |
| 119 | 119 | ||
| 120 | // Built from every issue id in the repository, not from the rows above. | ||
| 121 | // The two are deliberately different queries: an id that is unique among | ||
| 122 | // the issues this filter happens to show may name two issues in the repo, | ||
| 123 | // and the id printed here is a reference that leaves the page — into a | ||
| 124 | // `Issue:` trailer, a branch name, a script. See `abbrev`. | ||
| 125 | let abbrev = git_collab::abbrev::for_issues(&repo); | ||
| 126 | |||
| 120 | let issues = filtered_issues | 127 | let issues = filtered_issues |
| 121 | .into_iter() | 128 | .into_iter() |
| 122 | .map(|i| { | 129 | .map(|i| { |
| 123 | let id = i.id.clone(); | 130 | let id = i.id.clone(); |
| 124 | let short_id = id[..8.min(id.len())].to_string(); | 131 | let short_id = abbrev.of(&id).to_string(); |
| 125 | IssueListItem { | 132 | IssueListItem { |
| 126 | short_id, | 133 | short_id, |
| 127 | id, | 134 | id, |
src/server/http/repo/overview.rs
| Old | New | ||
|---|---|---|---|
| @@ -11,6 +11,10 @@ use super::{ | |||
| 11 | #[derive(Debug)] | 11 | #[derive(Debug)] |
| 12 | pub struct OverviewPatch { | 12 | pub struct OverviewPatch { |
| 13 | pub id: String, | 13 | pub id: String, |
| 14 | /// Abbreviated here rather than in the template: a slice in the template | ||
| 15 | /// is both a hard-coded width and a panic waiting for an id shorter than | ||
| 16 | /// it, inside a handler where a panic drops the connection. | ||
| 17 | pub short_id: String, | ||
| 14 | pub title: String, | 18 | pub title: String, |
| 15 | pub author: String, | 19 | pub author: String, |
| 16 | } | 20 | } |
| @@ -18,6 +22,7 @@ pub struct OverviewPatch { | |||
| 18 | #[derive(Debug)] | 22 | #[derive(Debug)] |
| 19 | pub struct OverviewIssue { | 23 | pub struct OverviewIssue { |
| 20 | pub id: String, | 24 | pub id: String, |
| 25 | pub short_id: String, | ||
| 21 | pub title: String, | 26 | pub title: String, |
| 22 | pub author: String, | 27 | pub author: String, |
| 23 | } | 28 | } |
| @@ -50,11 +55,19 @@ pub async fn overview( | |||
| 50 | let branch = head_branch_name(&repo); | 55 | let branch = head_branch_name(&repo); |
| 51 | let readme = readme::load_readme(&repo, &repo_name, &branch); | 56 | let readme = readme::load_readme(&repo, &repo_name, &branch); |
| 52 | 57 | ||
| 58 | // The overview is the most heavily filtered view there is — open objects | ||
| 59 | // only — so it is the one most likely to be abbreviated too narrowly if | ||
| 60 | // the width came from its own rows. Both abbreviators are built from every | ||
| 61 | // id of their kind instead. | ||
| 62 | let patch_abbrev = git_collab::abbrev::for_patches(&repo); | ||
| 63 | let issue_abbrev = git_collab::abbrev::for_issues(&repo); | ||
| 64 | |||
| 53 | let patches = git_collab::state::list_patches(&repo) | 65 | let patches = git_collab::state::list_patches(&repo) |
| 54 | .unwrap_or_default() | 66 | .unwrap_or_default() |
| 55 | .into_iter() | 67 | .into_iter() |
| 56 | .filter(|p| p.status == git_collab::state::PatchStatus::Open) | 68 | .filter(|p| p.status == git_collab::state::PatchStatus::Open) |
| 57 | .map(|p| OverviewPatch { | 69 | .map(|p| OverviewPatch { |
| 70 | short_id: patch_abbrev.of(&p.id).to_string(), | ||
| 58 | id: p.id, | 71 | id: p.id, |
| 59 | title: p.title, | 72 | title: p.title, |
| 60 | author: p.author.name, | 73 | author: p.author.name, |
| @@ -66,6 +79,7 @@ pub async fn overview( | |||
| 66 | .into_iter() | 79 | .into_iter() |
| 67 | .filter(|i| i.status == git_collab::state::IssueStatus::Open) | 80 | .filter(|i| i.status == git_collab::state::IssueStatus::Open) |
| 68 | .map(|i| OverviewIssue { | 81 | .map(|i| OverviewIssue { |
| 82 | short_id: issue_abbrev.of(&i.id).to_string(), | ||
| 69 | id: i.id, | 83 | id: i.id, |
| 70 | title: i.title, | 84 | title: i.title, |
| 71 | author: i.author.name, | 85 | author: i.author.name, |
src/server/http/repo/patches.rs
| Old | New | ||
|---|---|---|---|
| @@ -177,11 +177,15 @@ pub async fn patches( | |||
| 177 | } | 177 | } |
| 178 | }; | 178 | }; |
| 179 | 179 | ||
| 180 | // Built from every patch id in the repository, not from the rows above — | ||
| 181 | // see the matching comment in `issues.rs`. | ||
| 182 | let abbrev = git_collab::abbrev::for_patches(&repo); | ||
| 183 | |||
| 180 | let patches = filtered_patches | 184 | let patches = filtered_patches |
| 181 | .into_iter() | 185 | .into_iter() |
| 182 | .map(|p| { | 186 | .map(|p| { |
| 183 | let id = p.id.clone(); | 187 | let id = p.id.clone(); |
| 184 | let short_id = id[..8.min(id.len())].to_string(); | 188 | let short_id = abbrev.of(&id).to_string(); |
| 185 | PatchListItem { | 189 | PatchListItem { |
| 186 | short_id, | 190 | short_id, |
| 187 | id, | 191 | id, |
src/server/http/templates/repo_overview.html
| Old | New | ||
|---|---|---|---|
| @@ -26,7 +26,7 @@ | |||
| 26 | <tbody> | 26 | <tbody> |
| 27 | {% for patch in patches %} | 27 | {% for patch in patches %} |
| 28 | <tr> | 28 | <tr> |
| 29 | <td class="mono"><a href="/{{ repo_name }}/patches/{{ patch.id }}">{{ patch.id[..8] }}</a></td> | 29 | <td class="mono"><a href="/{{ repo_name }}/patches/{{ patch.id }}">{{ patch.short_id }}</a></td> |
| 30 | <td><a href="/{{ repo_name }}/patches/{{ patch.id }}">{{ patch.title }}</a></td> | 30 | <td><a href="/{{ repo_name }}/patches/{{ patch.id }}">{{ patch.title }}</a></td> |
| 31 | <td style="color: #666;">{{ patch.author }}</td> | 31 | <td style="color: #666;">{{ patch.author }}</td> |
| 32 | </tr> | 32 | </tr> |
| @@ -52,7 +52,7 @@ | |||
| 52 | <tbody> | 52 | <tbody> |
| 53 | {% for issue in issues %} | 53 | {% for issue in issues %} |
| 54 | <tr> | 54 | <tr> |
| 55 | <td class="mono"><a href="/{{ repo_name }}/issues/{{ issue.id }}">{{ issue.id[..8] }}</a></td> | 55 | <td class="mono"><a href="/{{ repo_name }}/issues/{{ issue.id }}">{{ issue.short_id }}</a></td> |
| 56 | <td><a href="/{{ repo_name }}/issues/{{ issue.id }}">{{ issue.title }}</a></td> | 56 | <td><a href="/{{ repo_name }}/issues/{{ issue.id }}">{{ issue.title }}</a></td> |
| 57 | <td style="color: #666;">{{ issue.author }}</td> | 57 | <td style="color: #666;">{{ issue.author }}</td> |
| 58 | </tr> | 58 | </tr> |
src/tui/events.rs
| Old | New | ||
|---|---|---|---|
| @@ -89,7 +89,10 @@ pub(crate) fn run_loop( | |||
| 89 | match issue_mod::open(repo, &title, "", None) { | 89 | match issue_mod::open(repo, &title, "", None) { |
| 90 | Ok(id) => { | 90 | Ok(id) => { |
| 91 | app.reload(repo); | 91 | app.reload(repo); |
| 92 | app.status_msg = Some(format!("Issue created: {:.8}", id)); | 92 | app.status_msg = Some(format!( |
| 93 | "Issue created: {}", | ||
| 94 | app.issue_abbrev.of(&id) | ||
| 95 | )); | ||
| 93 | } | 96 | } |
| 94 | Err(e) => { | 97 | Err(e) => { |
| 95 | app.status_msg = | 98 | app.status_msg = |
| @@ -106,7 +109,10 @@ pub(crate) fn run_loop( | |||
| 106 | match issue_mod::open(repo, &title, &body, None) { | 109 | match issue_mod::open(repo, &title, &body, None) { |
| 107 | Ok(id) => { | 110 | Ok(id) => { |
| 108 | app.reload(repo); | 111 | app.reload(repo); |
| 109 | app.status_msg = Some(format!("Issue created: {:.8}", id)); | 112 | app.status_msg = Some(format!( |
| 113 | "Issue created: {}", | ||
| 114 | app.issue_abbrev.of(&id) | ||
| 115 | )); | ||
| 110 | } | 116 | } |
| 111 | Err(e) => { | 117 | Err(e) => { |
| 112 | app.status_msg = | 118 | app.status_msg = |
src/tui/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -18,8 +18,13 @@ use self::state::App; | |||
| 18 | pub fn run(repo: &Repository) -> Result<(), Error> { | 18 | pub fn run(repo: &Repository) -> Result<(), Error> { |
| 19 | let issues = app_state::list_issues(repo)?; | 19 | let issues = app_state::list_issues(repo)?; |
| 20 | let patches = app_state::list_patches(repo)?; | 20 | let patches = app_state::list_patches(repo)?; |
| 21 | // A separate query from the two above, and that is the point: those list | ||
| 22 | // what the dashboard can display, this counts what the ids have to be | ||
| 23 | // unique against. | ||
| 24 | let issue_abbrev = crate::abbrev::for_issues(repo); | ||
| 25 | let patch_abbrev = crate::abbrev::for_patches(repo); | ||
| 21 | 26 | ||
| 22 | let mut app = App::new(issues, patches); | 27 | let mut app = App::new(issues, patches, issue_abbrev, patch_abbrev); |
| 23 | 28 | ||
| 24 | terminal::enable_raw_mode()?; | 29 | terminal::enable_raw_mode()?; |
| 25 | stdout().execute(EnterAlternateScreen)?; | 30 | stdout().execute(EnterAlternateScreen)?; |
| @@ -37,6 +42,7 @@ pub fn run(repo: &Repository) -> Result<(), Error> { | |||
| 37 | mod tests { | 42 | mod tests { |
| 38 | use super::state::*; | 43 | use super::state::*; |
| 39 | use super::widgets::*; | 44 | use super::widgets::*; |
| 45 | use crate::abbrev::Abbrev; | ||
| 40 | use crate::event::{Action, Author, ReviewVerdict}; | 46 | use crate::event::{Action, Author, ReviewVerdict}; |
| 41 | use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus}; | 47 | use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus}; |
| 42 | use git2::Oid; | 48 | use git2::Oid; |
| @@ -103,7 +109,7 @@ mod tests { | |||
| 103 | make_patch("p2", "Dashboard patch", PatchStatus::Closed), | 109 | make_patch("p2", "Dashboard patch", PatchStatus::Closed), |
| 104 | make_patch("p3", "Merged feature", PatchStatus::Merged), | 110 | make_patch("p3", "Merged feature", PatchStatus::Merged), |
| 105 | ]; | 111 | ]; |
| 106 | App::new(issues, patches) | 112 | App::new(issues, patches, Abbrev::minimal(), Abbrev::minimal()) |
| 107 | } | 113 | } |
| 108 | 114 | ||
| 109 | // T010: visible_issues filters by search_query (case-insensitive) | 115 | // T010: visible_issues filters by search_query (case-insensitive) |
| @@ -292,7 +298,12 @@ mod tests { | |||
| 292 | } | 298 | } |
| 293 | 299 | ||
| 294 | fn make_app(issues: usize, patches: usize) -> App { | 300 | fn make_app(issues: usize, patches: usize) -> App { |
| 295 | App::new(make_test_issues(issues), make_test_patches(patches)) | 301 | App::new( |
| 302 | make_test_issues(issues), | ||
| 303 | make_test_patches(patches), | ||
| 304 | Abbrev::minimal(), | ||
| 305 | Abbrev::minimal(), | ||
| 306 | ) | ||
| 296 | } | 307 | } |
| 297 | 308 | ||
| 298 | fn render_app(app: &mut App) -> Buffer { | 309 | fn render_app(app: &mut App) -> Buffer { |
| @@ -461,7 +472,7 @@ mod tests { | |||
| 461 | }, | 472 | }, |
| 462 | clock: 0, | 473 | clock: 0, |
| 463 | }; | 474 | }; |
| 464 | let detail = format_event_detail(&oid, &event); | 475 | let detail = format_event_detail(&oid, &event, &Abbrev::minimal()); |
| 465 | assert!(detail.contains("aaaaaaa")); | 476 | assert!(detail.contains("aaaaaaa")); |
| 466 | assert!(detail.contains("Test User <test@example.com>")); | 477 | assert!(detail.contains("Test User <test@example.com>")); |
| 467 | assert!(detail.contains("2026-01-01T00:00:00Z")); | 478 | assert!(detail.contains("2026-01-01T00:00:00Z")); |
| @@ -481,7 +492,7 @@ mod tests { | |||
| 481 | }, | 492 | }, |
| 482 | clock: 0, | 493 | clock: 0, |
| 483 | }; | 494 | }; |
| 484 | let detail = format_event_detail(&oid, &event); | 495 | let detail = format_event_detail(&oid, &event, &Abbrev::minimal()); |
| 485 | assert!(detail.contains("Issue Close")); | 496 | assert!(detail.contains("Issue Close")); |
| 486 | assert!(detail.contains("Reason: resolved")); | 497 | assert!(detail.contains("Reason: resolved")); |
| 487 | } | 498 | } |
| @@ -499,7 +510,7 @@ mod tests { | |||
| 499 | }, | 510 | }, |
| 500 | clock: 0, | 511 | clock: 0, |
| 501 | }; | 512 | }; |
| 502 | let detail = format_event_detail(&oid, &event); | 513 | let detail = format_event_detail(&oid, &event, &Abbrev::minimal()); |
| 503 | assert!(detail.contains("Patch Review")); | 514 | assert!(detail.contains("Patch Review")); |
| 504 | assert!(detail.contains("approve")); | 515 | assert!(detail.contains("approve")); |
| 505 | assert!(detail.contains("Looks good!")); | 516 | assert!(detail.contains("Looks good!")); |
| @@ -514,7 +525,7 @@ mod tests { | |||
| 514 | action: Action::IssueReopen, | 525 | action: Action::IssueReopen, |
| 515 | clock: 0, | 526 | clock: 0, |
| 516 | }; | 527 | }; |
| 517 | let detail = format_event_detail(&oid, &event); | 528 | let detail = format_event_detail(&oid, &event, &Abbrev::minimal()); |
| 518 | assert!(detail.contains("1234567")); | 529 | assert!(detail.contains("1234567")); |
| 519 | assert!(detail.contains("Commit: 1234567\n")); | 530 | assert!(detail.contains("Commit: 1234567\n")); |
| 520 | } | 531 | } |
| @@ -903,6 +914,68 @@ mod tests { | |||
| 903 | assert_buffer_contains(&buf, "No matches for current filter."); | 914 | assert_buffer_contains(&buf, "No matches for current filter."); |
| 904 | } | 915 | } |
| 905 | 916 | ||
| 917 | // ── Id width ───────────────────────────────────────────────────────── | ||
| 918 | // | ||
| 919 | // The dashboard's lists are filtered twice over — by status, then by the | ||
| 920 | // search box — and its issue set never contains an archived issue at all. | ||
| 921 | // The width has to come from the abbreviator it was handed, which was | ||
| 922 | // built over every object of the kind, and not from the rows it can see. | ||
| 923 | |||
| 924 | /// An id whose only twin is off-screen must still be printed wide enough | ||
| 925 | /// to tell them apart. This is the failure a width derived from the | ||
| 926 | /// visible rows produces, and it is silent: the list looks right. | ||
| 927 | #[test] | ||
| 928 | fn test_render_widens_an_id_whose_twin_is_not_on_screen() { | ||
| 929 | let on_screen = format!("{:0<40}", "abcdef01aa"); | ||
| 930 | let off_screen = format!("{:0<40}", "abcdef01bb"); | ||
| 931 | |||
| 932 | let mut app = App::new( | ||
| 933 | vec![make_issue( | ||
| 934 | &on_screen, | ||
| 935 | "The only visible issue", | ||
| 936 | IssueStatus::Open, | ||
| 937 | )], | ||
| 938 | vec![], | ||
| 939 | Abbrev::new(vec![on_screen.clone(), off_screen]), | ||
| 940 | Abbrev::minimal(), | ||
| 941 | ); | ||
| 942 | |||
| 943 | let buf = render_app(&mut app); | ||
| 944 | let text = buffer_to_string(&buf); | ||
| 945 | assert!( | ||
| 946 | text.contains(&on_screen[..9]), | ||
| 947 | "expected the id widened past the floor, got:\n{}", | ||
| 948 | text | ||
| 949 | ); | ||
| 950 | assert!( | ||
| 951 | !text.contains(&format!("{} ", &on_screen[..8])), | ||
| 952 | "the eight-character form names two issues:\n{}", | ||
| 953 | text | ||
| 954 | ); | ||
| 955 | } | ||
| 956 | |||
| 957 | /// A configured width reaches the list and the detail pane alike, so the | ||
| 958 | /// id a reader copies is the same one either way. | ||
| 959 | #[test] | ||
| 960 | fn test_render_honours_a_configured_width() { | ||
| 961 | let id = format!("{:0<40}", "abcdef0123456789"); | ||
| 962 | let mut app = App::new( | ||
| 963 | vec![make_issue(&id, "Configured", IssueStatus::Open)], | ||
| 964 | vec![], | ||
| 965 | Abbrev::with_width(vec![id.clone()], Some(14)), | ||
| 966 | Abbrev::minimal(), | ||
| 967 | ); | ||
| 968 | |||
| 969 | let buf = render_app(&mut app); | ||
| 970 | let text = buffer_to_string(&buf); | ||
| 971 | assert!(text.contains(&id[..14]), "list pane:\n{}", text); | ||
| 972 | assert!( | ||
| 973 | text.contains(&format!("Issue {}", &id[..14])), | ||
| 974 | "detail pane:\n{}", | ||
| 975 | text | ||
| 976 | ); | ||
| 977 | } | ||
| 978 | |||
| 906 | #[test] | 979 | #[test] |
| 907 | fn test_render_footer_keys() { | 980 | fn test_render_footer_keys() { |
| 908 | let mut app = make_app(3, 3); | 981 | let mut app = make_app(3, 3); |
src/tui/state.rs
| Old | New | ||
|---|---|---|---|
| @@ -2,6 +2,7 @@ use crossterm::event::{KeyCode, KeyModifiers}; | |||
| 2 | use git2::{Oid, Repository}; | 2 | use git2::{Oid, Repository}; |
| 3 | use ratatui::widgets::ListState; | 3 | use ratatui::widgets::ListState; |
| 4 | 4 | ||
| 5 | use crate::abbrev::Abbrev; | ||
| 5 | use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; | 6 | use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; |
| 6 | 7 | ||
| 7 | #[derive(Debug, PartialEq)] | 8 | #[derive(Debug, PartialEq)] |
| @@ -70,6 +71,18 @@ pub(crate) enum InputMode { | |||
| 70 | pub(crate) struct App { | 71 | pub(crate) struct App { |
| 71 | pub(crate) issues: Vec<IssueState>, | 72 | pub(crate) issues: Vec<IssueState>, |
| 72 | pub(crate) patches: Vec<PatchState>, | 73 | pub(crate) patches: Vec<PatchState>, |
| 74 | /// How wide to print an issue id, and likewise for patches. | ||
| 75 | /// | ||
| 76 | /// Deliberately not derived from `issues` and `patches` above. Those hold | ||
| 77 | /// what the dashboard can show — active objects, further narrowed by the | ||
| 78 | /// status filter and the search box before anything reaches the screen — | ||
| 79 | /// while the width has to come from every object of that kind, archived | ||
| 80 | /// ones included. An id abbreviated against the rows on screen is unique | ||
| 81 | /// only among them, and an id read off this screen is typed back into a | ||
| 82 | /// command, a trailer or a branch name, where "unique among whatever was | ||
| 83 | /// on screen at the time" is not a property anything can rely on. | ||
| 84 | pub(crate) issue_abbrev: Abbrev, | ||
| 85 | pub(crate) patch_abbrev: Abbrev, | ||
| 73 | pub(crate) list_state: ListState, | 86 | pub(crate) list_state: ListState, |
| 74 | pub(crate) patch_list_state: ListState, | 87 | pub(crate) patch_list_state: ListState, |
| 75 | pub(crate) list_mode: ListMode, | 88 | pub(crate) list_mode: ListMode, |
| @@ -93,7 +106,12 @@ pub(crate) struct App { | |||
| 93 | } | 106 | } |
| 94 | 107 | ||
| 95 | impl App { | 108 | impl App { |
| 96 | pub(crate) fn new(issues: Vec<IssueState>, patches: Vec<PatchState>) -> Self { | 109 | pub(crate) fn new( |
| 110 | issues: Vec<IssueState>, | ||
| 111 | patches: Vec<PatchState>, | ||
| 112 | issue_abbrev: Abbrev, | ||
| 113 | patch_abbrev: Abbrev, | ||
| 114 | ) -> Self { | ||
| 97 | let mut list_state = ListState::default(); | 115 | let mut list_state = ListState::default(); |
| 98 | if !issues.is_empty() { | 116 | if !issues.is_empty() { |
| 99 | list_state.select(Some(0)); | 117 | list_state.select(Some(0)); |
| @@ -105,6 +123,8 @@ impl App { | |||
| 105 | Self { | 123 | Self { |
| 106 | issues, | 124 | issues, |
| 107 | patches, | 125 | patches, |
| 126 | issue_abbrev, | ||
| 127 | patch_abbrev, | ||
| 108 | list_state, | 128 | list_state, |
| 109 | patch_list_state, | 129 | patch_list_state, |
| 110 | list_mode: ListMode::Issues, | 130 | list_mode: ListMode::Issues, |
| @@ -444,6 +464,12 @@ impl App { | |||
| 444 | if let Ok(patches) = state::list_patches(repo) { | 464 | if let Ok(patches) = state::list_patches(repo) { |
| 445 | self.patches = patches; | 465 | self.patches = patches; |
| 446 | } | 466 | } |
| 467 | // Refreshing the lists without refreshing the widths would leave the | ||
| 468 | // dashboard printing ids at a width chosen for a repository that no | ||
| 469 | // longer exists — including right after `n` opens an issue, which is | ||
| 470 | // exactly when a new id collides. Both are ref and config reads. | ||
| 471 | self.issue_abbrev = crate::abbrev::for_issues(repo); | ||
| 472 | self.patch_abbrev = crate::abbrev::for_patches(repo); | ||
| 447 | // Clamp issue list selection | 473 | // Clamp issue list selection |
| 448 | let issue_len = self.visible_issues().len(); | 474 | let issue_len = self.visible_issues().len(); |
| 449 | if let Some(sel) = self.list_state.selected() { | 475 | if let Some(sel) = self.list_state.selected() { |
src/tui/widgets.rs
| Old | New | ||
|---|---|---|---|
| @@ -2,6 +2,7 @@ use git2::{Oid, Repository}; | |||
| 2 | use ratatui::prelude::*; | 2 | use ratatui::prelude::*; |
| 3 | use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; | 3 | use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; |
| 4 | 4 | ||
| 5 | use crate::abbrev::Abbrev; | ||
| 5 | use crate::event::{Action, ReviewVerdict}; | 6 | use crate::event::{Action, ReviewVerdict}; |
| 6 | use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus}; | 7 | use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus}; |
| 7 | 8 | ||
| @@ -66,7 +67,17 @@ pub(crate) fn action_type_label(action: &Action) -> &str { | |||
| 66 | } | 67 | } |
| 67 | } | 68 | } |
| 68 | 69 | ||
| 69 | pub(crate) fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> String { | 70 | /// Render one raw event for the commit browser. |
| 71 | /// | ||
| 72 | /// `issue_abbrev` governs the issue ids in the payload. The event's own commit | ||
| 73 | /// oid, and the `target` of an edit or a delete, are git object ids naming an | ||
| 74 | /// event commit rather than collab ids, so they keep git's own short form — | ||
| 75 | /// `collab.abbrev` has nothing to say about them. | ||
| 76 | pub(crate) fn format_event_detail( | ||
| 77 | oid: &Oid, | ||
| 78 | event: &crate::event::Event, | ||
| 79 | issue_abbrev: &Abbrev, | ||
| 80 | ) -> String { | ||
| 70 | let short_oid = &oid.to_string()[..7]; | 81 | let short_oid = &oid.to_string()[..7]; |
| 71 | let action_label = action_type_label(&event.action); | 82 | let action_label = action_type_label(&event.action); |
| 72 | 83 | ||
| @@ -147,10 +158,13 @@ pub(crate) fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> Str | |||
| 147 | detail.push_str(&format!("\nRemoved Label: {}\n", label)); | 158 | detail.push_str(&format!("\nRemoved Label: {}\n", label)); |
| 148 | } | 159 | } |
| 149 | Action::IssueRelate { relates_to } => { | 160 | Action::IssueRelate { relates_to } => { |
| 150 | detail.push_str(&format!("\nRelates To: {:.8}\n", relates_to)); | 161 | detail.push_str(&format!("\nRelates To: {}\n", issue_abbrev.of(relates_to))); |
| 151 | } | 162 | } |
| 152 | Action::IssueUnrelate { relates_to } => { | 163 | Action::IssueUnrelate { relates_to } => { |
| 153 | detail.push_str(&format!("\nRemoved Relation: {:.8}\n", relates_to)); | 164 | detail.push_str(&format!( |
| 165 | "\nRemoved Relation: {}\n", | ||
| 166 | issue_abbrev.of(relates_to) | ||
| 167 | )); | ||
| 154 | } | 168 | } |
| 155 | Action::IssueAssign { assignee } => { | 169 | Action::IssueAssign { assignee } => { |
| 156 | detail.push_str(&format!("\nAssignee: {}\n", assignee)); | 170 | detail.push_str(&format!("\nAssignee: {}\n", assignee)); |
| @@ -216,7 +230,13 @@ fn render_list(frame: &mut Frame, app: &mut App, area: Rect) { | |||
| 216 | IssueStatus::Open => Style::default().fg(Color::Green), | 230 | IssueStatus::Open => Style::default().fg(Color::Green), |
| 217 | IssueStatus::Closed => Style::default().fg(Color::Red), | 231 | IssueStatus::Closed => Style::default().fg(Color::Red), |
| 218 | }; | 232 | }; |
| 219 | ListItem::new(format!("{:.8} {:6} {}", i.id, status, i.title)).style(style) | 233 | ListItem::new(format!( |
| 234 | "{} {:6} {}", | ||
| 235 | app.issue_abbrev.of(&i.id), | ||
| 236 | status, | ||
| 237 | i.title | ||
| 238 | )) | ||
| 239 | .style(style) | ||
| 220 | }) | 240 | }) |
| 221 | .collect(); | 241 | .collect(); |
| 222 | 242 | ||
| @@ -249,7 +269,13 @@ fn render_list(frame: &mut Frame, app: &mut App, area: Rect) { | |||
| 249 | PatchStatus::Closed => Style::default().fg(Color::Red), | 269 | PatchStatus::Closed => Style::default().fg(Color::Red), |
| 250 | PatchStatus::Merged => Style::default().fg(Color::Cyan), | 270 | PatchStatus::Merged => Style::default().fg(Color::Cyan), |
| 251 | }; | 271 | }; |
| 252 | ListItem::new(format!("{:.8} {:6} {}", p.id, status, p.title)).style(style) | 272 | ListItem::new(format!( |
| 273 | "{} {:6} {}", | ||
| 274 | app.patch_abbrev.of(&p.id), | ||
| 275 | status, | ||
| 276 | p.title | ||
| 277 | )) | ||
| 278 | .style(style) | ||
| 253 | }) | 279 | }) |
| 254 | .collect(); | 280 | .collect(); |
| 255 | 281 | ||
| @@ -331,7 +357,7 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect, repo: Option<&Rep | |||
| 331 | if app.mode == ViewMode::CommitDetail { | 357 | if app.mode == ViewMode::CommitDetail { |
| 332 | let content = if let Some(idx) = app.event_list_state.selected() { | 358 | let content = if let Some(idx) = app.event_list_state.selected() { |
| 333 | if let Some((oid, evt)) = app.event_history.get(idx) { | 359 | if let Some((oid, evt)) = app.event_history.get(idx) { |
| 334 | format_event_detail(oid, evt) | 360 | format_event_detail(oid, evt, &app.issue_abbrev) |
| 335 | } else { | 361 | } else { |
| 336 | "No event selected.".to_string() | 362 | "No event selected.".to_string() |
| 337 | } | 363 | } |
| @@ -358,7 +384,13 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect, repo: Option<&Rep | |||
| 358 | let visible = app.visible_issues(); | 384 | let visible = app.visible_issues(); |
| 359 | let selected_idx = app.list_state.selected().unwrap_or(0); | 385 | let selected_idx = app.list_state.selected().unwrap_or(0); |
| 360 | let content: Text = match visible.get(selected_idx) { | 386 | let content: Text = match visible.get(selected_idx) { |
| 361 | Some(issue) => build_issue_detail(issue, &app.patches, repo), | 387 | Some(issue) => build_issue_detail( |
| 388 | issue, | ||
| 389 | &app.patches, | ||
| 390 | repo, | ||
| 391 | &app.issue_abbrev, | ||
| 392 | &app.patch_abbrev, | ||
| 393 | ), | ||
| 362 | None => Text::raw("No matches for current filter."), | 394 | None => Text::raw("No matches for current filter."), |
| 363 | }; | 395 | }; |
| 364 | 396 | ||
| @@ -378,7 +410,7 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect, repo: Option<&Rep | |||
| 378 | let visible = app.visible_patches(); | 410 | let visible = app.visible_patches(); |
| 379 | let selected_idx = app.patch_list_state.selected().unwrap_or(0); | 411 | let selected_idx = app.patch_list_state.selected().unwrap_or(0); |
| 380 | let content: Text = match visible.get(selected_idx) { | 412 | let content: Text = match visible.get(selected_idx) { |
| 381 | Some(patch) => build_patch_summary(patch), | 413 | Some(patch) => build_patch_summary(patch, &app.issue_abbrev, &app.patch_abbrev), |
| 382 | None => Text::raw("No patches for current filter."), | 414 | None => Text::raw("No patches for current filter."), |
| 383 | }; | 415 | }; |
| 384 | 416 | ||
| @@ -401,6 +433,8 @@ fn build_issue_detail( | |||
| 401 | issue: &IssueState, | 433 | issue: &IssueState, |
| 402 | patches: &[PatchState], | 434 | patches: &[PatchState], |
| 403 | repo: Option<&Repository>, | 435 | repo: Option<&Repository>, |
| 436 | issue_abbrev: &Abbrev, | ||
| 437 | patch_abbrev: &Abbrev, | ||
| 404 | ) -> Text<'static> { | 438 | ) -> Text<'static> { |
| 405 | let status = issue.status.as_str(); | 439 | let status = issue.status.as_str(); |
| 406 | 440 | ||
| @@ -408,7 +442,7 @@ fn build_issue_detail( | |||
| 408 | Line::from(vec![ | 442 | Line::from(vec![ |
| 409 | Span::styled("Issue ", Style::default().add_modifier(Modifier::BOLD)), | 443 | Span::styled("Issue ", Style::default().add_modifier(Modifier::BOLD)), |
| 410 | Span::styled( | 444 | Span::styled( |
| 411 | format!("{:.8}", issue.id), | 445 | issue_abbrev.of(&issue.id).to_string(), |
| 412 | Style::default() | 446 | Style::default() |
| 413 | .fg(Color::Yellow) | 447 | .fg(Color::Yellow) |
| 414 | .add_modifier(Modifier::BOLD), | 448 | .add_modifier(Modifier::BOLD), |
| @@ -477,7 +511,10 @@ fn build_issue_detail( | |||
| 477 | PatchStatus::Merged => ("merged", Color::Cyan), | 511 | PatchStatus::Merged => ("merged", Color::Cyan), |
| 478 | }; | 512 | }; |
| 479 | lines.push(Line::from(vec![ | 513 | lines.push(Line::from(vec![ |
| 480 | Span::styled(format!("{:.8}", p.id), Style::default().fg(Color::Yellow)), | 514 | Span::styled( |
| 515 | patch_abbrev.of(&p.id).to_string(), | ||
| 516 | Style::default().fg(Color::Yellow), | ||
| 517 | ), | ||
| 481 | Span::raw(" "), | 518 | Span::raw(" "), |
| 482 | Span::styled(status.0, Style::default().fg(status.1)), | 519 | Span::styled(status.0, Style::default().fg(status.1)), |
| 483 | Span::raw(format!(" {}", p.title)), | 520 | Span::raw(format!(" {}", p.title)), |
| @@ -562,7 +599,11 @@ fn build_issue_detail( | |||
| 562 | Text::from(lines) | 599 | Text::from(lines) |
| 563 | } | 600 | } |
| 564 | 601 | ||
| 565 | fn build_patch_summary(patch: &PatchState) -> Text<'static> { | 602 | fn build_patch_summary( |
| 603 | patch: &PatchState, | ||
| 604 | issue_abbrev: &Abbrev, | ||
| 605 | patch_abbrev: &Abbrev, | ||
| 606 | ) -> Text<'static> { | ||
| 566 | let status_str = patch.status.as_str(); | 607 | let status_str = patch.status.as_str(); |
| 567 | let status_color = match patch.status { | 608 | let status_color = match patch.status { |
| 568 | PatchStatus::Open => Color::Green, | 609 | PatchStatus::Open => Color::Green, |
| @@ -574,7 +615,7 @@ fn build_patch_summary(patch: &PatchState) -> Text<'static> { | |||
| 574 | Line::from(vec![ | 615 | Line::from(vec![ |
| 575 | Span::styled("Patch ", Style::default().add_modifier(Modifier::BOLD)), | 616 | Span::styled("Patch ", Style::default().add_modifier(Modifier::BOLD)), |
| 576 | Span::styled( | 617 | Span::styled( |
| 577 | format!("{:.8}", patch.id), | 618 | patch_abbrev.of(&patch.id).to_string(), |
| 578 | Style::default() | 619 | Style::default() |
| 579 | .fg(Color::Yellow) | 620 | .fg(Color::Yellow) |
| 580 | .add_modifier(Modifier::BOLD), | 621 | .add_modifier(Modifier::BOLD), |
| @@ -607,7 +648,8 @@ fn build_patch_summary(patch: &PatchState) -> Text<'static> { | |||
| 607 | if let Some(ref fixes) = patch.fixes { | 648 | if let Some(ref fixes) = patch.fixes { |
| 608 | lines.push(Line::from(vec![ | 649 | lines.push(Line::from(vec![ |
| 609 | Span::styled("Fixes: ", Style::default().fg(Color::DarkGray)), | 650 | Span::styled("Fixes: ", Style::default().fg(Color::DarkGray)), |
| 610 | Span::raw(format!("{:.8}", fixes)), | 651 | // `fixes` names an issue, so it is abbreviated against issues. |
| 652 | Span::raw(issue_abbrev.of(fixes).to_string()), | ||
| 611 | ])); | 653 | ])); |
| 612 | } | 654 | } |
| 613 | 655 | ||
| @@ -644,7 +686,7 @@ fn build_patch_detail_text(app: &App) -> Text<'static> { | |||
| 644 | Line::from(vec![ | 686 | Line::from(vec![ |
| 645 | Span::styled("Patch ", Style::default().add_modifier(Modifier::BOLD)), | 687 | Span::styled("Patch ", Style::default().add_modifier(Modifier::BOLD)), |
| 646 | Span::styled( | 688 | Span::styled( |
| 647 | format!("{:.8}", patch.id), | 689 | app.patch_abbrev.of(&patch.id).to_string(), |
| 648 | Style::default() | 690 | Style::default() |
| 649 | .fg(Color::Yellow) | 691 | .fg(Color::Yellow) |
| 650 | .add_modifier(Modifier::BOLD), | 692 | .add_modifier(Modifier::BOLD), |
| @@ -684,7 +726,7 @@ fn build_patch_detail_text(app: &App) -> Text<'static> { | |||
| 684 | if let Some(ref fixes) = patch.fixes { | 726 | if let Some(ref fixes) = patch.fixes { |
| 685 | lines.push(Line::from(vec![ | 727 | lines.push(Line::from(vec![ |
| 686 | Span::styled("Fixes: ", Style::default().fg(Color::DarkGray)), | 728 | Span::styled("Fixes: ", Style::default().fg(Color::DarkGray)), |
| 687 | Span::raw(format!("{:.8}", fixes)), | 729 | Span::raw(app.issue_abbrev.of(fixes).to_string()), |
| 688 | ])); | 730 | ])); |
| 689 | } | 731 | } |
| 690 | 732 | ||
tests/abbrev_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -4,10 +4,15 @@ | |||
| 4 | //! the two promises the CLI makes about it: an id it prints can always be | 4 | //! the two promises the CLI makes about it: an id it prints can always be |
| 5 | //! typed straight back in, and a prefix that names more than one object is | 5 | //! typed straight back in, and a prefix that names more than one object is |
| 6 | //! refused rather than resolved. | 6 | //! refused rather than resolved. |
| 7 | //! | ||
| 8 | //! The `web ui` and `dashboard` sections at the bottom hold the same promises | ||
| 9 | //! to the other two surfaces, which have their own way of getting them wrong: | ||
| 10 | //! both render a *filtered* subset, and a width computed from what is on | ||
| 11 | //! screen is not the width the policy asks for. | ||
| 7 | 12 | ||
| 8 | mod common; | 13 | mod common; |
| 9 | 14 | ||
| 10 | use common::TestRepo; | 15 | use common::{ServerHarness, TestRepo}; |
| 11 | 16 | ||
| 12 | /// Pull the leading id column out of a `list` line. | 17 | /// Pull the leading id column out of a `list` line. |
| 13 | fn first_column(line: &str) -> &str { | 18 | fn first_column(line: &str) -> &str { |
| @@ -357,3 +362,466 @@ fn the_id_set_covers_archived_patches() { | |||
| 357 | all | 362 | all |
| 358 | ); | 363 | ); |
| 359 | } | 364 | } |
| 365 | |||
| 366 | // --------------------------------------------------------------------------- | ||
| 367 | // The other two surfaces | ||
| 368 | // | ||
| 369 | // The web UI and the dashboard both render a *filtered* subset — open issues, | ||
| 370 | // merged patches, whatever the current view asks for. The policy is computed | ||
| 371 | // over every object of a kind, so a filtered view has to be abbreviated at a | ||
| 372 | // width it cannot derive from its own rows. These tests are written to fail | ||
| 373 | // against a surface that abbreviates over what it is showing. | ||
| 374 | // --------------------------------------------------------------------------- | ||
| 375 | |||
| 376 | /// Give `real_id` a twin that shares its first eight characters. | ||
| 377 | /// | ||
| 378 | /// Eight is the floor width, so two ids only collide there in a repository far | ||
| 379 | /// too large to build in a test. Planting the collision is the only way to see | ||
| 380 | /// the widening at all, and an *archived* twin is the honest place to plant | ||
| 381 | /// it: archived objects are in the id set the policy is computed over and in | ||
| 382 | /// none of the views that list objects. A surface that abbreviates over the | ||
| 383 | /// rows it is showing prints eight characters here, and is wrong. | ||
| 384 | /// | ||
| 385 | /// Returns the twin's id. The real id must now be printed nine characters | ||
| 386 | /// wide: identical through the floor width, distinct one character later. | ||
| 387 | fn plant_archived_issue_twin(git: &git2::Repository, real_id: &str) -> String { | ||
| 388 | let mut chars: Vec<char> = real_id.chars().collect(); | ||
| 389 | chars[8] = if chars[8] == '0' { '1' } else { '0' }; | ||
| 390 | let twin: String = chars.into_iter().collect(); | ||
| 391 | let target = git | ||
| 392 | .refname_to_id(&format!("refs/collab/issues/{}", real_id)) | ||
| 393 | .expect("the real issue ref should exist"); | ||
| 394 | git.reference( | ||
| 395 | &format!("refs/collab/archive/issues/{}", twin), | ||
| 396 | target, | ||
| 397 | false, | ||
| 398 | "planted twin", | ||
| 399 | ) | ||
| 400 | .unwrap(); | ||
| 401 | twin | ||
| 402 | } | ||
| 403 | |||
| 404 | /// The full 40-character id behind whatever prefix a command printed. | ||
| 405 | fn full_issue_id(git: &git2::Repository, prefix: &str) -> String { | ||
| 406 | git_collab::state::resolve_issue_ref(git, prefix).unwrap().1 | ||
| 407 | } | ||
| 408 | |||
| 409 | // --------------------------------------------------------------------------- | ||
| 410 | // web ui | ||
| 411 | // --------------------------------------------------------------------------- | ||
| 412 | |||
| 413 | fn set_server_abbrev(harness: &ServerHarness, value: &str) { | ||
| 414 | let repo_dir = harness | ||
| 415 | .repos_dir() | ||
| 416 | .join(format!("{}.git", harness.repo_name())); | ||
| 417 | common::git_cmd(&repo_dir, &["config", "collab.abbrev", value]); | ||
| 418 | } | ||
| 419 | |||
| 420 | /// The text of the `<a>` whose href ends in `href_tail`. The list templates | ||
| 421 | /// link the row's id cell to the object's own page, so this is exactly the | ||
| 422 | /// string the page offers a reader to copy. | ||
| 423 | fn anchor_text(body: &str, href_tail: &str) -> String { | ||
| 424 | let needle = format!("{}\">", href_tail); | ||
| 425 | let start = body | ||
| 426 | .find(&needle) | ||
| 427 | .unwrap_or_else(|| panic!("no link ending in '{}' in:\n{}", href_tail, body)) | ||
| 428 | + needle.len(); | ||
| 429 | let end = body[start..] | ||
| 430 | .find("</a>") | ||
| 431 | .unwrap_or_else(|| panic!("unterminated link for '{}'", href_tail)) | ||
| 432 | + start; | ||
| 433 | body[start..end].to_string() | ||
| 434 | } | ||
| 435 | |||
| 436 | /// The test the naive fix fails. The issue list defaults to open issues only; | ||
| 437 | /// the twin that forces the widening is archived and appears on none of these | ||
| 438 | /// pages. Its absence must not narrow the id. | ||
| 439 | #[test] | ||
| 440 | fn a_filtered_issue_page_is_abbreviated_at_the_full_set_width() { | ||
| 441 | let harness = ServerHarness::new("abbrev-web-filtered"); | ||
| 442 | harness.push_head(); | ||
| 443 | |||
| 444 | let short = harness.work_repo().issue_open("A visible issue"); | ||
| 445 | let git = git2::Repository::open(harness.work_repo().dir.path()).unwrap(); | ||
| 446 | let real_id = full_issue_id(&git, &short); | ||
| 447 | let twin = plant_archived_issue_twin(&git, &real_id); | ||
| 448 | harness.push_collab_refs(); | ||
| 449 | |||
| 450 | let page = harness.get_ok(&format!("/{}/issues?filter=open", harness.repo_name())); | ||
| 451 | let shown = anchor_text(&page.body, &format!("/issues/{}", real_id)); | ||
| 452 | |||
| 453 | assert_eq!( | ||
| 454 | shown.len(), | ||
| 455 | 9, | ||
| 456 | "the open-issue page abbreviated over its own rows instead of every \ | ||
| 457 | issue: showed '{}' while an archived issue shares its first eight \ | ||
| 458 | characters", | ||
| 459 | shown | ||
| 460 | ); | ||
| 461 | assert!(real_id.starts_with(&shown), "showed '{}'", shown); | ||
| 462 | assert!( | ||
| 463 | !twin.starts_with(&shown), | ||
| 464 | "'{}' names two issues, so it is not a usable reference", | ||
| 465 | shown | ||
| 466 | ); | ||
| 467 | } | ||
| 468 | |||
| 469 | /// The same issue must render as the same string whichever filter is applied, | ||
| 470 | /// or the id someone copies depends on which tab they happened to be on. | ||
| 471 | #[test] | ||
| 472 | fn every_issue_filter_prints_the_same_width() { | ||
| 473 | let harness = ServerHarness::new("abbrev-web-agree"); | ||
| 474 | harness.push_head(); | ||
| 475 | |||
| 476 | let short = harness.work_repo().issue_open("A visible issue"); | ||
| 477 | let git = git2::Repository::open(harness.work_repo().dir.path()).unwrap(); | ||
| 478 | let real_id = full_issue_id(&git, &short); | ||
| 479 | plant_archived_issue_twin(&git, &real_id); | ||
| 480 | harness.push_collab_refs(); | ||
| 481 | |||
| 482 | let tail = format!("/issues/{}", real_id); | ||
| 483 | let open = harness.get_ok(&format!("/{}/issues?filter=open", harness.repo_name())); | ||
| 484 | let all = harness.get_ok(&format!("/{}/issues?filter=all", harness.repo_name())); | ||
| 485 | let overview = harness.get_ok(&format!("/{}", harness.repo_name())); | ||
| 486 | |||
| 487 | let from_open = anchor_text(&open.body, &tail); | ||
| 488 | assert_eq!(from_open, anchor_text(&all.body, &tail), "open vs all"); | ||
| 489 | assert_eq!( | ||
| 490 | from_open, | ||
| 491 | anchor_text(&overview.body, &tail), | ||
| 492 | "issue list vs repo overview" | ||
| 493 | ); | ||
| 494 | } | ||
| 495 | |||
| 496 | #[test] | ||
| 497 | fn collab_abbrev_sets_the_width_of_the_web_issue_and_patch_lists() { | ||
| 498 | let harness = ServerHarness::new("abbrev-web-config"); | ||
| 499 | harness.push_head(); | ||
| 500 | |||
| 501 | let issue_short = harness.work_repo().issue_open("Configured issue"); | ||
| 502 | let patch_short = harness.work_repo().patch_create("Configured patch"); | ||
| 503 | harness.push_collab_refs(); | ||
| 504 | set_server_abbrev(&harness, "14"); | ||
| 505 | |||
| 506 | let git = git2::Repository::open(harness.work_repo().dir.path()).unwrap(); | ||
| 507 | let issue_id = full_issue_id(&git, &issue_short); | ||
| 508 | let patch_id = git_collab::state::resolve_patch_ref(&git, &patch_short) | ||
| 509 | .unwrap() | ||
| 510 | .1; | ||
| 511 | |||
| 512 | let issues = harness.get_ok(&format!("/{}/issues", harness.repo_name())); | ||
| 513 | assert_eq!( | ||
| 514 | anchor_text(&issues.body, &format!("/issues/{}", issue_id)).len(), | ||
| 515 | 14, | ||
| 516 | "the issue list ignored collab.abbrev" | ||
| 517 | ); | ||
| 518 | |||
| 519 | let patches = harness.get_ok(&format!("/{}/patches", harness.repo_name())); | ||
| 520 | assert_eq!( | ||
| 521 | anchor_text(&patches.body, &format!("/patches/{}", patch_id)).len(), | ||
| 522 | 14, | ||
| 523 | "the patch list ignored collab.abbrev" | ||
| 524 | ); | ||
| 525 | |||
| 526 | let overview = harness.get_ok(&format!("/{}", harness.repo_name())); | ||
| 527 | assert_eq!( | ||
| 528 | anchor_text(&overview.body, &format!("/issues/{}", issue_id)).len(), | ||
| 529 | 14, | ||
| 530 | "the repo overview ignored collab.abbrev" | ||
| 531 | ); | ||
| 532 | assert_eq!( | ||
| 533 | anchor_text(&overview.body, &format!("/patches/{}", patch_id)).len(), | ||
| 534 | 14, | ||
| 535 | "the repo overview ignored collab.abbrev" | ||
| 536 | ); | ||
| 537 | } | ||
| 538 | |||
| 539 | /// `full` is git's spelling for "do not abbreviate", and the web UI has the | ||
| 540 | /// room to honour it. | ||
| 541 | #[test] | ||
| 542 | fn collab_abbrev_full_prints_whole_ids_in_the_web_ui() { | ||
| 543 | let harness = ServerHarness::new("abbrev-web-full"); | ||
| 544 | harness.push_head(); | ||
| 545 | |||
| 546 | let issue_short = harness.work_repo().issue_open("Whole id issue"); | ||
| 547 | harness.push_collab_refs(); | ||
| 548 | set_server_abbrev(&harness, "full"); | ||
| 549 | |||
| 550 | let git = git2::Repository::open(harness.work_repo().dir.path()).unwrap(); | ||
| 551 | let issue_id = full_issue_id(&git, &issue_short); | ||
| 552 | |||
| 553 | let issues = harness.get_ok(&format!("/{}/issues", harness.repo_name())); | ||
| 554 | assert_eq!( | ||
| 555 | anchor_text(&issues.body, &format!("/issues/{}", issue_id)), | ||
| 556 | issue_id, | ||
| 557 | "collab.abbrev=full should print the whole id" | ||
| 558 | ); | ||
| 559 | } | ||
| 560 | |||
| 561 | // --------------------------------------------------------------------------- | ||
| 562 | // Reading is not writing | ||
| 563 | // | ||
| 564 | // Both surfaces now run an extra query — every id of a kind — on every render. | ||
| 565 | // It has to stay a query. A page view or a dashboard launch that appended an | ||
| 566 | // event or moved a ref would be writing to a shared, replicated history on | ||
| 567 | // behalf of someone who only looked at it. | ||
| 568 | // --------------------------------------------------------------------------- | ||
| 569 | |||
| 570 | /// Every collab ref and what it points at, as a sorted list. | ||
| 571 | fn collab_refs_snapshot(git_dir: &std::path::Path) -> Vec<String> { | ||
| 572 | let out = std::process::Command::new("git") | ||
| 573 | .args([ | ||
| 574 | "for-each-ref", | ||
| 575 | "--format=%(refname) %(objectname)", | ||
| 576 | "refs/collab/", | ||
| 577 | ]) | ||
| 578 | .current_dir(git_dir) | ||
| 579 | .output() | ||
| 580 | .expect("for-each-ref failed"); | ||
| 581 | let mut refs: Vec<String> = String::from_utf8_lossy(&out.stdout) | ||
| 582 | .lines() | ||
| 583 | .map(|l| l.to_string()) | ||
| 584 | .collect(); | ||
| 585 | refs.sort(); | ||
| 586 | refs | ||
| 587 | } | ||
| 588 | |||
| 589 | #[test] | ||
| 590 | fn rendering_web_pages_does_not_touch_the_collab_refs() { | ||
| 591 | let harness = ServerHarness::new("abbrev-web-readonly"); | ||
| 592 | harness.push_head(); | ||
| 593 | harness.work_repo().issue_open("An issue to look at"); | ||
| 594 | harness.work_repo().patch_create("A patch to look at"); | ||
| 595 | harness.push_collab_refs(); | ||
| 596 | |||
| 597 | let served = harness | ||
| 598 | .repos_dir() | ||
| 599 | .join(format!("{}.git", harness.repo_name())); | ||
| 600 | let before = collab_refs_snapshot(&served); | ||
| 601 | assert!(!before.is_empty(), "the test needs some refs to protect"); | ||
| 602 | |||
| 603 | let name = harness.repo_name(); | ||
| 604 | for path in [ | ||
| 605 | format!("/{}", name), | ||
| 606 | format!("/{}/issues", name), | ||
| 607 | format!("/{}/issues?filter=all", name), | ||
| 608 | format!("/{}/patches", name), | ||
| 609 | format!("/{}/patches?filter=all", name), | ||
| 610 | ] { | ||
| 611 | harness.get_ok(&path); | ||
| 612 | } | ||
| 613 | |||
| 614 | assert_eq!( | ||
| 615 | before, | ||
| 616 | collab_refs_snapshot(&served), | ||
| 617 | "rendering a page changed the collab refs" | ||
| 618 | ); | ||
| 619 | } | ||
| 620 | |||
| 621 | #[test] | ||
| 622 | fn launching_the_dashboard_does_not_touch_the_collab_refs() { | ||
| 623 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 624 | repo.issue_open("An issue to look at"); | ||
| 625 | repo.patch_create("A patch to look at"); | ||
| 626 | |||
| 627 | let before = collab_refs_snapshot(repo.dir.path()); | ||
| 628 | assert!(!before.is_empty(), "the test needs some refs to protect"); | ||
| 629 | |||
| 630 | // `a` cycles the status filter and `P` swaps to patches, so this walks | ||
| 631 | // the views that re-derive a width, not just the one on startup. | ||
| 632 | let output = repo.run_dashboard_smoke_sized("aPaq", 120, 24); | ||
| 633 | assert!(output.status.success(), "dashboard should exit cleanly"); | ||
| 634 | |||
| 635 | assert_eq!( | ||
| 636 | before, | ||
| 637 | collab_refs_snapshot(repo.dir.path()), | ||
| 638 | "opening the dashboard changed the collab refs" | ||
| 639 | ); | ||
| 640 | } | ||
| 641 | |||
| 642 | // --------------------------------------------------------------------------- | ||
| 643 | // patch checkout | ||
| 644 | // | ||
| 645 | // The branch `patch checkout` creates is named after the patch, so the branch | ||
| 646 | // name is a displayed id like any other — and the one displayed id that | ||
| 647 | // outlives the command by definition, since the branch stays behind. | ||
| 648 | // --------------------------------------------------------------------------- | ||
| 649 | |||
| 650 | /// The patch equivalent of `plant_archived_issue_twin`. | ||
| 651 | fn plant_archived_patch_twin(git: &git2::Repository, real_id: &str) -> String { | ||
| 652 | let mut chars: Vec<char> = real_id.chars().collect(); | ||
| 653 | chars[8] = if chars[8] == '0' { '1' } else { '0' }; | ||
| 654 | let twin: String = chars.into_iter().collect(); | ||
| 655 | let target = git | ||
| 656 | .refname_to_id(&format!("refs/collab/patches/{}/events", real_id)) | ||
| 657 | .expect("the real patch ref should exist"); | ||
| 658 | git.reference( | ||
| 659 | &format!("refs/collab/archive/patches/{}/events", twin), | ||
| 660 | target, | ||
| 661 | false, | ||
| 662 | "planted twin", | ||
| 663 | ) | ||
| 664 | .unwrap(); | ||
| 665 | twin | ||
| 666 | } | ||
| 667 | |||
| 668 | /// Two patches sharing eight characters must not want the same branch name. | ||
| 669 | /// | ||
| 670 | /// The suffix loop in `checkout` would paper over it — `collab/<id>-1` — but | ||
| 671 | /// that name reads as a second checkout of one patch rather than a checkout of | ||
| 672 | /// a different one, and the reviewer has no way to tell which patch they are | ||
| 673 | /// looking at from the branch they are on. | ||
| 674 | #[test] | ||
| 675 | fn patch_checkout_names_the_branch_at_the_full_set_width() { | ||
| 676 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 677 | let short = repo.patch_create("A patch to check out"); | ||
| 678 | let git = git2::Repository::open(repo.dir.path()).unwrap(); | ||
| 679 | let real_id = git_collab::state::resolve_patch_ref(&git, &short) | ||
| 680 | .unwrap() | ||
| 681 | .1; | ||
| 682 | let twin = plant_archived_patch_twin(&git, &real_id); | ||
| 683 | |||
| 684 | let out = repo.run_ok(&["patch", "checkout", &real_id]); | ||
| 685 | let branch = repo.git(&["rev-parse", "--abbrev-ref", "HEAD"]); | ||
| 686 | let branch = branch.trim(); | ||
| 687 | |||
| 688 | assert_eq!( | ||
| 689 | branch, | ||
| 690 | format!("collab/{}", &real_id[..9]), | ||
| 691 | "the branch name was cut to a prefix that also names patch {}", | ||
| 692 | &twin[..9] | ||
| 693 | ); | ||
| 694 | assert!( | ||
| 695 | out.contains(&real_id[..9]), | ||
| 696 | "the message should name the patch at the same width: {}", | ||
| 697 | out | ||
| 698 | ); | ||
| 699 | } | ||
| 700 | |||
| 701 | #[test] | ||
| 702 | fn collab_abbrev_sets_the_width_of_the_patch_checkout_branch() { | ||
| 703 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 704 | let short = repo.patch_create("A configured patch"); | ||
| 705 | let git = git2::Repository::open(repo.dir.path()).unwrap(); | ||
| 706 | let real_id = git_collab::state::resolve_patch_ref(&git, &short) | ||
| 707 | .unwrap() | ||
| 708 | .1; | ||
| 709 | set_abbrev(&repo, "14"); | ||
| 710 | |||
| 711 | repo.run_ok(&["patch", "checkout", &real_id]); | ||
| 712 | let branch = repo.git(&["rev-parse", "--abbrev-ref", "HEAD"]); | ||
| 713 | |||
| 714 | assert_eq!(branch.trim(), format!("collab/{}", &real_id[..14])); | ||
| 715 | } | ||
| 716 | |||
| 717 | // --------------------------------------------------------------------------- | ||
| 718 | // dashboard | ||
| 719 | // | ||
| 720 | // The TUI is driven through a pty, so what these assert on is the characters | ||
| 721 | // that actually reached the screen. | ||
| 722 | // --------------------------------------------------------------------------- | ||
| 723 | |||
| 724 | /// The printable text of a terminal session, with the escape sequences that | ||
| 725 | /// carried the colours removed. | ||
| 726 | fn strip_ansi(raw: &str) -> String { | ||
| 727 | let mut out = String::new(); | ||
| 728 | let mut chars = raw.chars().peekable(); | ||
| 729 | while let Some(c) = chars.next() { | ||
| 730 | if c != '\u{1b}' { | ||
| 731 | out.push(c); | ||
| 732 | continue; | ||
| 733 | } | ||
| 734 | // CSI sequences run until a byte in @-~; anything else is a two-byte | ||
| 735 | // escape. Neither ever contains printable payload the dashboard means | ||
| 736 | // for a reader, so both are dropped whole. | ||
| 737 | if chars.peek() == Some(&'[') { | ||
| 738 | chars.next(); | ||
| 739 | for c2 in chars.by_ref() { | ||
| 740 | if ('@'..='~').contains(&c2) { | ||
| 741 | break; | ||
| 742 | } | ||
| 743 | } | ||
| 744 | } else { | ||
| 745 | chars.next(); | ||
| 746 | } | ||
| 747 | } | ||
| 748 | out | ||
| 749 | } | ||
| 750 | |||
| 751 | fn dashboard_screen(repo: &TestRepo, cols: u16) -> String { | ||
| 752 | let output = repo.run_dashboard_smoke_sized("q", cols, 24); | ||
| 753 | assert!( | ||
| 754 | output.status.success(), | ||
| 755 | "dashboard did not exit cleanly: {}", | ||
| 756 | String::from_utf8_lossy(&output.stderr) | ||
| 757 | ); | ||
| 758 | strip_ansi(&String::from_utf8_lossy(&output.stdout)) | ||
| 759 | } | ||
| 760 | |||
| 761 | /// Open one issue and give it an archived twin sharing its first eight | ||
| 762 | /// characters. Returns the issue's full id. | ||
| 763 | fn issue_with_archived_twin(repo: &TestRepo, title: &str) -> (String, String) { | ||
| 764 | let short = repo.issue_open(title); | ||
| 765 | let git = git2::Repository::open(repo.dir.path()).unwrap(); | ||
| 766 | let real_id = full_issue_id(&git, &short); | ||
| 767 | let twin = plant_archived_issue_twin(&git, &real_id); | ||
| 768 | (real_id, twin) | ||
| 769 | } | ||
| 770 | |||
| 771 | /// The dashboard's counterpart to the web test above. Its issue list defaults | ||
| 772 | /// to open issues and never shows archived ones at all, yet the archived twin | ||
| 773 | /// still has to widen what it prints. | ||
| 774 | #[test] | ||
| 775 | fn the_dashboard_issue_list_is_abbreviated_at_the_full_set_width() { | ||
| 776 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 777 | let (real_id, twin) = issue_with_archived_twin(&repo, "Dashboard issue"); | ||
| 778 | |||
| 779 | let screen = dashboard_screen(&repo, 120); | ||
| 780 | |||
| 781 | assert!( | ||
| 782 | screen.contains(&format!("{} ", &real_id[..9])), | ||
| 783 | "expected the issue id widened to nine characters ('{}') on screen:\n{}", | ||
| 784 | &real_id[..9], | ||
| 785 | screen | ||
| 786 | ); | ||
| 787 | assert!( | ||
| 788 | !screen.contains(&format!("{} ", &real_id[..8])), | ||
| 789 | "the dashboard printed '{}', which also names the archived issue {}", | ||
| 790 | &real_id[..8], | ||
| 791 | &twin[..9] | ||
| 792 | ); | ||
| 793 | } | ||
| 794 | |||
| 795 | #[test] | ||
| 796 | fn collab_abbrev_sets_the_width_of_the_dashboard_lists() { | ||
| 797 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 798 | let short = repo.issue_open("Configured issue"); | ||
| 799 | let git = git2::Repository::open(repo.dir.path()).unwrap(); | ||
| 800 | let issue_id = full_issue_id(&git, &short); | ||
| 801 | set_abbrev(&repo, "14"); | ||
| 802 | |||
| 803 | let screen = dashboard_screen(&repo, 120); | ||
| 804 | assert!( | ||
| 805 | screen.contains(&format!("{} ", &issue_id[..14])), | ||
| 806 | "the dashboard ignored collab.abbrev=14:\n{}", | ||
| 807 | screen | ||
| 808 | ); | ||
| 809 | } | ||
| 810 | |||
| 811 | #[test] | ||
| 812 | fn collab_abbrev_full_prints_whole_ids_in_the_dashboard() { | ||
| 813 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 814 | let short = repo.issue_open("Whole id issue"); | ||
| 815 | let git = git2::Repository::open(repo.dir.path()).unwrap(); | ||
| 816 | let issue_id = full_issue_id(&git, &short); | ||
| 817 | set_abbrev(&repo, "full"); | ||
| 818 | |||
| 819 | // Wide enough that a 40-character id is not merely clipped by the pane. | ||
| 820 | let screen = dashboard_screen(&repo, 200); | ||
| 821 | assert!( | ||
| 822 | screen.contains(&issue_id), | ||
| 823 | "collab.abbrev=full should print the whole id '{}':\n{}", | ||
| 824 | issue_id, | ||
| 825 | screen | ||
| 826 | ); | ||
| 827 | } | ||
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -572,12 +572,27 @@ impl TestRepo { | |||
| 572 | 572 | ||
| 573 | /// Run `git-collab dashboard` in a pseudo-terminal, feed input, and return raw output. | 573 | /// Run `git-collab dashboard` in a pseudo-terminal, feed input, and return raw output. |
| 574 | pub fn run_dashboard_smoke(&self, input: &str) -> Output { | 574 | pub fn run_dashboard_smoke(&self, input: &str) -> Output { |
| 575 | self.run_dashboard_smoke_sized(input, 80, 24) | ||
| 576 | } | ||
| 577 | |||
| 578 | /// As `run_dashboard_smoke`, but with an explicit terminal size. | ||
| 579 | /// | ||
| 580 | /// The dashboard lays its panes out in columns and clips whatever does not | ||
| 581 | /// fit, so a test asserting on a rendered id has to give it room for the | ||
| 582 | /// widest id it could legitimately print. `stty` runs inside the pty | ||
| 583 | /// `script` allocates, before the dashboard reads its size. | ||
| 584 | pub fn run_dashboard_smoke_sized(&self, input: &str, cols: u16, rows: u16) -> Output { | ||
| 575 | let mut command = Command::new("script"); | 585 | let mut command = Command::new("script"); |
| 576 | self.apply_env(&mut command); | 586 | self.apply_env(&mut command); |
| 577 | let mut child = command | 587 | let mut child = command |
| 578 | .args([ | 588 | .args([ |
| 579 | "-qec", | 589 | "-qec", |
| 580 | &format!("{} dashboard", env!("CARGO_BIN_EXE_git-collab")), | 590 | &format!( |
| 591 | "stty cols {} rows {}; {} dashboard", | ||
| 592 | cols, | ||
| 593 | rows, | ||
| 594 | env!("CARGO_BIN_EXE_git-collab") | ||
| 595 | ), | ||
| 581 | "/dev/null", | 596 | "/dev/null", |
| 582 | ]) | 597 | ]) |
| 583 | .current_dir(self.dir.path()) | 598 | .current_dir(self.dir.path()) |