65e0e821
Wrap the patch detail pane, and show dates the way the web does
a73x 2026-08-13 07:30
Commit message
Cargo.lock
| Old | New | ||
|---|---|---|---|
| @@ -1630,6 +1630,7 @@ dependencies = [ | |||
| 1630 | "toml_edit", | 1630 | "toml_edit", |
| 1631 | "tracing", | 1631 | "tracing", |
| 1632 | "tracing-subscriber", | 1632 | "tracing-subscriber", |
| 1633 | "unicode-width", | ||
| 1633 | "zeroize", | 1634 | "zeroize", |
| 1634 | ] | 1635 | ] |
| 1635 | 1636 | ||
Cargo.toml
| Old | New | ||
|---|---|---|---|
| @@ -47,6 +47,12 @@ sha2 = "0.10" | |||
| 47 | # can push the config, so the engine must be one with a linear-time guarantee | 47 | # can push the config, so the engine must be one with a linear-time guarantee |
| 48 | # rather than a backtracker. Already in the tree via tracing-subscriber. | 48 | # rather than a backtracker. Already in the tree via tracing-subscriber. |
| 49 | regex = "1" | 49 | regex = "1" |
| 50 | # The dashboard wraps the patch detail pane itself rather than letting | ||
| 51 | # `Paragraph` do it, because it needs the mapping from display rows back to | ||
| 52 | # logical lines that `Paragraph` does not expose. Wrapping by column means | ||
| 53 | # knowing how many columns a character occupies. Already in the tree via | ||
| 54 | # ratatui, at the same version, so this adds a name and not a crate. | ||
| 55 | unicode-width = "0.2" | ||
| 50 | tempfile = "3" | 56 | tempfile = "3" |
| 51 | tokio-util = { version = "0.7", features = ["io"] } | 57 | tokio-util = { version = "0.7", features = ["io"] } |
| 52 | 58 | ||
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -21,6 +21,7 @@ pub mod status; | |||
| 21 | pub mod sync; | 21 | pub mod sync; |
| 22 | pub mod sync_lock; | 22 | pub mod sync_lock; |
| 23 | pub mod timeline; | 23 | pub mod timeline; |
| 24 | pub mod timestamp; | ||
| 24 | pub mod trailer; | 25 | pub mod trailer; |
| 25 | pub mod trust; | 26 | pub mod trust; |
| 26 | pub mod tui; | 27 | pub mod tui; |
src/server/http/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,11 @@ | |||
| 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 | // The same helper the dashboard renders with. It lives in the library because |
| 5 | // two surfaces showing one stored field two ways is the defect `952390ad` | ||
| 6 | // reported: a reader moves between them, and a screenshot from one gets pasted | ||
| 7 | // next to output from the other. | ||
| 8 | pub use git_collab::timestamp; | ||
| 5 | 9 | ||
| 6 | use axum::extract::{DefaultBodyLimit, Request, State}; | 10 | use axum::extract::{DefaultBodyLimit, Request, State}; |
| 7 | use axum::http::uri::{PathAndQuery, Uri}; | 11 | use axum::http::uri::{PathAndQuery, Uri}; |
src/server/http/timestamp.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,117 +0,0 @@ | |||
| 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/timestamp.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,126 @@ | |||
| 1 | //! How a stored timestamp goes onto a screen. | ||
| 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 the dashboard uses it too | ||
| 22 | //! | ||
| 23 | //! Issue `952390ad`: the TUI kept printing the stored string after the web had | ||
| 24 | //! stopped, which is the divergence `10bb2d84` says must not happen for a | ||
| 25 | //! displayed value — two surfaces rendering one field two ways teach two | ||
| 26 | //! different habits and make screenshots and pasted output disagree. A | ||
| 27 | //! terminal has no hover, so the dashboard shows [`Timestamp::short`] and | ||
| 28 | //! nothing else; the full value is still one `show` or `--json` away. | ||
| 29 | //! | ||
| 30 | //! # Why minutes, and why absolute | ||
| 31 | //! | ||
| 32 | //! Minute precision is the coarsest rendering that still orders two events on | ||
| 33 | //! the same day, which is the common case for a review and the comment | ||
| 34 | //! answering it. Seconds add three characters and settle nothing a reader | ||
| 35 | //! cares about. | ||
| 36 | //! | ||
| 37 | //! Absolute rather than relative ("3 days ago") because a relative rendering | ||
| 38 | //! is a function of when the page was built, so the same URL says different | ||
| 39 | //! things at different times: it cannot be cached, cannot be compared between | ||
| 40 | //! two rows rendered from different requests, and cannot be asserted by a test | ||
| 41 | //! without freezing a clock. The exact instant is what was recorded; the page | ||
| 42 | //! should say it. | ||
| 43 | //! | ||
| 44 | //! # Why UTC | ||
| 45 | //! | ||
| 46 | //! The server cannot know the reader's timezone, and a page rendered in the | ||
| 47 | //! *server's* zone would silently disagree with the same page rendered | ||
| 48 | //! elsewhere. Every timestamp this project writes is already `+00:00`, so | ||
| 49 | //! normalizing to UTC changes nothing in practice and fixes the display for a | ||
| 50 | //! value that arrived with some other offset from a repository synced in. | ||
| 51 | |||
| 52 | /// A stored timestamp, split into what a cell shows and what it stands for. | ||
| 53 | #[derive(Debug, Clone)] | ||
| 54 | pub struct Timestamp { | ||
| 55 | /// Exactly as stored — the `title` attribute, so nothing is lost. | ||
| 56 | pub full: String, | ||
| 57 | /// `YYYY-MM-DD HH:MM` in UTC. | ||
| 58 | pub short: String, | ||
| 59 | } | ||
| 60 | |||
| 61 | impl Timestamp { | ||
| 62 | /// Split a stored RFC3339 timestamp for display. | ||
| 63 | /// | ||
| 64 | /// An unparseable value renders as itself. A timestamp this project did | ||
| 65 | /// not write is still a fact about the object, and a page that dropped it | ||
| 66 | /// or printed a placeholder would be hiding the one thing that could | ||
| 67 | /// explain the row. Ugly beats absent. | ||
| 68 | pub fn new(stored: impl Into<String>) -> Self { | ||
| 69 | let full = stored.into(); | ||
| 70 | let short = chrono::DateTime::parse_from_rfc3339(&full) | ||
| 71 | .map(|dt| { | ||
| 72 | dt.with_timezone(&chrono::Utc) | ||
| 73 | .format("%Y-%m-%d %H:%M") | ||
| 74 | .to_string() | ||
| 75 | }) | ||
| 76 | .unwrap_or_else(|_| full.clone()); | ||
| 77 | Timestamp { full, short } | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | #[cfg(test)] | ||
| 82 | mod tests { | ||
| 83 | use super::*; | ||
| 84 | |||
| 85 | #[test] | ||
| 86 | fn nanoseconds_and_the_offset_are_dropped_from_the_short_form() { | ||
| 87 | let t = Timestamp::new("2026-08-11T16:08:31.458124482+00:00"); | ||
| 88 | assert_eq!(t.short, "2026-08-11 16:08"); | ||
| 89 | assert_eq!(t.short.len(), 16); | ||
| 90 | } | ||
| 91 | |||
| 92 | #[test] | ||
| 93 | fn the_stored_value_survives_for_the_title_attribute() { | ||
| 94 | let stored = "2026-08-11T16:08:31.458124482+00:00"; | ||
| 95 | assert_eq!(Timestamp::new(stored).full, stored); | ||
| 96 | } | ||
| 97 | |||
| 98 | /// A value that arrived with a non-UTC offset must not read as though its | ||
| 99 | /// wall-clock digits were UTC — two rows in one table have to be | ||
| 100 | /// comparable by eye. | ||
| 101 | #[test] | ||
| 102 | fn a_foreign_offset_is_normalized_to_utc() { | ||
| 103 | let t = Timestamp::new("2026-08-11T18:08:31+02:00"); | ||
| 104 | assert_eq!(t.short, "2026-08-11 16:08"); | ||
| 105 | } | ||
| 106 | |||
| 107 | #[test] | ||
| 108 | fn a_second_precision_timestamp_shortens_too() { | ||
| 109 | assert_eq!( | ||
| 110 | Timestamp::new("2026-08-11T16:08:31Z").short, | ||
| 111 | "2026-08-11 16:08" | ||
| 112 | ); | ||
| 113 | } | ||
| 114 | |||
| 115 | #[test] | ||
| 116 | fn an_unparseable_timestamp_renders_as_itself_rather_than_vanishing() { | ||
| 117 | let t = Timestamp::new("not a date"); | ||
| 118 | assert_eq!(t.short, "not a date"); | ||
| 119 | assert_eq!(t.full, "not a date"); | ||
| 120 | } | ||
| 121 | |||
| 122 | #[test] | ||
| 123 | fn an_empty_timestamp_stays_empty() { | ||
| 124 | assert_eq!(Timestamp::new("").short, ""); | ||
| 125 | } | ||
| 126 | } | ||
src/tui/events.rs
| Old | New | ||
|---|---|---|---|
| @@ -288,11 +288,15 @@ fn comment_seed(headline: &str, context: Vec<String>) -> String { | |||
| 288 | seed | 288 | seed |
| 289 | } | 289 | } |
| 290 | 290 | ||
| 291 | /// The rows around the cursor, as `#` context for the editor buffer. | 291 | /// The lines around the cursor, as `#` context for the editor buffer. |
| 292 | /// | ||
| 293 | /// Logical lines, not screen rows: the reviewer is being shown what they are | ||
| 294 | /// writing about, and a line broken across three rows by the width of a pane | ||
| 295 | /// they are no longer looking at is not that. | ||
| 292 | fn cursor_context(app: &App) -> Vec<String> { | 296 | fn cursor_context(app: &App) -> Vec<String> { |
| 293 | let start = app.patch_cursor.saturating_sub(5); | 297 | let start = app.patch_cursor.saturating_sub(5); |
| 294 | let end = (app.patch_cursor + 4).min(app.patch_rows.len()); | 298 | let end = (app.patch_cursor + 4).min(app.patch_lines.len()); |
| 295 | app.patch_rows[start..end] | 299 | app.patch_lines[start..end] |
| 296 | .iter() | 300 | .iter() |
| 297 | .enumerate() | 301 | .enumerate() |
| 298 | .map(|(i, row)| { | 302 | .map(|(i, row)| { |
src/tui/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -293,7 +293,11 @@ mod tests { | |||
| 293 | } | 293 | } |
| 294 | 294 | ||
| 295 | fn render_app(app: &mut App) -> Buffer { | 295 | fn render_app(app: &mut App) -> Buffer { |
| 296 | let backend = TestBackend::new(80, 24); | 296 | render_app_sized(app, 80, 24) |
| 297 | } | ||
| 298 | |||
| 299 | fn render_app_sized(app: &mut App, cols: u16, rows: u16) -> Buffer { | ||
| 300 | let backend = TestBackend::new(cols, rows); | ||
| 297 | let mut terminal = Terminal::new(backend).unwrap(); | 301 | let mut terminal = Terminal::new(backend).unwrap(); |
| 298 | terminal.draw(|frame| ui(frame, app, None)).unwrap(); | 302 | terminal.draw(|frame| ui(frame, app, None)).unwrap(); |
| 299 | terminal.backend().buffer().clone() | 303 | terminal.backend().buffer().clone() |
| @@ -461,7 +465,8 @@ mod tests { | |||
| 461 | let detail = format_event_detail(&oid, &event, &Abbrev::minimal()); | 465 | let detail = format_event_detail(&oid, &event, &Abbrev::minimal()); |
| 462 | assert!(detail.contains("aaaaaaa")); | 466 | assert!(detail.contains("aaaaaaa")); |
| 463 | assert!(detail.contains("Test User <test@example.com>")); | 467 | assert!(detail.contains("Test User <test@example.com>")); |
| 464 | assert!(detail.contains("2026-01-01T00:00:00Z")); | 468 | // The short form, not the stored one: see `an_issues_created_date...`. |
| 469 | assert!(detail.contains("2026-01-01 00:00")); | ||
| 465 | assert!(detail.contains("Issue Open")); | 470 | assert!(detail.contains("Issue Open")); |
| 466 | assert!(detail.contains("Title: My Issue")); | 471 | assert!(detail.contains("Title: My Issue")); |
| 467 | assert!(detail.contains("Description here")); | 472 | assert!(detail.contains("Description here")); |
| @@ -1272,7 +1277,9 @@ mod tests { | |||
| 1272 | app.mode = ViewMode::PatchDetail; | 1277 | app.mode = ViewMode::PatchDetail; |
| 1273 | app.current_patch = Some(make_patch_with_revisions()); | 1278 | app.current_patch = Some(make_patch_with_revisions()); |
| 1274 | app.rebuild_patch_rows(); | 1279 | app.rebuild_patch_rows(); |
| 1275 | let last = app.patch_rows.len() - 1; | 1280 | // Logical lines: the cursor indexes those, not the screen rows they |
| 1281 | // wrap to. | ||
| 1282 | let last = app.patch_lines.len() - 1; | ||
| 1276 | 1283 | ||
| 1277 | for _ in 0..500 { | 1284 | for _ in 0..500 { |
| 1278 | app.handle_key( | 1285 | app.handle_key( |
| @@ -1540,7 +1547,7 @@ mod tests { | |||
| 1540 | app.rebuild_patch_rows(); | 1547 | app.rebuild_patch_rows(); |
| 1541 | 1548 | ||
| 1542 | let anchored: Vec<&RowTarget> = app | 1549 | let anchored: Vec<&RowTarget> = app |
| 1543 | .patch_rows | 1550 | .patch_lines |
| 1544 | .iter() | 1551 | .iter() |
| 1545 | .map(|r| &r.target) | 1552 | .map(|r| &r.target) |
| 1546 | .filter(|t| matches!(t, RowTarget::Diff { .. })) | 1553 | .filter(|t| matches!(t, RowTarget::Diff { .. })) |
| @@ -1571,7 +1578,7 @@ mod tests { | |||
| 1571 | app.rebuild_patch_rows(); | 1578 | app.rebuild_patch_rows(); |
| 1572 | 1579 | ||
| 1573 | assert!(app | 1580 | assert!(app |
| 1574 | .patch_rows | 1581 | .patch_lines |
| 1575 | .iter() | 1582 | .iter() |
| 1576 | .all(|r| !matches!(r.target, RowTarget::Diff { .. }))); | 1583 | .all(|r| !matches!(r.target, RowTarget::Diff { .. }))); |
| 1577 | } | 1584 | } |
| @@ -1589,7 +1596,7 @@ mod tests { | |||
| 1589 | app.rebuild_patch_rows(); | 1596 | app.rebuild_patch_rows(); |
| 1590 | 1597 | ||
| 1591 | let found = app | 1598 | let found = app |
| 1592 | .patch_rows | 1599 | .patch_lines |
| 1593 | .iter() | 1600 | .iter() |
| 1594 | .find_map(|r| match &r.target { | 1601 | .find_map(|r| match &r.target { |
| 1595 | RowTarget::Comment { oid, resolved } => Some((oid.clone(), *resolved)), | 1602 | RowTarget::Comment { oid, resolved } => Some((oid.clone(), *resolved)), |
| @@ -1671,6 +1678,618 @@ mod tests { | |||
| 1671 | assert_buffer_contains(&buf, "1 new event "); | 1678 | assert_buffer_contains(&buf, "1 new event "); |
| 1672 | } | 1679 | } |
| 1673 | 1680 | ||
| 1681 | // ── Wrapping the patch detail pane (9f9dee31) ──────────────────────── | ||
| 1682 | // | ||
| 1683 | // `07a78a60` turned wrapping off because `Paragraph::wrap` wraps *after* it | ||
| 1684 | // is handed lines and exposes no mapping back to the logical line, so the | ||
| 1685 | // cursor — a raw row offset — landed on a different line than `c` would | ||
| 1686 | // comment on. The answer is not to stop wrapping but to own it: emit N | ||
| 1687 | // display rows per logical line, all carrying that line's target, and move | ||
| 1688 | // the cursor by logical line the way vim does. | ||
| 1689 | |||
| 1690 | /// A patch detail pane whose diff is `n` anchored lines, each far wider | ||
| 1691 | /// than any pane, with the two ends marked so a test can see both. | ||
| 1692 | fn app_with_long_diff_lines(n: u32) -> App { | ||
| 1693 | let mut app = make_app(0, 0); | ||
| 1694 | app.mode = ViewMode::PatchDetail; | ||
| 1695 | app.current_patch = Some(make_patch_with_revisions()); | ||
| 1696 | app.patch_diff = (0..n) | ||
| 1697 | .map(|i| crate::patch::DiffRow { | ||
| 1698 | text: format!("+HEAD{}{}TAIL{}\n", i, "x".repeat(200), i), | ||
| 1699 | body: true, | ||
| 1700 | anchor: Some(("src/main.rs".to_string(), 305 + i)), | ||
| 1701 | }) | ||
| 1702 | .collect(); | ||
| 1703 | app | ||
| 1704 | } | ||
| 1705 | |||
| 1706 | /// The text of one logical line as it was handed to the pane. | ||
| 1707 | fn logical_text(app: &App, logical: usize) -> String { | ||
| 1708 | app.patch_lines[logical].line.to_string() | ||
| 1709 | } | ||
| 1710 | |||
| 1711 | fn display_rows_of(app: &App, logical: usize) -> Vec<&DisplayRow> { | ||
| 1712 | app.patch_rows | ||
| 1713 | .iter() | ||
| 1714 | .filter(|r| r.logical == logical) | ||
| 1715 | .collect() | ||
| 1716 | } | ||
| 1717 | |||
| 1718 | /// The index of the logical line holding the diff line anchored at `line`. | ||
| 1719 | fn logical_index_of_diff_line(app: &App, line: u32) -> usize { | ||
| 1720 | app.patch_lines | ||
| 1721 | .iter() | ||
| 1722 | .position(|r| { | ||
| 1723 | matches!(&r.target, RowTarget::Diff { line: l, .. } if *l == line) | ||
| 1724 | }) | ||
| 1725 | .expect("a diff line with that anchor") | ||
| 1726 | } | ||
| 1727 | |||
| 1728 | /// Split `s` into the runs of characters a pane `width` columns wide would | ||
| 1729 | /// show it in, so a test can look for every one of them on screen. | ||
| 1730 | fn column_chunks(s: &str, width: usize) -> Vec<String> { | ||
| 1731 | let mut out = vec![String::new()]; | ||
| 1732 | for c in s.chars() { | ||
| 1733 | if out.last().unwrap().chars().count() == width { | ||
| 1734 | out.push(String::new()); | ||
| 1735 | } | ||
| 1736 | out.last_mut().unwrap().push(c); | ||
| 1737 | } | ||
| 1738 | out | ||
| 1739 | } | ||
| 1740 | |||
| 1741 | /// The property the issue is about: a line longer than the pane must be | ||
| 1742 | /// **fully present**, not clipped at the right edge with no sign that | ||
| 1743 | /// anything is missing. Asserted against the rendered cells — every column | ||
| 1744 | /// run of the logical line has to be somewhere on the screen — rather than | ||
| 1745 | /// against the row list, so that it is the reader's view being checked. | ||
| 1746 | #[test] | ||
| 1747 | fn a_line_longer_than_the_pane_is_fully_on_screen() { | ||
| 1748 | let mut app = app_with_long_diff_lines(1); | ||
| 1749 | app.rebuild_patch_rows(); | ||
| 1750 | let buf = render_app_sized(&mut app, 80, 60); | ||
| 1751 | |||
| 1752 | let width = app.patch_width as usize; | ||
| 1753 | assert!(width > 0 && width < 100, "implausible pane width {}", width); | ||
| 1754 | |||
| 1755 | let logical = logical_index_of_diff_line(&app, 305); | ||
| 1756 | let text = logical_text(&app, logical); | ||
| 1757 | let chunks = column_chunks(&text, width); | ||
| 1758 | assert!( | ||
| 1759 | chunks.len() > 3, | ||
| 1760 | "the fixture must be long enough to wrap several times, got {} rows", | ||
| 1761 | chunks.len() | ||
| 1762 | ); | ||
| 1763 | for chunk in &chunks { | ||
| 1764 | assert_buffer_contains(&buf, chunk); | ||
| 1765 | } | ||
| 1766 | // Both ends specifically, since those are what clipping loses. | ||
| 1767 | assert_buffer_contains(&buf, "HEAD0"); | ||
| 1768 | assert_buffer_contains(&buf, "TAIL0"); | ||
| 1769 | } | ||
| 1770 | |||
| 1771 | /// ...and the display rows really are a partition of the logical line: put | ||
| 1772 | /// back together they are it exactly, with nothing dropped at a break and | ||
| 1773 | /// nothing duplicated, and none of them overruns the pane. | ||
| 1774 | #[test] | ||
| 1775 | fn the_display_rows_of_a_line_reassemble_into_it() { | ||
| 1776 | let mut app = app_with_long_diff_lines(1); | ||
| 1777 | app.patch_width = 37; | ||
| 1778 | app.rebuild_patch_rows(); | ||
| 1779 | |||
| 1780 | let logical = logical_index_of_diff_line(&app, 305); | ||
| 1781 | let rows = display_rows_of(&app, logical); | ||
| 1782 | assert!(rows.len() > 3, "expected several rows, got {}", rows.len()); | ||
| 1783 | let rejoined: String = rows.iter().map(|r| r.line.to_string()).collect(); | ||
| 1784 | assert_eq!(rejoined, logical_text(&app, logical)); | ||
| 1785 | for r in &rows { | ||
| 1786 | assert!( | ||
| 1787 | r.line.width() <= 37, | ||
| 1788 | "a display row overran the pane: {:?}", | ||
| 1789 | r.line.to_string() | ||
| 1790 | ); | ||
| 1791 | } | ||
| 1792 | } | ||
| 1793 | |||
| 1794 | /// The rule that makes wrapping safe: every display row of a wrapped line | ||
| 1795 | /// carries that line's target, so `c` anchors to the same `file:line` | ||
| 1796 | /// whichever of them the reader is looking at. | ||
| 1797 | #[test] | ||
| 1798 | fn every_display_row_of_a_wrapped_diff_line_carries_the_same_anchor() { | ||
| 1799 | let mut app = app_with_long_diff_lines(1); | ||
| 1800 | app.patch_width = 37; | ||
| 1801 | app.rebuild_patch_rows(); | ||
| 1802 | |||
| 1803 | let logical = logical_index_of_diff_line(&app, 305); | ||
| 1804 | let rows = display_rows_of(&app, logical); | ||
| 1805 | assert!(rows.len() > 3); | ||
| 1806 | let expected = RowTarget::Diff { | ||
| 1807 | file: "src/main.rs".to_string(), | ||
| 1808 | line: 305, | ||
| 1809 | }; | ||
| 1810 | // Read the way the program reads it: a screen row names a logical | ||
| 1811 | // line, and the anchor belongs to that line. A wrong `logical` on any | ||
| 1812 | // row would show up here as a different anchor — or as a row of this | ||
| 1813 | // line missing from the run above. | ||
| 1814 | for r in &rows { | ||
| 1815 | assert_eq!(app.patch_lines[r.logical].target, expected); | ||
| 1816 | } | ||
| 1817 | |||
| 1818 | // And what `c` actually reads agrees with them. | ||
| 1819 | app.patch_cursor = logical; | ||
| 1820 | assert_eq!(app.cursor_target(), expected); | ||
| 1821 | } | ||
| 1822 | |||
| 1823 | /// The cursor covers the whole run, the way vim highlights a wrapped line | ||
| 1824 | /// — otherwise the reader sees a highlight on one third of a line and has | ||
| 1825 | /// to guess what `c` is about to act on. | ||
| 1826 | #[test] | ||
| 1827 | fn the_cursor_highlights_every_row_of_the_line_it_is_on() { | ||
| 1828 | let mut app = app_with_long_diff_lines(1); | ||
| 1829 | app.rebuild_patch_rows(); | ||
| 1830 | // Draw once so the pane records its width, then put the cursor on the | ||
| 1831 | // wrapped line and draw again. | ||
| 1832 | render_app_sized(&mut app, 80, 60); | ||
| 1833 | app.patch_cursor = logical_index_of_diff_line(&app, 305); | ||
| 1834 | app.patch_scroll = 0; | ||
| 1835 | let expected = display_rows_of(&app, app.patch_cursor).len(); | ||
| 1836 | assert!( | ||
| 1837 | expected > 1, | ||
| 1838 | "the line under test has to wrap for this to mean anything" | ||
| 1839 | ); | ||
| 1840 | let buf = render_app_sized(&mut app, 80, 60); | ||
| 1841 | |||
| 1842 | let highlighted = (1..buf.area.height - 1) | ||
| 1843 | .filter(|y| { | ||
| 1844 | (0..buf.area.width) | ||
| 1845 | .any(|x| buf.cell((x, *y)).unwrap().bg == Color::DarkGray) | ||
| 1846 | }) | ||
| 1847 | .count(); | ||
| 1848 | assert_eq!( | ||
| 1849 | highlighted, expected, | ||
| 1850 | "expected the highlight to cover all {} rows of the wrapped line", | ||
| 1851 | expected | ||
| 1852 | ); | ||
| 1853 | } | ||
| 1854 | |||
| 1855 | /// `j` moves by logical line, not by display row — vim's own default, | ||
| 1856 | /// which is why `gj` exists. Without it, crossing one wrapped line takes | ||
| 1857 | /// several presses and the cursor appears to stall. | ||
| 1858 | #[test] | ||
| 1859 | fn j_crosses_a_wrapped_line_in_one_press() { | ||
| 1860 | let mut app = app_with_long_diff_lines(3); | ||
| 1861 | app.patch_width = 37; | ||
| 1862 | app.rebuild_patch_rows(); | ||
| 1863 | |||
| 1864 | app.patch_cursor = logical_index_of_diff_line(&app, 305); | ||
| 1865 | assert!( | ||
| 1866 | display_rows_of(&app, app.patch_cursor).len() > 3, | ||
| 1867 | "the line under test has to wrap for this to mean anything" | ||
| 1868 | ); | ||
| 1869 | |||
| 1870 | app.handle_key( | ||
| 1871 | crossterm::event::KeyCode::Char('j'), | ||
| 1872 | crossterm::event::KeyModifiers::empty(), | ||
| 1873 | ); | ||
| 1874 | assert_eq!( | ||
| 1875 | app.cursor_target(), | ||
| 1876 | RowTarget::Diff { | ||
| 1877 | file: "src/main.rs".to_string(), | ||
| 1878 | line: 306 | ||
| 1879 | } | ||
| 1880 | ); | ||
| 1881 | |||
| 1882 | app.handle_key( | ||
| 1883 | crossterm::event::KeyCode::Char('k'), | ||
| 1884 | crossterm::event::KeyModifiers::empty(), | ||
| 1885 | ); | ||
| 1886 | assert_eq!( | ||
| 1887 | app.cursor_target(), | ||
| 1888 | RowTarget::Diff { | ||
| 1889 | file: "src/main.rs".to_string(), | ||
| 1890 | line: 305 | ||
| 1891 | } | ||
| 1892 | ); | ||
| 1893 | } | ||
| 1894 | |||
| 1895 | /// A logical line can wrap taller than the pane. Cursor movement is per | ||
| 1896 | /// logical line and scrolling is per display row precisely so that this | ||
| 1897 | /// case stays readable: landing on such a line shows its *start*, rather | ||
| 1898 | /// than jumping the view past rows the reader never saw. | ||
| 1899 | #[test] | ||
| 1900 | fn landing_on_an_over_tall_line_shows_its_start() { | ||
| 1901 | let mut app = app_with_long_diff_lines(2); | ||
| 1902 | app.patch_width = 37; | ||
| 1903 | app.rebuild_patch_rows(); | ||
| 1904 | app.patch_viewport = 4; | ||
| 1905 | |||
| 1906 | let target = logical_index_of_diff_line(&app, 306); | ||
| 1907 | let (first, last) = app.line_span(target); | ||
| 1908 | assert!( | ||
| 1909 | last - first > 4, | ||
| 1910 | "the line has to be taller than the pane: {} rows", | ||
| 1911 | last - first | ||
| 1912 | ); | ||
| 1913 | |||
| 1914 | app.patch_cursor = target - 1; | ||
| 1915 | app.patch_scroll = 0; | ||
| 1916 | app.handle_key( | ||
| 1917 | crossterm::event::KeyCode::Char('j'), | ||
| 1918 | crossterm::event::KeyModifiers::empty(), | ||
| 1919 | ); | ||
| 1920 | assert_eq!(app.patch_cursor, target); | ||
| 1921 | assert_eq!( | ||
| 1922 | app.patch_scroll as usize, first, | ||
| 1923 | "the view skipped the start of the line it moved onto" | ||
| 1924 | ); | ||
| 1925 | } | ||
| 1926 | |||
| 1927 | /// ...and the rest of such a line is reachable, because rows scroll | ||
| 1928 | /// independently of the cursor. That is what `Ctrl-E`/`Ctrl-Y` are for in | ||
| 1929 | /// vim, and without them the middle of an over-tall line cannot be seen. | ||
| 1930 | #[test] | ||
| 1931 | fn ctrl_e_scrolls_rows_without_moving_the_cursor() { | ||
| 1932 | let mut app = app_with_long_diff_lines(2); | ||
| 1933 | app.patch_width = 37; | ||
| 1934 | app.rebuild_patch_rows(); | ||
| 1935 | app.patch_viewport = 4; | ||
| 1936 | |||
| 1937 | let target = logical_index_of_diff_line(&app, 306); | ||
| 1938 | app.patch_cursor = target; | ||
| 1939 | let (first, last) = app.line_span(target); | ||
| 1940 | app.patch_scroll = first as u16; | ||
| 1941 | |||
| 1942 | for _ in 0..3 { | ||
| 1943 | app.handle_key( | ||
| 1944 | crossterm::event::KeyCode::Char('e'), | ||
| 1945 | crossterm::event::KeyModifiers::CONTROL, | ||
| 1946 | ); | ||
| 1947 | } | ||
| 1948 | assert_eq!(app.patch_cursor, target, "Ctrl-E moved the cursor"); | ||
| 1949 | assert_eq!(app.patch_scroll as usize, first + 3); | ||
| 1950 | assert!( | ||
| 1951 | (app.patch_scroll as usize) < last, | ||
| 1952 | "scrolled past the line it was reading" | ||
| 1953 | ); | ||
| 1954 | |||
| 1955 | app.handle_key( | ||
| 1956 | crossterm::event::KeyCode::Char('y'), | ||
| 1957 | crossterm::event::KeyModifiers::CONTROL, | ||
| 1958 | ); | ||
| 1959 | assert_eq!(app.patch_cursor, target); | ||
| 1960 | assert_eq!(app.patch_scroll as usize, first + 2); | ||
| 1961 | } | ||
| 1962 | |||
| 1963 | /// `w` is the escape hatch for the alignment case: `+`/`-` line up again | ||
| 1964 | /// because a diff line takes one row. Prose is not what that argument is | ||
| 1965 | /// about, so prose keeps wrapping. | ||
| 1966 | #[test] | ||
| 1967 | fn w_turns_off_wrapping_for_the_diff_but_not_for_prose() { | ||
| 1968 | let mut app = app_with_long_diff_lines(1); | ||
| 1969 | app.current_patch.as_mut().unwrap().comments[0].body = "p".repeat(200); | ||
| 1970 | app.patch_width = 37; | ||
| 1971 | app.rebuild_patch_rows(); | ||
| 1972 | |||
| 1973 | let diff_line = logical_index_of_diff_line(&app, 305); | ||
| 1974 | let prose_line = app | ||
| 1975 | .patch_lines | ||
| 1976 | .iter() | ||
| 1977 | .position(|r| r.line.to_string().contains(&"p".repeat(50))) | ||
| 1978 | .expect("the long comment body"); | ||
| 1979 | assert!(display_rows_of(&app, diff_line).len() > 3); | ||
| 1980 | assert!(display_rows_of(&app, prose_line).len() > 3); | ||
| 1981 | |||
| 1982 | app.handle_key( | ||
| 1983 | crossterm::event::KeyCode::Char('w'), | ||
| 1984 | crossterm::event::KeyModifiers::empty(), | ||
| 1985 | ); | ||
| 1986 | |||
| 1987 | assert_eq!( | ||
| 1988 | display_rows_of(&app, diff_line).len(), | ||
| 1989 | 1, | ||
| 1990 | "the diff line still wrapped after `w`" | ||
| 1991 | ); | ||
| 1992 | assert!( | ||
| 1993 | display_rows_of(&app, prose_line).len() > 3, | ||
| 1994 | "`w` stopped prose wrapping too" | ||
| 1995 | ); | ||
| 1996 | } | ||
| 1997 | |||
| 1998 | /// The silent clipping is a defect in its own right and outlives whichever | ||
| 1999 | /// default wins: with wrapping off, a line that does not fit has to say so. | ||
| 2000 | #[test] | ||
| 2001 | fn a_clipped_line_is_marked_as_clipped() { | ||
| 2002 | let mut app = app_with_long_diff_lines(1); | ||
| 2003 | app.patch_width = 37; | ||
| 2004 | app.patch_wrap = false; | ||
| 2005 | app.rebuild_patch_rows(); | ||
| 2006 | |||
| 2007 | let diff_line = logical_index_of_diff_line(&app, 305); | ||
| 2008 | let rows = display_rows_of(&app, diff_line); | ||
| 2009 | assert_eq!(rows.len(), 1); | ||
| 2010 | let text = rows[0].line.to_string(); | ||
| 2011 | assert!( | ||
| 2012 | text.ends_with(TRUNCATION_MARK), | ||
| 2013 | "a clipped line said nothing about being clipped: {:?}", | ||
| 2014 | text | ||
| 2015 | ); | ||
| 2016 | assert!(text.chars().count() <= 37); | ||
| 2017 | |||
| 2018 | // A line that fits is left alone — the mark means something. | ||
| 2019 | let short = app | ||
| 2020 | .patch_lines | ||
| 2021 | .iter() | ||
| 2022 | .position(|r| r.line.to_string() == "Base: main") | ||
| 2023 | .expect("the Base: header row"); | ||
| 2024 | assert!(!display_rows_of(&app, short)[0] | ||
| 2025 | .line | ||
| 2026 | .to_string() | ||
| 2027 | .ends_with(TRUNCATION_MARK)); | ||
| 2028 | } | ||
| 2029 | |||
| 2030 | /// The choice is remembered for the session: rebuilding the rows for a new | ||
| 2031 | /// revision, a new diff mode or a fresh comment must not quietly put it | ||
| 2032 | /// back. | ||
| 2033 | #[test] | ||
| 2034 | fn the_wrap_choice_survives_a_rebuild() { | ||
| 2035 | let mut app = app_with_long_diff_lines(1); | ||
| 2036 | app.patch_width = 37; | ||
| 2037 | app.rebuild_patch_rows(); | ||
| 2038 | |||
| 2039 | app.handle_key( | ||
| 2040 | crossterm::event::KeyCode::Char('w'), | ||
| 2041 | crossterm::event::KeyModifiers::empty(), | ||
| 2042 | ); | ||
| 2043 | assert!(!app.patch_wrap); | ||
| 2044 | |||
| 2045 | app.handle_key( | ||
| 2046 | crossterm::event::KeyCode::Char('d'), | ||
| 2047 | crossterm::event::KeyModifiers::empty(), | ||
| 2048 | ); | ||
| 2049 | app.rebuild_patch_rows(); | ||
| 2050 | assert!(!app.patch_wrap, "the wrap choice was reset by a rebuild"); | ||
| 2051 | assert_eq!( | ||
| 2052 | display_rows_of(&app, logical_index_of_diff_line(&app, 305)).len(), | ||
| 2053 | 1 | ||
| 2054 | ); | ||
| 2055 | } | ||
| 2056 | |||
| 2057 | /// git hands the whole `diff --git ... / index ... / --- a/f` file header | ||
| 2058 | /// over as *one* diff row with newlines inside it. A `Line` cannot hold a | ||
| 2059 | /// newline — ratatui has no row to put the rest on — so those three lines | ||
| 2060 | /// used to arrive mashed into one, and a line whose width counts a `\n` as | ||
| 2061 | /// nothing is a line this pane cannot lay out. One physical line per row. | ||
| 2062 | #[test] | ||
| 2063 | fn a_multi_line_diff_header_becomes_one_row_per_line() { | ||
| 2064 | let mut app = make_app(0, 0); | ||
| 2065 | app.mode = ViewMode::PatchDetail; | ||
| 2066 | app.current_patch = Some(make_patch_with_revisions()); | ||
| 2067 | app.patch_diff = vec![crate::patch::DiffRow { | ||
| 2068 | text: "diff --git a/f.rs b/f.rs\nindex fbbee86..789bcdf 100644\n--- a/f.rs\n".into(), | ||
| 2069 | body: false, | ||
| 2070 | anchor: None, | ||
| 2071 | }]; | ||
| 2072 | app.patch_width = 200; | ||
| 2073 | app.rebuild_patch_rows(); | ||
| 2074 | |||
| 2075 | let start = app | ||
| 2076 | .patch_lines | ||
| 2077 | .iter() | ||
| 2078 | .position(|r| r.line.to_string().starts_with("diff --git")) | ||
| 2079 | .expect("the file header"); | ||
| 2080 | let texts: Vec<String> = app.patch_lines[start..start + 3] | ||
| 2081 | .iter() | ||
| 2082 | .map(|r| r.line.to_string()) | ||
| 2083 | .collect(); | ||
| 2084 | assert_eq!( | ||
| 2085 | texts, | ||
| 2086 | vec![ | ||
| 2087 | "diff --git a/f.rs b/f.rs".to_string(), | ||
| 2088 | "index fbbee86..789bcdf 100644".to_string(), | ||
| 2089 | "--- a/f.rs".to_string(), | ||
| 2090 | ] | ||
| 2091 | ); | ||
| 2092 | assert!( | ||
| 2093 | app.patch_lines.iter().all(|r| !r.line.to_string().contains('\n')), | ||
| 2094 | "a rendered line still holds a newline" | ||
| 2095 | ); | ||
| 2096 | } | ||
| 2097 | |||
| 2098 | /// A body line that carries an anchor keeps it however it is split, so the | ||
| 2099 | /// splitting above cannot cost a comment its target. | ||
| 2100 | #[test] | ||
| 2101 | fn splitting_a_diff_row_keeps_its_anchor_on_every_piece() { | ||
| 2102 | let mut app = make_app(0, 0); | ||
| 2103 | app.mode = ViewMode::PatchDetail; | ||
| 2104 | app.current_patch = Some(make_patch_with_revisions()); | ||
| 2105 | app.patch_diff = vec![crate::patch::DiffRow { | ||
| 2106 | text: "+first\n+second\n".into(), | ||
| 2107 | body: true, | ||
| 2108 | anchor: Some(("src/main.rs".to_string(), 12)), | ||
| 2109 | }]; | ||
| 2110 | app.patch_width = 200; | ||
| 2111 | app.rebuild_patch_rows(); | ||
| 2112 | |||
| 2113 | let anchored: Vec<&RowTarget> = app | ||
| 2114 | .patch_lines | ||
| 2115 | .iter() | ||
| 2116 | .map(|r| &r.target) | ||
| 2117 | .filter(|t| matches!(t, RowTarget::Diff { .. })) | ||
| 2118 | .collect(); | ||
| 2119 | let expected = RowTarget::Diff { | ||
| 2120 | file: "src/main.rs".to_string(), | ||
| 2121 | line: 12, | ||
| 2122 | }; | ||
| 2123 | assert_eq!(anchored, vec![&expected, &expected]); | ||
| 2124 | } | ||
| 2125 | |||
| 2126 | /// The pane is re-wrapped when it is resized, because the rows were built | ||
| 2127 | /// for a width that no longer exists. | ||
| 2128 | #[test] | ||
| 2129 | fn a_resize_re_wraps_the_pane() { | ||
| 2130 | let mut app = app_with_long_diff_lines(1); | ||
| 2131 | app.rebuild_patch_rows(); | ||
| 2132 | |||
| 2133 | render_app_sized(&mut app, 80, 60); | ||
| 2134 | let narrow = app.patch_rows.len(); | ||
| 2135 | render_app_sized(&mut app, 200, 60); | ||
| 2136 | let wide = app.patch_rows.len(); | ||
| 2137 | |||
| 2138 | assert!( | ||
| 2139 | wide < narrow, | ||
| 2140 | "a wider pane needed as many rows ({} vs {})", | ||
| 2141 | wide, | ||
| 2142 | narrow | ||
| 2143 | ); | ||
| 2144 | } | ||
| 2145 | |||
| 2146 | /// A pane too narrow to hold anything must not loop forever or lose the | ||
| 2147 | /// line; the same for a line that is empty. | ||
| 2148 | #[test] | ||
| 2149 | fn a_degenerate_width_still_terminates() { | ||
| 2150 | let mut app = app_with_long_diff_lines(1); | ||
| 2151 | app.patch_width = 1; | ||
| 2152 | app.rebuild_patch_rows(); | ||
| 2153 | assert!(!app.patch_rows.is_empty()); | ||
| 2154 | |||
| 2155 | app.patch_width = 0; | ||
| 2156 | app.rebuild_patch_rows(); | ||
| 2157 | assert_eq!(app.patch_rows.len(), app.patch_lines.len()); | ||
| 2158 | |||
| 2159 | // Blank lines are rows too — dropping them would close the gaps the | ||
| 2160 | // pane uses to separate its sections. | ||
| 2161 | app.patch_width = 20; | ||
| 2162 | app.rebuild_patch_rows(); | ||
| 2163 | let blanks = app | ||
| 2164 | .patch_lines | ||
| 2165 | .iter() | ||
| 2166 | .filter(|r| r.line.to_string().is_empty()) | ||
| 2167 | .count(); | ||
| 2168 | assert!(blanks > 0); | ||
| 2169 | assert_eq!( | ||
| 2170 | app.patch_rows | ||
| 2171 | .iter() | ||
| 2172 | .filter(|r| r.line.to_string().is_empty()) | ||
| 2173 | .count(), | ||
| 2174 | blanks | ||
| 2175 | ); | ||
| 2176 | } | ||
| 2177 | |||
| 2178 | // ── Timestamps (952390ad) ──────────────────────────────────────────── | ||
| 2179 | // | ||
| 2180 | // The stored form is `2026-01-01T00:00:00Z` — up to 35 characters, nine of | ||
| 2181 | // them nanoseconds. `b2a57996` settled on `YYYY-MM-DD HH:MM` for the web, | ||
| 2182 | // and the two surfaces have to agree: a reader moves between them and a | ||
| 2183 | // screenshot from one is pasted next to output from the other. The TUI has | ||
| 2184 | // no hover, so the short form is all there is; nothing is lost, because | ||
| 2185 | // `--json` and `show` still carry the stored value in full. | ||
| 2186 | |||
| 2187 | fn assert_buffer_lacks(buf: &Buffer, unwanted: &str) { | ||
| 2188 | let text = buffer_to_string(buf); | ||
| 2189 | assert!( | ||
| 2190 | !text.contains(unwanted), | ||
| 2191 | "expected buffer NOT to contain {:?}, but it is in:\n{}", | ||
| 2192 | unwanted, | ||
| 2193 | text | ||
| 2194 | ); | ||
| 2195 | } | ||
| 2196 | |||
| 2197 | #[test] | ||
| 2198 | fn an_issues_created_date_is_shown_to_the_minute() { | ||
| 2199 | let mut app = make_app(3, 0); | ||
| 2200 | app.pane = Pane::Detail; | ||
| 2201 | app.list_state.select(Some(0)); | ||
| 2202 | |||
| 2203 | let buf = render_app_sized(&mut app, 120, 24); | ||
| 2204 | assert_buffer_contains(&buf, "Created: 2026-01-01 00:00"); | ||
| 2205 | assert_buffer_lacks(&buf, "2026-01-01T00:00:00Z"); | ||
| 2206 | } | ||
| 2207 | |||
| 2208 | #[test] | ||
| 2209 | fn an_issue_comments_date_is_shown_to_the_minute() { | ||
| 2210 | let mut app = make_app(1, 0); | ||
| 2211 | app.issues[0].comments = vec![crate::state::Comment { | ||
| 2212 | author: test_author(), | ||
| 2213 | body: "a remark".into(), | ||
| 2214 | timestamp: "2026-02-03T04:05:06.789012345+00:00".into(), | ||
| 2215 | commit_id: Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(), | ||
| 2216 | edited: false, | ||
| 2217 | deleted: false, | ||
| 2218 | }]; | ||
| 2219 | app.pane = Pane::Detail; | ||
| 2220 | app.list_state.select(Some(0)); | ||
| 2221 | |||
| 2222 | let buf = render_app_sized(&mut app, 120, 24); | ||
| 2223 | assert_buffer_contains(&buf, "(2026-02-03 04:05)"); | ||
| 2224 | assert_buffer_lacks(&buf, "789012345"); | ||
| 2225 | } | ||
| 2226 | |||
| 2227 | #[test] | ||
| 2228 | fn a_linked_commits_date_is_shown_to_the_minute() { | ||
| 2229 | let mut app = make_app(1, 0); | ||
| 2230 | app.issues[0].linked_commits = vec![crate::state::LinkedCommit { | ||
| 2231 | commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), | ||
| 2232 | event_author: test_author(), | ||
| 2233 | event_timestamp: "2026-02-03T04:05:06.789012345+00:00".into(), | ||
| 2234 | }]; | ||
| 2235 | app.pane = Pane::Detail; | ||
| 2236 | app.list_state.select(Some(0)); | ||
| 2237 | |||
| 2238 | let buf = render_app_sized(&mut app, 200, 24); | ||
| 2239 | assert_buffer_contains(&buf, "2026-02-03 04:05"); | ||
| 2240 | assert_buffer_lacks(&buf, "789012345"); | ||
| 2241 | } | ||
| 2242 | |||
| 2243 | /// The revision list, the reviews and the thread comments all carry a | ||
| 2244 | /// stored timestamp, and all three are on one pane — so all three have to | ||
| 2245 | /// be shortened or the pane shows one field two ways. | ||
| 2246 | #[test] | ||
| 2247 | fn every_date_on_the_patch_detail_pane_is_shown_to_the_minute() { | ||
| 2248 | let mut app = make_app(0, 0); | ||
| 2249 | app.mode = ViewMode::PatchDetail; | ||
| 2250 | app.current_patch = Some(make_patch_with_revisions()); | ||
| 2251 | app.patch_diff.clear(); | ||
| 2252 | app.rebuild_patch_rows(); | ||
| 2253 | |||
| 2254 | let buf = render_app_sized(&mut app, 200, 60); | ||
| 2255 | assert_buffer_contains(&buf, "2026-01-01 00:00"); // revision r1 | ||
| 2256 | assert_buffer_contains(&buf, "2026-01-02 00:00"); // revision r2 | ||
| 2257 | assert_buffer_contains(&buf, "2026-01-04 00:00"); // the review | ||
| 2258 | assert_buffer_contains(&buf, "(2026-01-05 00:00)"); // the thread comment | ||
| 2259 | assert_buffer_lacks(&buf, "T00:00:00Z"); | ||
| 2260 | } | ||
| 2261 | |||
| 2262 | #[test] | ||
| 2263 | fn the_event_list_and_event_detail_show_dates_to_the_minute() { | ||
| 2264 | let mut app = make_app(3, 0); | ||
| 2265 | app.event_history = make_test_event_history(); | ||
| 2266 | app.event_list_state.select(Some(0)); | ||
| 2267 | |||
| 2268 | app.mode = ViewMode::CommitList; | ||
| 2269 | let buf = render_app_sized(&mut app, 120, 24); | ||
| 2270 | assert_buffer_contains(&buf, "2026-01-01 00:00"); | ||
| 2271 | assert_buffer_lacks(&buf, "2026-01-01T00:00:00Z"); | ||
| 2272 | |||
| 2273 | app.mode = ViewMode::CommitDetail; | ||
| 2274 | let buf = render_app_sized(&mut app, 120, 24); | ||
| 2275 | assert_buffer_contains(&buf, "2026-01-01 00:00"); | ||
| 2276 | assert_buffer_lacks(&buf, "2026-01-01T00:00:00Z"); | ||
| 2277 | } | ||
| 2278 | |||
| 2279 | /// A value this project did not write is still a fact about the object. | ||
| 2280 | /// Printing it as it stands beats printing a placeholder that hides the one | ||
| 2281 | /// thing which could explain the row — the same rule the web took. | ||
| 2282 | #[test] | ||
| 2283 | fn an_unparseable_stored_date_is_shown_as_it_stands() { | ||
| 2284 | let mut app = make_app(1, 0); | ||
| 2285 | app.issues[0].created_at = "whenever".into(); | ||
| 2286 | app.pane = Pane::Detail; | ||
| 2287 | app.list_state.select(Some(0)); | ||
| 2288 | |||
| 2289 | let buf = render_app_sized(&mut app, 120, 24); | ||
| 2290 | assert_buffer_contains(&buf, "Created: whenever"); | ||
| 2291 | } | ||
| 2292 | |||
| 1674 | #[test] | 2293 | #[test] |
| 1675 | fn test_linked_patch_for_selected() { | 2294 | fn test_linked_patch_for_selected() { |
| 1676 | let mut app = test_app(); | 2295 | let mut app = test_app(); |
src/tui/state.rs
| Old | New | ||
|---|---|---|---|
| @@ -7,7 +7,7 @@ use crate::abbrev::Abbrev; | |||
| 7 | use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; | 7 | use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; |
| 8 | 8 | ||
| 9 | use super::tips::Tips; | 9 | use super::tips::Tips; |
| 10 | use super::widgets::{DetailRow, RowTarget}; | 10 | use super::widgets::{DetailRow, DisplayRow, RowTarget}; |
| 11 | 11 | ||
| 12 | #[derive(Debug, PartialEq)] | 12 | #[derive(Debug, PartialEq)] |
| 13 | pub(crate) enum Pane { | 13 | pub(crate) enum Pane { |
| @@ -130,15 +130,35 @@ pub(crate) struct App { | |||
| 130 | /// comment on it would use. Rows rather than a string because the cursor | 130 | /// comment on it would use. Rows rather than a string because the cursor |
| 131 | /// has to be able to say which line of which file it is sitting on. | 131 | /// has to be able to say which line of which file it is sitting on. |
| 132 | pub(crate) patch_diff: Vec<crate::patch::DiffRow>, | 132 | pub(crate) patch_diff: Vec<crate::patch::DiffRow>, |
| 133 | /// Every line the patch detail pane renders, with what a write key would | 133 | /// Every logical line the patch detail pane renders, with what a write key |
| 134 | /// act on. Built when the view is built rather than during a draw: the key | 134 | /// would act on. Built when the view is built rather than during a draw: |
| 135 | /// handler needs it, and the key handler never renders. | 135 | /// the key handler needs it, and the key handler never renders. |
| 136 | pub(crate) patch_rows: Vec<DetailRow>, | 136 | pub(crate) patch_lines: Vec<DetailRow>, |
| 137 | /// Which of `patch_rows` the cursor is on. | 137 | /// Those lines laid out for the pane's current width — one entry per screen |
| 138 | /// row, each pointing back at the line it came from. | ||
| 139 | pub(crate) patch_rows: Vec<DisplayRow>, | ||
| 140 | /// For each logical line, the half-open range of `patch_rows` it occupies. | ||
| 141 | /// Kept beside the rows so the scroll can ask where a line is without a | ||
| 142 | /// scan on every keypress. | ||
| 143 | patch_spans: Vec<(usize, usize)>, | ||
| 144 | /// Which of `patch_lines` the cursor is on. | ||
| 145 | /// | ||
| 146 | /// A logical line, not a screen row. `j` therefore crosses a wrapped line | ||
| 147 | /// in one press — vim's default, which is why `gj` exists — and `c` anchors | ||
| 148 | /// to the line the reader believes they are on however it happened to wrap. | ||
| 138 | pub(crate) patch_cursor: usize, | 149 | pub(crate) patch_cursor: usize, |
| 139 | /// Rows the detail pane last had room for, so the scroll can follow the | 150 | /// Rows the detail pane last had room for, so the scroll can follow the |
| 140 | /// cursor. Only the renderer knows this; it is recorded on the way past. | 151 | /// cursor. Only the renderer knows this; it is recorded on the way past. |
| 141 | pub(crate) patch_viewport: u16, | 152 | pub(crate) patch_viewport: u16, |
| 153 | /// Columns the detail pane last had room for, and so the width its rows | ||
| 154 | /// were laid out for. Recorded by the renderer, like the height above. | ||
| 155 | pub(crate) patch_width: u16, | ||
| 156 | /// Whether the diff wraps. On by default: this pane has no column — the | ||
| 157 | /// cursor is a single index and there is no horizontal movement anywhere in | ||
| 158 | /// the TUI — so the only cost of wrapping is `+`/`-` alignment, against | ||
| 159 | /// text disappearing off the right edge of a review tool. `w` is the escape | ||
| 160 | /// hatch for the alignment case, and the choice sticks for the session. | ||
| 161 | pub(crate) patch_wrap: bool, | ||
| 142 | /// When set, the diff on screen is the change that answered this comment | 162 | /// When set, the diff on screen is the change that answered this comment |
| 143 | /// rather than the revision's own diff. | 163 | /// rather than the revision's own diff. |
| 144 | pub(crate) patch_answers: Option<String>, | 164 | pub(crate) patch_answers: Option<String>, |
| @@ -192,9 +212,13 @@ impl App { | |||
| 192 | event_list_state: ListState::default(), | 212 | event_list_state: ListState::default(), |
| 193 | current_patch: None, | 213 | current_patch: None, |
| 194 | patch_diff: Vec::new(), | 214 | patch_diff: Vec::new(), |
| 215 | patch_lines: Vec::new(), | ||
| 195 | patch_rows: Vec::new(), | 216 | patch_rows: Vec::new(), |
| 217 | patch_spans: Vec::new(), | ||
| 196 | patch_cursor: 0, | 218 | patch_cursor: 0, |
| 197 | patch_viewport: 20, | 219 | patch_viewport: 20, |
| 220 | patch_width: 0, | ||
| 221 | patch_wrap: true, | ||
| 198 | patch_answers: None, | 222 | patch_answers: None, |
| 199 | patch_scroll: 0, | 223 | patch_scroll: 0, |
| 200 | patch_revision_idx: 0, | 224 | patch_revision_idx: 0, |
| @@ -228,23 +252,49 @@ impl App { | |||
| 228 | /// have to be the rows the reader was looking at when they pressed the | 252 | /// have to be the rows the reader was looking at when they pressed the |
| 229 | /// key, not the rows a subsequent redraw would have produced. | 253 | /// key, not the rows a subsequent redraw would have produced. |
| 230 | pub(crate) fn rebuild_patch_rows(&mut self) { | 254 | pub(crate) fn rebuild_patch_rows(&mut self) { |
| 231 | self.patch_rows = super::widgets::build_patch_detail_rows(self); | 255 | self.patch_lines = super::widgets::build_patch_detail_rows(self); |
| 232 | if self.patch_cursor >= self.patch_rows.len() { | 256 | if self.patch_cursor >= self.patch_lines.len() { |
| 233 | self.patch_cursor = self.patch_rows.len().saturating_sub(1); | 257 | self.patch_cursor = self.patch_lines.len().saturating_sub(1); |
| 258 | } | ||
| 259 | self.rewrap(); | ||
| 260 | } | ||
| 261 | |||
| 262 | /// Lay the logical lines out for the pane's current width. | ||
| 263 | /// | ||
| 264 | /// Called from the renderer when the width changes and from | ||
| 265 | /// [`App::rebuild_patch_rows`] when the lines themselves do. It touches no | ||
| 266 | /// repository state and cannot move the cursor — only where the cursor's | ||
| 267 | /// line happens to sit on screen. | ||
| 268 | pub(crate) fn rewrap(&mut self) { | ||
| 269 | self.patch_rows = | ||
| 270 | super::widgets::wrap_detail_rows(&self.patch_lines, self.patch_width, self.patch_wrap); | ||
| 271 | self.patch_spans = vec![(0, 0); self.patch_lines.len()]; | ||
| 272 | for (i, row) in self.patch_rows.iter().enumerate() { | ||
| 273 | let span = &mut self.patch_spans[row.logical]; | ||
| 274 | if span.1 == 0 { | ||
| 275 | *span = (i, i + 1); | ||
| 276 | } else { | ||
| 277 | span.1 = i + 1; | ||
| 278 | } | ||
| 234 | } | 279 | } |
| 235 | self.follow_cursor(); | 280 | self.follow_cursor(); |
| 236 | } | 281 | } |
| 237 | 282 | ||
| 238 | /// The row the cursor is on in the patch detail pane. | 283 | /// The half-open range of display rows a logical line occupies. |
| 284 | pub(crate) fn line_span(&self, logical: usize) -> (usize, usize) { | ||
| 285 | self.patch_spans.get(logical).copied().unwrap_or((0, 0)) | ||
| 286 | } | ||
| 287 | |||
| 288 | /// The line the cursor is on in the patch detail pane. | ||
| 239 | pub(crate) fn cursor_target(&self) -> RowTarget { | 289 | pub(crate) fn cursor_target(&self) -> RowTarget { |
| 240 | self.patch_rows | 290 | self.patch_lines |
| 241 | .get(self.patch_cursor) | 291 | .get(self.patch_cursor) |
| 242 | .map(|r| r.target.clone()) | 292 | .map(|r| r.target.clone()) |
| 243 | .unwrap_or_default() | 293 | .unwrap_or_default() |
| 244 | } | 294 | } |
| 245 | 295 | ||
| 246 | fn move_cursor(&mut self, delta: i32) { | 296 | fn move_cursor(&mut self, delta: i32) { |
| 247 | let len = self.patch_rows.len(); | 297 | let len = self.patch_lines.len(); |
| 248 | if len == 0 { | 298 | if len == 0 { |
| 249 | self.patch_cursor = 0; | 299 | self.patch_cursor = 0; |
| 250 | return; | 300 | return; |
| @@ -257,16 +307,57 @@ impl App { | |||
| 257 | self.follow_cursor(); | 307 | self.follow_cursor(); |
| 258 | } | 308 | } |
| 259 | 309 | ||
| 260 | /// Keep the cursor on screen without moving the view any further than it | 310 | /// Scroll display rows without touching the cursor. |
| 261 | /// has to: a reader walking a diff should not have the text jump. | 311 | /// |
| 312 | /// The counterpart to moving by logical line, and the reason a line taller | ||
| 313 | /// than the pane is still readable: `j` would jump the view straight past | ||
| 314 | /// its middle. Vim keeps the two separate for the same reason, and calls | ||
| 315 | /// them `Ctrl-E` and `Ctrl-Y`. | ||
| 316 | fn scroll_rows(&mut self, delta: i32) { | ||
| 317 | let max = self.patch_rows.len().saturating_sub(1); | ||
| 318 | let top = self.patch_scroll as usize; | ||
| 319 | let new = if delta > 0 { | ||
| 320 | (top + delta as usize).min(max) | ||
| 321 | } else { | ||
| 322 | top.saturating_sub((-delta) as usize) | ||
| 323 | }; | ||
| 324 | self.patch_scroll = new as u16; | ||
| 325 | } | ||
| 326 | |||
| 327 | /// Bring the cursor's line into view, without moving further than it has to | ||
| 328 | /// and without assuming the line fits. | ||
| 329 | /// | ||
| 330 | /// A logical line can wrap to more rows than the pane is tall. Scrolling to | ||
| 331 | /// put such a line "on screen" cannot mean showing all of it, so it means | ||
| 332 | /// showing its start and leaving the rest to `Ctrl-E`; and once the reader | ||
| 333 | /// is part way through one, nothing here may drag them back to the top. | ||
| 262 | fn follow_cursor(&mut self) { | 334 | fn follow_cursor(&mut self) { |
| 263 | let height = self.patch_viewport.max(1) as usize; | 335 | let height = self.patch_viewport.max(1) as usize; |
| 336 | let (first, end) = self.line_span(self.patch_cursor); | ||
| 337 | if end == 0 { | ||
| 338 | self.patch_scroll = 0; | ||
| 339 | return; | ||
| 340 | } | ||
| 341 | let last = end - 1; | ||
| 264 | let top = self.patch_scroll as usize; | 342 | let top = self.patch_scroll as usize; |
| 265 | if self.patch_cursor < top { | 343 | |
| 266 | self.patch_scroll = self.patch_cursor as u16; | 344 | // Already wholly in view. |
| 267 | } else if self.patch_cursor >= top + height { | 345 | if first >= top && last < top + height { |
| 268 | self.patch_scroll = (self.patch_cursor + 1 - height) as u16; | 346 | return; |
| 347 | } | ||
| 348 | // Taller than the pane, and some of it is on screen: the reader is | ||
| 349 | // walking through it a row at a time, so leave the view where they put | ||
| 350 | // it rather than snapping back to the line's first row. | ||
| 351 | if end - first >= height && first < top + height && last >= top { | ||
| 352 | return; | ||
| 269 | } | 353 | } |
| 354 | // Otherwise scroll the least that helps. `.min(first)` is what keeps an | ||
| 355 | // over-tall line's start visible instead of its end. | ||
| 356 | self.patch_scroll = if first < top { | ||
| 357 | first as u16 | ||
| 358 | } else { | ||
| 359 | ((last + 1 - height).min(first)) as u16 | ||
| 360 | }; | ||
| 270 | } | 361 | } |
| 271 | 362 | ||
| 272 | /// Reset what a change of revision or diff mode invalidates. | 363 | /// Reset what a change of revision or diff mode invalidates. |
| @@ -357,6 +448,7 @@ impl App { | |||
| 357 | self.pane = Pane::ItemList; | 448 | self.pane = Pane::ItemList; |
| 358 | self.current_patch = None; | 449 | self.current_patch = None; |
| 359 | self.patch_diff.clear(); | 450 | self.patch_diff.clear(); |
| 451 | self.patch_lines.clear(); | ||
| 360 | self.patch_rows.clear(); | 452 | self.patch_rows.clear(); |
| 361 | self.patch_revision_idx = 0; | 453 | self.patch_revision_idx = 0; |
| 362 | self.patch_interdiff_mode = false; | 454 | self.patch_interdiff_mode = false; |
| @@ -367,6 +459,16 @@ impl App { | |||
| 367 | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { | 459 | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { |
| 368 | return KeyAction::Quit; | 460 | return KeyAction::Quit; |
| 369 | } | 461 | } |
| 462 | // Rows, not lines, and the cursor stays where it is — the only | ||
| 463 | // way through a line that wrapped taller than the pane. | ||
| 464 | KeyCode::Char('e') if modifiers.contains(KeyModifiers::CONTROL) => { | ||
| 465 | self.scroll_rows(1); | ||
| 466 | return KeyAction::Continue; | ||
| 467 | } | ||
| 468 | KeyCode::Char('y') if modifiers.contains(KeyModifiers::CONTROL) => { | ||
| 469 | self.scroll_rows(-1); | ||
| 470 | return KeyAction::Continue; | ||
| 471 | } | ||
| 370 | KeyCode::Char('j') | KeyCode::Down => { | 472 | KeyCode::Char('j') | KeyCode::Down => { |
| 371 | self.move_cursor(1); | 473 | self.move_cursor(1); |
| 372 | return KeyAction::Continue; | 474 | return KeyAction::Continue; |
| @@ -416,6 +518,14 @@ impl App { | |||
| 416 | self.reset_patch_view(); | 518 | self.reset_patch_view(); |
| 417 | return KeyAction::Reload; // signal to regenerate diff | 519 | return KeyAction::Reload; // signal to regenerate diff |
| 418 | } | 520 | } |
| 521 | // The escape hatch for reading `+`/`-` in column. Deliberately | ||
| 522 | // not reset by `reset_patch_view`: a reader who turned wrapping | ||
| 523 | // off did not mean "until the next revision". | ||
| 524 | KeyCode::Char('w') => { | ||
| 525 | self.patch_wrap = !self.patch_wrap; | ||
| 526 | self.rewrap(); | ||
| 527 | return KeyAction::Continue; | ||
| 528 | } | ||
| 419 | _ => return KeyAction::Continue, | 529 | _ => return KeyAction::Continue, |
| 420 | } | 530 | } |
| 421 | } | 531 | } |
src/tui/widgets.rs
| Old | New | ||
|---|---|---|---|
| @@ -8,6 +8,16 @@ use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus}; | |||
| 8 | 8 | ||
| 9 | use super::state::{App, InputMode, ListMode, Pane, StatusFilter, ViewMode}; | 9 | use super::state::{App, InputMode, ListMode, Pane, StatusFilter, ViewMode}; |
| 10 | 10 | ||
| 11 | /// A stored timestamp as the dashboard shows it: `YYYY-MM-DD HH:MM`, the same | ||
| 12 | /// rendering the web settled on in `b2a57996`. | ||
| 13 | /// | ||
| 14 | /// A terminal has no hover, so unlike the web there is nowhere to hang the | ||
| 15 | /// stored value and the short form is all that goes on screen. Nothing is | ||
| 16 | /// lost: `issue show`, `patch show` and `--json` still carry it in full. | ||
| 17 | fn short_time(stored: &str) -> String { | ||
| 18 | crate::timestamp::Timestamp::new(stored).short | ||
| 19 | } | ||
| 20 | |||
| 11 | /// The lines to render for a comment body: the text as it stands, or the | 21 | /// The lines to render for a comment body: the text as it stands, or the |
| 12 | /// tombstone when it was deleted. | 22 | /// tombstone when it was deleted. |
| 13 | /// | 23 | /// |
| @@ -86,7 +96,11 @@ pub(crate) fn format_event_detail( | |||
| 86 | 96 | ||
| 87 | let mut detail = format!( | 97 | let mut detail = format!( |
| 88 | "Commit: {}\nAuthor: {} <{}>\nDate: {}\nType: {}\n", | 98 | "Commit: {}\nAuthor: {} <{}>\nDate: {}\nType: {}\n", |
| 89 | short_oid, event.author.name, event.author.email, event.timestamp, action_label, | 99 | short_oid, |
| 100 | event.author.name, | ||
| 101 | event.author.email, | ||
| 102 | short_time(&event.timestamp), | ||
| 103 | action_label, | ||
| 90 | ); | 104 | ); |
| 91 | 105 | ||
| 92 | // Action-specific payload | 106 | // Action-specific payload |
| @@ -218,14 +232,29 @@ pub(crate) enum RowTarget { | |||
| 218 | Comment { oid: String, resolved: bool }, | 232 | Comment { oid: String, resolved: bool }, |
| 219 | } | 233 | } |
| 220 | 234 | ||
| 221 | /// A rendered line of the patch detail pane, and what it stands for. | 235 | /// Which of the pane's two kinds of content a line is. |
| 236 | /// | ||
| 237 | /// The distinction exists for one key. Wrapping is on for everything by | ||
| 238 | /// default, but the case against it — that `+`/`-` stop lining up — is an | ||
| 239 | /// argument about a diff and not about an issue body or a review, so `w` turns | ||
| 240 | /// it off for [`RowKind::Diff`] alone. | ||
| 241 | #[derive(Debug, Clone, Copy, PartialEq)] | ||
| 242 | pub(crate) enum RowKind { | ||
| 243 | Prose, | ||
| 244 | Diff, | ||
| 245 | } | ||
| 246 | |||
| 247 | /// A logical line of the patch detail pane, and what it stands for. | ||
| 222 | /// | 248 | /// |
| 223 | /// Built once when the view is built rather than during a draw: the key | 249 | /// Built once when the view is built rather than during a draw: the key |
| 224 | /// handler has to know what the cursor is on, and the key handler never draws. | 250 | /// handler has to know what the cursor is on, and the key handler never draws. |
| 251 | /// The cursor indexes these, not the display rows below — so what `c` acts on | ||
| 252 | /// does not depend on how wide the pane happened to be. | ||
| 225 | #[derive(Debug, Clone)] | 253 | #[derive(Debug, Clone)] |
| 226 | pub(crate) struct DetailRow { | 254 | pub(crate) struct DetailRow { |
| 227 | pub(crate) line: Line<'static>, | 255 | pub(crate) line: Line<'static>, |
| 228 | pub(crate) target: RowTarget, | 256 | pub(crate) target: RowTarget, |
| 257 | pub(crate) kind: RowKind, | ||
| 229 | } | 258 | } |
| 230 | 259 | ||
| 231 | impl DetailRow { | 260 | impl DetailRow { |
| @@ -233,15 +262,38 @@ impl DetailRow { | |||
| 233 | DetailRow { | 262 | DetailRow { |
| 234 | line, | 263 | line, |
| 235 | target: RowTarget::None, | 264 | target: RowTarget::None, |
| 265 | kind: RowKind::Prose, | ||
| 236 | } | 266 | } |
| 237 | } | 267 | } |
| 238 | } | 268 | } |
| 239 | 269 | ||
| 270 | /// One screen row of the patch detail pane: a slice of a logical line, wide | ||
| 271 | /// enough to fit and no wider. | ||
| 272 | /// | ||
| 273 | /// `logical` is the whole trick. A diff line that wraps to three rows becomes | ||
| 274 | /// three `DisplayRow`s all pointing at the one line that says | ||
| 275 | /// `Diff { line: 305 }` — so the highlight covers the run, and `c` means 305 | ||
| 276 | /// from any part of it. The target is deliberately *not* copied down here: | ||
| 277 | /// one owner means the rows cannot disagree with the line they came from. | ||
| 278 | #[derive(Debug, Clone)] | ||
| 279 | pub(crate) struct DisplayRow { | ||
| 280 | pub(crate) line: Line<'static>, | ||
| 281 | /// Index into the logical lines this was wrapped from. | ||
| 282 | pub(crate) logical: usize, | ||
| 283 | } | ||
| 284 | |||
| 285 | /// What a line that had to be cut short ends with. | ||
| 286 | /// | ||
| 287 | /// Wrapping is the default, so this is only reached with `w` pressed — but a | ||
| 288 | /// line that simply stops, with nothing to say more was there, is the defect | ||
| 289 | /// that started this and it must not survive in the one mode that still clips. | ||
| 290 | pub(crate) const TRUNCATION_MARK: &str = "›"; | ||
| 291 | |||
| 240 | /// Accumulator for the detail pane. | 292 | /// Accumulator for the detail pane. |
| 241 | /// | 293 | /// |
| 242 | /// Most of the pane is prose with nothing to act on, so `push` takes a bare | 294 | /// Most of the pane is prose with nothing to act on, so `push` takes a bare |
| 243 | /// line; the two places the cursor can do something — an inline comment and a | 295 | /// line; the two places the cursor can do something — an inline comment and a |
| 244 | /// new-side line of the diff — say so with `push_target`. | 296 | /// new-side line of the diff — say so with `push_target` and `push_diff`. |
| 245 | struct Rows(Vec<DetailRow>); | 297 | struct Rows(Vec<DetailRow>); |
| 246 | 298 | ||
| 247 | impl Rows { | 299 | impl Rows { |
| @@ -254,10 +306,131 @@ impl Rows { | |||
| 254 | } | 306 | } |
| 255 | 307 | ||
| 256 | fn push_target(&mut self, line: Line<'static>, target: RowTarget) { | 308 | fn push_target(&mut self, line: Line<'static>, target: RowTarget) { |
| 257 | self.0.push(DetailRow { line, target }); | 309 | self.0.push(DetailRow { |
| 310 | line, | ||
| 311 | target, | ||
| 312 | kind: RowKind::Prose, | ||
| 313 | }); | ||
| 314 | } | ||
| 315 | |||
| 316 | fn push_diff(&mut self, line: Line<'static>, target: RowTarget) { | ||
| 317 | self.0.push(DetailRow { | ||
| 318 | line, | ||
| 319 | target, | ||
| 320 | kind: RowKind::Diff, | ||
| 321 | }); | ||
| 258 | } | 322 | } |
| 259 | } | 323 | } |
| 260 | 324 | ||
| 325 | /// Lay the pane's logical lines out for a pane `width` columns wide. | ||
| 326 | /// | ||
| 327 | /// This is the mapping `Paragraph::wrap` does not expose: every row knows which | ||
| 328 | /// logical line it came from, so the cursor can stay a logical index while the | ||
| 329 | /// scroll stays a row offset. A width of zero means the pane has not been drawn | ||
| 330 | /// yet and nothing is known about how much room there is, so the lines pass | ||
| 331 | /// through one for one. | ||
| 332 | pub(crate) fn wrap_detail_rows(lines: &[DetailRow], width: u16, wrap_diff: bool) -> Vec<DisplayRow> { | ||
| 333 | let width = width as usize; | ||
| 334 | let mut out = Vec::with_capacity(lines.len()); | ||
| 335 | for (logical, row) in lines.iter().enumerate() { | ||
| 336 | let wrapped = if width == 0 { | ||
| 337 | vec![row.line.clone()] | ||
| 338 | } else if row.kind == RowKind::Diff && !wrap_diff { | ||
| 339 | vec![truncate_line(&row.line, width)] | ||
| 340 | } else { | ||
| 341 | wrap_line(&row.line, width) | ||
| 342 | }; | ||
| 343 | for line in wrapped { | ||
| 344 | out.push(DisplayRow { line, logical }); | ||
| 345 | } | ||
| 346 | } | ||
| 347 | out | ||
| 348 | } | ||
| 349 | |||
| 350 | /// Break one line into rows no wider than `width`, keeping every character and | ||
| 351 | /// the style it was written in. | ||
| 352 | /// | ||
| 353 | /// The break is at the column, not at a word: this pane shows code as often as | ||
| 354 | /// prose, and a reader following an anchor to `file:line` needs the row to | ||
| 355 | /// correspond to the columns of the line. An empty line still yields one row — | ||
| 356 | /// the pane uses blanks to separate its sections. | ||
| 357 | fn wrap_line(line: &Line<'static>, width: usize) -> Vec<Line<'static>> { | ||
| 358 | use unicode_width::UnicodeWidthChar; | ||
| 359 | |||
| 360 | let mut rows: Vec<Vec<Span<'static>>> = vec![Vec::new()]; | ||
| 361 | let mut used = 0usize; | ||
| 362 | for span in &line.spans { | ||
| 363 | let style = span.style; | ||
| 364 | let mut buf = String::new(); | ||
| 365 | for ch in span.content.chars() { | ||
| 366 | let cw = ch.width().unwrap_or(0); | ||
| 367 | // `used > 0` keeps a character wider than the whole pane on a row | ||
| 368 | // of its own rather than looping forever trying to fit it. | ||
| 369 | if used + cw > width && used > 0 { | ||
| 370 | if !buf.is_empty() { | ||
| 371 | rows.last_mut() | ||
| 372 | .unwrap() | ||
| 373 | .push(Span::styled(std::mem::take(&mut buf), style)); | ||
| 374 | } | ||
| 375 | rows.push(Vec::new()); | ||
| 376 | used = 0; | ||
| 377 | } | ||
| 378 | buf.push(ch); | ||
| 379 | used += cw; | ||
| 380 | } | ||
| 381 | if !buf.is_empty() { | ||
| 382 | rows.last_mut().unwrap().push(Span::styled(buf, style)); | ||
| 383 | } | ||
| 384 | } | ||
| 385 | rows.into_iter() | ||
| 386 | .map(|spans| Line::from(spans).style(line.style)) | ||
| 387 | .collect() | ||
| 388 | } | ||
| 389 | |||
| 390 | /// Cut a line down to `width`, ending it with [`TRUNCATION_MARK`] so that what | ||
| 391 | /// was dropped is visible as having been dropped. | ||
| 392 | fn truncate_line(line: &Line<'static>, width: usize) -> Line<'static> { | ||
| 393 | use unicode_width::UnicodeWidthChar; | ||
| 394 | |||
| 395 | if line.width() <= width { | ||
| 396 | return line.clone(); | ||
| 397 | } | ||
| 398 | // No room for both a character and the mark; say only that there is more. | ||
| 399 | if width == 1 { | ||
| 400 | return Line::from(vec![Span::styled( | ||
| 401 | TRUNCATION_MARK, | ||
| 402 | Style::default().fg(Color::DarkGray), | ||
| 403 | )]) | ||
| 404 | .style(line.style); | ||
| 405 | } | ||
| 406 | |||
| 407 | let keep = width - 1; | ||
| 408 | let mut used = 0usize; | ||
| 409 | let mut spans: Vec<Span<'static>> = Vec::new(); | ||
| 410 | 'outer: for span in &line.spans { | ||
| 411 | let mut buf = String::new(); | ||
| 412 | for ch in span.content.chars() { | ||
| 413 | let cw = ch.width().unwrap_or(0); | ||
| 414 | if used + cw > keep { | ||
| 415 | if !buf.is_empty() { | ||
| 416 | spans.push(Span::styled(buf, span.style)); | ||
| 417 | } | ||
| 418 | break 'outer; | ||
| 419 | } | ||
| 420 | buf.push(ch); | ||
| 421 | used += cw; | ||
| 422 | } | ||
| 423 | if !buf.is_empty() { | ||
| 424 | spans.push(Span::styled(buf, span.style)); | ||
| 425 | } | ||
| 426 | } | ||
| 427 | spans.push(Span::styled( | ||
| 428 | TRUNCATION_MARK, | ||
| 429 | Style::default().fg(Color::DarkGray), | ||
| 430 | )); | ||
| 431 | Line::from(spans).style(line.style) | ||
| 432 | } | ||
| 433 | |||
| 261 | pub(crate) fn ui(frame: &mut Frame, app: &mut App, repo: Option<&Repository>) { | 434 | pub(crate) fn ui(frame: &mut Frame, app: &mut App, repo: Option<&Repository>) { |
| 262 | let chunks = Layout::default() | 435 | let chunks = Layout::default() |
| 263 | .direction(Direction::Vertical) | 436 | .direction(Direction::Vertical) |
| @@ -429,21 +602,32 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect, repo: Option<&Rep | |||
| 429 | 602 | ||
| 430 | // Handle patch detail mode | 603 | // Handle patch detail mode |
| 431 | if app.mode == ViewMode::PatchDetail { | 604 | if app.mode == ViewMode::PatchDetail { |
| 432 | // The rows are built by the key handler, not here, so that `c` and `x` | 605 | // The logical lines are built by the key handler, not here, so that `c` |
| 433 | // act on the same row the reader is looking at. A draw that rebuilt | 606 | // and `x` act on the same line the reader is looking at. A draw that |
| 434 | // them could disagree with the last keypress. | 607 | // rebuilt them could disagree with the last keypress. |
| 608 | // | ||
| 609 | // Laying those lines out into display rows *is* done here, because how | ||
| 610 | // many rows a line needs is a fact about this pane at this size and | ||
| 611 | // nothing else knows it. That is safe only because the cursor indexes | ||
| 612 | // the logical lines: re-wrapping cannot move what a key would act on. | ||
| 435 | // | 613 | // |
| 436 | // Recording the height is the one thing only the renderer knows, and | 614 | // Recording the height and width is the one thing only the renderer |
| 437 | // the scroll needs it to follow the cursor. It moves no ref and reads | 615 | // knows. It moves no ref and reads no object: a draw stays a draw. |
| 438 | // no object: a draw stays a draw. | ||
| 439 | app.patch_viewport = area.height.saturating_sub(2); | 616 | app.patch_viewport = area.height.saturating_sub(2); |
| 617 | let width = area.width.saturating_sub(2); | ||
| 618 | if width != app.patch_width { | ||
| 619 | app.patch_width = width; | ||
| 620 | app.rewrap(); | ||
| 621 | } | ||
| 440 | let cursor = app.patch_cursor; | 622 | let cursor = app.patch_cursor; |
| 441 | let lines: Vec<Line> = app | 623 | let lines: Vec<Line> = app |
| 442 | .patch_rows | 624 | .patch_rows |
| 443 | .iter() | 625 | .iter() |
| 444 | .enumerate() | 626 | .map(|row| { |
| 445 | .map(|(i, row)| { | 627 | // The highlight covers the whole run of rows the cursor's line |
| 446 | if i == cursor { | 628 | // wrapped to, which is what vim does and what keeps the reader |
| 629 | // from guessing which line `c` is about to act on. | ||
| 630 | if row.logical == cursor { | ||
| 447 | row.line.clone().style( | 631 | row.line.clone().style( |
| 448 | Style::default() | 632 | Style::default() |
| 449 | .bg(Color::DarkGray) | 633 | .bg(Color::DarkGray) |
| @@ -458,9 +642,10 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect, repo: Option<&Rep | |||
| 458 | .borders(Borders::ALL) | 642 | .borders(Borders::ALL) |
| 459 | .title("Patch Detail") | 643 | .title("Patch Detail") |
| 460 | .border_style(border_style); | 644 | .border_style(border_style); |
| 461 | // No wrapping: a wrapped line occupies more rows than it has, which | 645 | // No `Paragraph::wrap`: the rows above are already no wider than the |
| 462 | // would put the cursor's highlight on a different line than the one | 646 | // pane, and `Paragraph` would wrap them a second time with no way to |
| 463 | // `c` is about to comment on. | 647 | // tell which logical line the result came from — which is exactly what |
| 648 | // `07a78a60` had to turn off. | ||
| 464 | let para = Paragraph::new(Text::from(lines)) | 649 | let para = Paragraph::new(Text::from(lines)) |
| 465 | .block(block) | 650 | .block(block) |
| 466 | .scroll((app.patch_scroll, 0)); | 651 | .scroll((app.patch_scroll, 0)); |
| @@ -477,7 +662,9 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect, repo: Option<&Rep | |||
| 477 | let label = action_type_label(&evt.action); | 662 | let label = action_type_label(&evt.action); |
| 478 | ListItem::new(format!( | 663 | ListItem::new(format!( |
| 479 | "{} | {} | {}", | 664 | "{} | {} | {}", |
| 480 | label, evt.author.name, evt.timestamp | 665 | label, |
| 666 | evt.author.name, | ||
| 667 | short_time(&evt.timestamp) | ||
| 481 | )) | 668 | )) |
| 482 | }) | 669 | }) |
| 483 | .collect(); | 670 | .collect(); |
| @@ -605,7 +792,7 @@ fn build_issue_detail( | |||
| 605 | ]), | 792 | ]), |
| 606 | Line::from(vec![ | 793 | Line::from(vec![ |
| 607 | Span::styled("Created: ", Style::default().fg(Color::DarkGray)), | 794 | Span::styled("Created: ", Style::default().fg(Color::DarkGray)), |
| 608 | Span::raw(issue.created_at.clone()), | 795 | Span::raw(short_time(&issue.created_at)), |
| 609 | ]), | 796 | ]), |
| 610 | ]; | 797 | ]; |
| 611 | 798 | ||
| @@ -691,7 +878,7 @@ fn build_issue_detail( | |||
| 691 | Style::default().add_modifier(Modifier::BOLD), | 878 | Style::default().add_modifier(Modifier::BOLD), |
| 692 | ), | 879 | ), |
| 693 | Span::styled( | 880 | Span::styled( |
| 694 | format!(" ({})", c.timestamp), | 881 | format!(" ({})", short_time(&c.timestamp)), |
| 695 | Style::default().fg(Color::DarkGray), | 882 | Style::default().fg(Color::DarkGray), |
| 696 | ), | 883 | ), |
| 697 | edited_span(c.edited), | 884 | edited_span(c.edited), |
| @@ -730,12 +917,19 @@ fn build_issue_detail( | |||
| 730 | let line_text = if commit_author.is_empty() { | 917 | let line_text = if commit_author.is_empty() { |
| 731 | format!( | 918 | format!( |
| 732 | "· linked {} (commit {} not in local repo) (linked by {}, {})", | 919 | "· linked {} (commit {} not in local repo) (linked by {}, {})", |
| 733 | short_sha, short_sha, lc.event_author.name, lc.event_timestamp | 920 | short_sha, |
| 921 | short_sha, | ||
| 922 | lc.event_author.name, | ||
| 923 | short_time(&lc.event_timestamp) | ||
| 734 | ) | 924 | ) |
| 735 | } else { | 925 | } else { |
| 736 | format!( | 926 | format!( |
| 737 | "· linked {} \"{}\" by {} (linked by {}, {})", | 927 | "· linked {} \"{}\" by {} (linked by {}, {})", |
| 738 | short_sha, subject, commit_author, lc.event_author.name, lc.event_timestamp | 928 | short_sha, |
| 929 | subject, | ||
| 930 | commit_author, | ||
| 931 | lc.event_author.name, | ||
| 932 | short_time(&lc.event_timestamp) | ||
| 739 | ) | 933 | ) |
| 740 | }; | 934 | }; |
| 741 | lines.push(Line::raw(line_text)); | 935 | lines.push(Line::raw(line_text)); |
| @@ -911,7 +1105,11 @@ pub(crate) fn build_patch_detail_rows(app: &App) -> Vec<DetailRow> { | |||
| 911 | let label = if i == 0 { " (initial)" } else { "" }; | 1105 | let label = if i == 0 { " (initial)" } else { "" }; |
| 912 | rows.push(Line::from(vec![Span::raw(format!( | 1106 | rows.push(Line::from(vec![Span::raw(format!( |
| 913 | "{}r{} {} {}{}", | 1107 | "{}r{} {} {}{}", |
| 914 | marker, rev.number, short, rev.timestamp, label | 1108 | marker, |
| 1109 | rev.number, | ||
| 1110 | short, | ||
| 1111 | short_time(&rev.timestamp), | ||
| 1112 | label | ||
| 915 | ))])); | 1113 | ))])); |
| 916 | } | 1114 | } |
| 917 | } | 1115 | } |
| @@ -942,7 +1140,7 @@ pub(crate) fn build_patch_detail_rows(app: &App) -> Vec<DetailRow> { | |||
| 942 | review.author.name.clone(), | 1140 | review.author.name.clone(), |
| 943 | Style::default().add_modifier(Modifier::BOLD), | 1141 | Style::default().add_modifier(Modifier::BOLD), |
| 944 | ), | 1142 | ), |
| 945 | Span::raw(format!(" {} ", review.timestamp)), | 1143 | Span::raw(format!(" {} ", short_time(&review.timestamp))), |
| 946 | Span::styled(verdict_str, Style::default().fg(verdict_color)), | 1144 | Span::styled(verdict_str, Style::default().fg(verdict_color)), |
| 947 | Span::raw(rev_label), | 1145 | Span::raw(rev_label), |
| 948 | edited_span(review.edited), | 1146 | edited_span(review.edited), |
| @@ -1021,7 +1219,7 @@ pub(crate) fn build_patch_detail_rows(app: &App) -> Vec<DetailRow> { | |||
| 1021 | Style::default().add_modifier(Modifier::BOLD), | 1219 | Style::default().add_modifier(Modifier::BOLD), |
| 1022 | ), | 1220 | ), |
| 1023 | Span::styled( | 1221 | Span::styled( |
| 1024 | format!(" ({})", c.timestamp), | 1222 | format!(" ({})", short_time(&c.timestamp)), |
| 1025 | Style::default().fg(Color::DarkGray), | 1223 | Style::default().fg(Color::DarkGray), |
| 1026 | ), | 1224 | ), |
| 1027 | edited_span(c.edited), | 1225 | edited_span(c.edited), |
| @@ -1083,31 +1281,39 @@ fn push_diff_rows(rows: &mut Rows, app: &App) { | |||
| 1083 | rows.push(Line::raw("(no diff available)")); | 1281 | rows.push(Line::raw("(no diff available)")); |
| 1084 | return; | 1282 | return; |
| 1085 | } | 1283 | } |
| 1284 | // Every line from here down is a line of a file, so `w` governs it. | ||
| 1086 | for row in &app.patch_diff { | 1285 | for row in &app.patch_diff { |
| 1087 | let text = row.text.trim_end_matches(['\n', '\r']).to_string(); | 1286 | let target = match &row.anchor { |
| 1088 | let style = if text.starts_with("diff ") { | 1287 | Some((file, number)) => RowTarget::Diff { |
| 1089 | Style::default() | 1288 | file: file.clone(), |
| 1090 | .fg(Color::Yellow) | 1289 | line: *number, |
| 1091 | .add_modifier(Modifier::BOLD) | 1290 | }, |
| 1092 | } else if text.starts_with("@@") { | 1291 | None => RowTarget::None, |
| 1093 | Style::default().fg(Color::Cyan) | ||
| 1094 | } else if text.starts_with('+') { | ||
| 1095 | Style::default().fg(Color::Green) | ||
| 1096 | } else if text.starts_with('-') { | ||
| 1097 | Style::default().fg(Color::Red) | ||
| 1098 | } else { | ||
| 1099 | Style::default() | ||
| 1100 | }; | 1292 | }; |
| 1101 | let line = Line::styled(text, style); | 1293 | // One row per *physical* line. git hands the whole file header — |
| 1102 | match &row.anchor { | 1294 | // `diff --git`, `index`, `--- a/f` — over as a single diff row with |
| 1103 | Some((file, number)) => rows.push_target( | 1295 | // newlines inside it, and a `Line` has nowhere to put a newline: it |
| 1104 | line, | 1296 | // used to arrive as one unreadable row, and its width would have lied |
| 1105 | RowTarget::Diff { | 1297 | // to the wrapper. Each piece keeps the anchor of the row it came from. |
| 1106 | file: file.clone(), | 1298 | // The stored text ends with its newline; that one is a terminator and |
| 1107 | line: *number, | 1299 | // not an empty line after it. |
| 1108 | }, | 1300 | let body = row.text.strip_suffix('\n').unwrap_or(&row.text); |
| 1109 | ), | 1301 | for text in body.split('\n') { |
| 1110 | None => rows.push(line), | 1302 | let text = text.trim_end_matches('\r').to_string(); |
| 1303 | let style = if text.starts_with("diff ") { | ||
| 1304 | Style::default() | ||
| 1305 | .fg(Color::Yellow) | ||
| 1306 | .add_modifier(Modifier::BOLD) | ||
| 1307 | } else if text.starts_with("@@") { | ||
| 1308 | Style::default().fg(Color::Cyan) | ||
| 1309 | } else if text.starts_with('+') { | ||
| 1310 | Style::default().fg(Color::Green) | ||
| 1311 | } else if text.starts_with('-') { | ||
| 1312 | Style::default().fg(Color::Red) | ||
| 1313 | } else { | ||
| 1314 | Style::default() | ||
| 1315 | }; | ||
| 1316 | rows.push_diff(Line::styled(text, style), target.clone()); | ||
| 1111 | } | 1317 | } |
| 1112 | } | 1318 | } |
| 1113 | } | 1319 | } |
| @@ -1167,7 +1373,7 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { | |||
| 1167 | // way out, then the things you can also get to by other means. | 1373 | // way out, then the things you can also get to by other means. |
| 1168 | ViewMode::PatchDetail => { | 1374 | ViewMode::PatchDetail => { |
| 1169 | " j/k:line c:comment R:review x:resolve a:answers Esc:back q:quit \ | 1375 | " j/k:line c:comment R:review x:resolve a:answers Esc:back q:quit \ |
| 1170 | [/]:revision d:interdiff o:checkout" | 1376 | [/]:revision d:interdiff o:checkout w:wrap" |
| 1171 | .to_string() | 1377 | .to_string() |
| 1172 | } | 1378 | } |
| 1173 | ViewMode::Details => { | 1379 | ViewMode::Details => { |
tests/tui_review_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -138,6 +138,100 @@ fn the_dashboards_own_write_does_not_raise_the_banner() { | |||
| 138 | ); | 138 | ); |
| 139 | } | 139 | } |
| 140 | 140 | ||
| 141 | // ── Wrapping the patch detail pane (9f9dee31) ─────────────────────────────── | ||
| 142 | |||
| 143 | /// The defect in a real terminal: a diff line wider than the pane used to end | ||
| 144 | /// at the right edge with nothing to say the rest existed. The end of the line | ||
| 145 | /// is a distinct token, so its presence on screen is the whole property — and | ||
| 146 | /// it is checked through a pty because the clipping was a property of the pane | ||
| 147 | /// as drawn, not of the row list. | ||
| 148 | #[test] | ||
| 149 | fn a_diff_line_wider_than_the_pane_is_not_clipped_away() { | ||
| 150 | let repo = TestRepo::new("Reviewer", "reviewer@example.com"); | ||
| 151 | repo.commit_file("src/lib.rs", "one\ntwo\n", "seed"); | ||
| 152 | repo.git(&["checkout", "-b", "feature/wide"]); | ||
| 153 | // Far wider than the ~63 columns the detail pane gets at 100 across, with | ||
| 154 | // the far end named so the test can look for it rather than for a length. | ||
| 155 | let wide = format!("let x = \"{}\"; // ENDOFTHEWIDELINE", "w".repeat(300)); | ||
| 156 | repo.commit_file("src/lib.rs", &format!("one\ntwo\n{}\n", wide), "a wide line"); | ||
| 157 | repo.run_ok(&["patch", "create", "-t", "A wide patch", "-B", "feature/wide"]); | ||
| 158 | repo.git(&["checkout", "main"]); | ||
| 159 | |||
| 160 | let out = repo.run_dashboard_driven(100, 45, &[], |stdin| { | ||
| 161 | let _ = stdin.write_all(b"P"); | ||
| 162 | thread::sleep(Duration::from_millis(400)); | ||
| 163 | let _ = stdin.write_all(b"\r"); | ||
| 164 | thread::sleep(Duration::from_millis(1200)); | ||
| 165 | let _ = stdin.write_all(b"q"); | ||
| 166 | }); | ||
| 167 | let text = screen_text(&out.stdout); | ||
| 168 | assert!( | ||
| 169 | text.contains("ENDOFTHEWIDELINE"), | ||
| 170 | "the end of a 300-column diff line never reached the screen:\n{}", | ||
| 171 | text | ||
| 172 | ); | ||
| 173 | } | ||
| 174 | |||
| 175 | // ── Timestamps (952390ad) ─────────────────────────────────────────────────── | ||
| 176 | |||
| 177 | /// The defect as it was captured: a real pty, a real repository, and | ||
| 178 | /// `Created: 2026-08-12T16:57:16.325870642+00:00` on the issue pane. | ||
| 179 | /// | ||
| 180 | /// Both halves are asserted from one screen. Stored timestamps are RFC3339 | ||
| 181 | /// with a `+00:00` offset and nothing else the dashboard prints ends that way, | ||
| 182 | /// so the offset's absence covers every pane the walk visits at once; and the | ||
| 183 | /// short form has to be positively there, or dropping the field entirely would | ||
| 184 | /// satisfy it. The expected value comes from the stored one the CLI prints | ||
| 185 | /// rather than from the clock, so nothing here races midnight. | ||
| 186 | #[test] | ||
| 187 | fn no_pane_prints_a_stored_timestamp_in_full() { | ||
| 188 | let (repo, id) = repo_with_patch(); | ||
| 189 | repo.issue_open("something with a date on it"); | ||
| 190 | repo.run_ok(&["patch", "comment", &id, "-b", "a remark with a date on it"]); | ||
| 191 | |||
| 192 | let listed = repo.run_ok(&["issue", "list"]); | ||
| 193 | let issue = listed.split_whitespace().next().expect("one issue"); | ||
| 194 | let shown = repo.run_ok(&["issue", "show", issue]); | ||
| 195 | let stored = shown | ||
| 196 | .lines() | ||
| 197 | .find_map(|l| l.strip_prefix("Created: ")) | ||
| 198 | .expect("issue show prints the stored timestamp") | ||
| 199 | .trim() | ||
| 200 | .to_string(); | ||
| 201 | // `2026-08-13T07:33:57.151572101+00:00` -> `2026-08-1307:33`. The space | ||
| 202 | // between the date and the time is left out on purpose: ratatui writes only | ||
| 203 | // the cells that differ from what is already there, and a cell holding a | ||
| 204 | // space starts out holding a space — so an interior blank never reaches the | ||
| 205 | // stream this reads. Everything either side of it does. | ||
| 206 | let expected = format!("{}{}", &stored[..10], &stored[11..16]); | ||
| 207 | |||
| 208 | // Walk the panes a reader walks: the issue detail, then the patch list, | ||
| 209 | // then the patch detail with its revisions, comments and diff. | ||
| 210 | let out = repo.run_dashboard_driven(120, 45, &[], |stdin| { | ||
| 211 | let _ = stdin.write_all(b"\t"); | ||
| 212 | thread::sleep(Duration::from_millis(400)); | ||
| 213 | let _ = stdin.write_all(b"P"); | ||
| 214 | thread::sleep(Duration::from_millis(400)); | ||
| 215 | let _ = stdin.write_all(b"\r"); | ||
| 216 | thread::sleep(Duration::from_millis(800)); | ||
| 217 | let _ = stdin.write_all(b"q"); | ||
| 218 | }); | ||
| 219 | let text = screen_text(&out.stdout); | ||
| 220 | |||
| 221 | assert!( | ||
| 222 | text.contains(&expected), | ||
| 223 | "expected the date rendered as `{} {}`:\n{}", | ||
| 224 | &stored[..10], | ||
| 225 | &stored[11..16], | ||
| 226 | text | ||
| 227 | ); | ||
| 228 | assert!( | ||
| 229 | !text.contains("+00:00"), | ||
| 230 | "a stored timestamp reached the screen in full:\n{}", | ||
| 231 | text | ||
| 232 | ); | ||
| 233 | } | ||
| 234 | |||
| 141 | // ── Writing from the review surface (094b055b) ────────────────────────────── | 235 | // ── Writing from the review surface (094b055b) ────────────────────────────── |
| 142 | 236 | ||
| 143 | /// The whole point of the issue: read a patch and say something about it | 237 | /// The whole point of the issue: read a patch and say something about it |