a73x

15bc9a3c

Make the dashboard a review surface, and let it admit it is old

a73x   2026-08-12 17:52

Commit message
Make the dashboard a review surface, and let it admit it is old

The TUI wrote exactly one kind of event: `n` opened an issue. You could
read a patch, walk its revisions, view diffs and interdiffs, read every
review comment — and then had to quit to say anything about what you had
just read. For a tool whose thesis is that review should preserve the
correspondence between feedback and the change answering it, the review
surface could not leave a comment.

It also never noticed anything. The event loop polled for keystrokes, not
for repository changes, so the screen showed whatever was true when `r`
was last pressed, with nothing on it admitting that. Leave it open beside
working agents — the way it is meant to be used — and it looks perfectly
healthy while being an hour out of date.

The review loop, on the patch detail pane, anchored to what is on screen:

  c  comment on the diff line under the cursor, or on the patch
  R  review with a verdict: approve / request-changes / comment
  x  mark the comment under the cursor answered, or withdraw the claim
  a  show the change that answered it
  o  check the patch out

Prose goes through $EDITOR, as `git commit` does and as the CLI already
did — no second, worse text input gets written here, and the one that
existed for issue bodies is gone. The buffer is seeded with the hunk
being commented on, as `#` lines, so suspending does not cost the writer
the thing they were writing about; those lines come back out, and an
empty message aborts and says so. No $EDITOR is a sentence on the status
line, never a hang. The screen is restored by a `Drop`, so a non-zero
exit or a killed editor gives the terminal back either way.

Inline comments go through `patch::comment`'s `--at` path, so they
inherit its validation against the revision the comment anchors to. The
anchors themselves come from the diff machinery rather than a second
reading of it: `format_diff` now renders rows that already carry them,
and `--line-numbers` is one way of printing those rows.

Refresh notifies rather than reloads. A two-second timer compares
`refs/collab/**` tips — a ref read, not a fold — and raises a banner
counting what arrived; the reader presses `r` when they are ready, so
rows never move under someone mid-comment. An `as of HH:MM` marker is
always there, which is the whole difference between a screen that is
silently wrong and one that is merely old. Reloading re-baselines the
snapshot, so the dashboard's own writes are not announced back as news,
and `refs/collab/local/` is excluded so a read marker is not mistaken
for someone speaking.

`o` no longer checks out `PatchState.branch`. That field has been
provenance only since revision refs landed, and on agent-produced
patches it names a worktree that is long gone; it now runs the same
`patch::checkout` the CLI does, against the latest revision's commit,
which a revision ref pins and which therefore always resolves.

Fixes 094b055b. Fixes 59ab40e8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

README.md
Old New
@@ -178,7 +178,17 @@ $ git-collab patch review a1b2c3d4 --verdict request-changes -b "see inline"
178 ``` 178 ```
179 179
180 `git-collab dashboard` opens a TUI over the same data if you would rather browse 180 `git-collab dashboard` opens a TUI over the same data if you would rather browse
181 than type. 181 than type — and it is a review surface, not only a browser. Open a patch and
182 the review loop is under your hands: `c` comments on the diff line the cursor
183 is on (or on the patch, anywhere else), `R` records a verdict, `x` marks a
184 comment answered or withdraws the claim, `a` shows the change that answered it,
185 and `o` checks the latest revision out. Every body is composed in `$EDITOR`,
186 seeded with the hunk you are writing about; as with `git commit`, `#` lines are
187 dropped and an empty message aborts.
188
189 The dashboard never reloads under you. A header says `as of HH:MM`, and when an
190 agent lands something while you are reading, a banner says how many events
191 arrived — you reload with `r` when you are ready.
182 192
183 ## Writing and correcting prose 193 ## Writing and correcting prose
184 194
src/patch.rs
Old New
@@ -1202,6 +1202,16 @@ fn generate_diff_at_revision(
1202 rev_number: u32, 1202 rev_number: u32,
1203 opts: &DiffOpts, 1203 opts: &DiffOpts,
1204 ) -> Result<String, Error> { 1204 ) -> Result<String, Error> {
1205 let git_diff = revision_diff(repo, patch, rev_number, opts)?;
1206 format_diff(&git_diff, opts)
1207 }
1208
1209 fn revision_diff<'r>(
1210 repo: &'r Repository,
1211 patch: &PatchState,
1212 rev_number: u32,
1213 opts: &DiffOpts,
1214 ) -> Result<git2::Diff<'r>, Error> {
1205 let revision = patch 1215 let revision = patch
1206 .revisions 1216 .revisions
1207 .iter() 1217 .iter()
@@ -1215,12 +1225,43 @@ fn generate_diff_at_revision(
1215 None => resolve_base_tree(repo, &patch.base_ref, commit_oid)?, 1225 None => resolve_base_tree(repo, &patch.base_ref, commit_oid)?,
1216 }; 1226 };
1217 1227
1218 let git_diff = repo.diff_tree_to_tree( 1228 Ok(repo.diff_tree_to_tree(
1219 base_tree.as_ref(), 1229 base_tree.as_ref(),
1220 Some(&head_tree), 1230 Some(&head_tree),
1221 Some(&mut diff_options(opts)), 1231 Some(&mut diff_options(opts)),
1222 )?; 1232 )?)
1223 format_diff(&git_diff, opts) 1233 }
1234
1235 /// The rows of the diff a reviewer is looking at, each carrying the anchor
1236 /// `patch comment --at` accepts for it.
1237 ///
1238 /// Exists so the dashboard can offer "comment on this line" without inventing
1239 /// a second notion of which lines are anchorable. `interdiff_from` selects the
1240 /// interdiff view; the note an interdiff prepends is dropped, since it is
1241 /// prose for a terminal rather than a line of the change.
1242 pub fn diff_rows_for(
1243 repo: &Repository,
1244 patch: &PatchState,
1245 rev_number: u32,
1246 interdiff_from: Option<u32>,
1247 opts: &DiffOpts,
1248 ) -> Result<Vec<DiffRow>, Error> {
1249 match interdiff_from {
1250 Some(from) => {
1251 let (note, git_diff) = interdiff_diff(repo, patch, from, rev_number, opts)?;
1252 let mut rows = Vec::new();
1253 for line in note.lines() {
1254 rows.push(DiffRow {
1255 text: format!("{}\n", line),
1256 body: false,
1257 anchor: None,
1258 });
1259 }
1260 rows.extend(diff_rows(&git_diff)?);
1261 Ok(rows)
1262 }
1263 None => diff_rows(&revision_diff(repo, patch, rev_number, opts)?),
1264 }
1224 } 1265 }
1225 1266
1226 /// Compute the interdiff between two revisions: what the author changed in 1267 /// Compute the interdiff between two revisions: what the author changed in
@@ -1254,6 +1295,21 @@ pub fn interdiff(
1254 to_rev: u32, 1295 to_rev: u32,
1255 opts: &DiffOpts, 1296 opts: &DiffOpts,
1256 ) -> Result<String, Error> { 1297 ) -> Result<String, Error> {
1298 let (note, git_diff) = interdiff_diff(repo, patch, from_rev, to_rev, opts)?;
1299 Ok(note + &format_diff(&git_diff, opts)?)
1300 }
1301
1302 /// The note and the libgit2 diff an interdiff renders from.
1303 ///
1304 /// Split out of [`interdiff`] so the dashboard can take the rows — and with
1305 /// them the anchors — rather than a rendered string it would have to parse.
1306 fn interdiff_diff<'r>(
1307 repo: &'r Repository,
1308 patch: &PatchState,
1309 from_rev: u32,
1310 to_rev: u32,
1311 opts: &DiffOpts,
1312 ) -> Result<(String, git2::Diff<'r>), Error> {
1257 let from = find_revision(patch, from_rev)?; 1313 let from = find_revision(patch, from_rev)?;
1258 let to = find_revision(patch, to_rev)?; 1314 let to = find_revision(patch, to_rev)?;
1259 1315
@@ -1269,13 +1325,13 @@ pub fn interdiff(
1269 1325
1270 let from_tree = repo.find_tree(Oid::from_str(&from.tree)?)?; 1326 let from_tree = repo.find_tree(Oid::from_str(&from.tree)?)?;
1271 let to_tree = repo.find_tree(Oid::from_str(&to.tree)?)?; 1327 let to_tree = repo.find_tree(Oid::from_str(&to.tree)?)?;
1272 let plain = |note: String| -> Result<String, Error> { 1328 let plain = |note: String| -> Result<(String, git2::Diff<'r>), Error> {
1273 let git_diff = repo.diff_tree_to_tree( 1329 let git_diff = repo.diff_tree_to_tree(
1274 Some(&from_tree), 1330 Some(&from_tree),
1275 Some(&to_tree), 1331 Some(&to_tree),
1276 Some(&mut diff_options(opts)), 1332 Some(&mut diff_options(opts)),
1277 )?; 1333 )?;
1278 Ok(note + &format_diff(&git_diff, opts)?) 1334 Ok((note, git_diff))
1279 }; 1335 };
1280 1336
1281 let (older, newer) = if from_rev < to_rev { 1337 let (older, newer) = if from_rev < to_rev {
@@ -1353,7 +1409,7 @@ pub fn interdiff(
1353 } else { 1409 } else {
1354 repo.diff_tree_to_index(Some(&from_tree), Some(&replayed), Some(&mut git_opts))? 1410 repo.diff_tree_to_index(Some(&from_tree), Some(&replayed), Some(&mut git_opts))?
1355 }; 1411 };
1356 Ok(note + &format_diff(&git_diff, opts)?) 1412 Ok((note, git_diff))
1357 } 1413 }
1358 1414
1359 fn find_revision(patch: &PatchState, number: u32) -> Result<&state::Revision, Error> { 1415 fn find_revision(patch: &PatchState, number: u32) -> Result<&state::Revision, Error> {
@@ -1425,11 +1481,39 @@ fn format_diff(git_diff: &git2::Diff, opts: &DiffOpts) -> Result<String, Error>
1425 let buf = stats.to_buf(git2::DiffStatsFormat::FULL, 80)?; 1481 let buf = stats.to_buf(git2::DiffStatsFormat::FULL, 80)?;
1426 return Ok(String::from_utf8_lossy(&buf).into_owned()); 1482 return Ok(String::from_utf8_lossy(&buf).into_owned());
1427 } 1483 }
1484 Ok(render_rows(&diff_rows(git_diff)?, opts.line_numbers))
1485 }
1428 1486
1429 // `(gutter, text)`. `None` marks a line with no anchor; a header line 1487 /// One row of a rendered diff, and the anchor a comment on it would carry.
1430 // carries no gutter at all, so it is held separately from "anchorable but 1488 ///
1431 // not on the new side". 1489 /// The dashboard needs the anchor of the line under its cursor, and printing a
1432 let mut rows: Vec<(Option<Option<String>>, String)> = Vec::new(); 1490 /// gutter and parsing it back would be a second anchoring path — one that
1491 /// could disagree with `--line-numbers` about which lines are anchorable at
1492 /// all. So the rows are the shared representation and the gutter is one way of
1493 /// rendering them.
1494 #[derive(Debug, Clone, PartialEq)]
1495 pub struct DiffRow {
1496 /// The row as it prints, newline included, with no gutter.
1497 pub text: String,
1498 /// Whether this is a diff body line (`+`, `-`, ` `) rather than a header
1499 /// or hunk marker. Only body rows take the gutter column.
1500 pub body: bool,
1501 /// The `path:line` a comment here would anchor to. `Some` only on new-side
1502 /// lines: a deleted line is not in the revision's tree, so nothing could
1503 /// anchor to it and `patch comment` would reject an anchor offered there.
1504 pub anchor: Option<(String, u32)>,
1505 }
1506
1507 impl DiffRow {
1508 /// The gutter token, as `patch comment --at` accepts it.
1509 fn anchor_token(&self) -> Option<String> {
1510 self.anchor.as_ref().map(|(p, n)| format!("{}:{}", p, n))
1511 }
1512 }
1513
1514 /// Walk a libgit2 diff into rows.
1515 fn diff_rows(git_diff: &git2::Diff) -> Result<Vec<DiffRow>, Error> {
1516 let mut rows: Vec<DiffRow> = Vec::new();
1433 git_diff.print(DiffFormat::Patch, |delta, _hunk, line| { 1517 git_diff.print(DiffFormat::Patch, |delta, _hunk, line| {
1434 if rows.len() >= 5000 { 1518 if rows.len() >= 5000 {
1435 return true; 1519 return true;
@@ -1444,50 +1528,63 @@ fn format_diff(git_diff: &git2::Diff, opts: &DiffOpts) -> Result<String, Error>
1444 if let Ok(content) = std::str::from_utf8(line.content()) { 1528 if let Ok(content) = std::str::from_utf8(line.content()) {
1445 text.push_str(content); 1529 text.push_str(content);
1446 } 1530 }
1447 let gutter = if opts.line_numbers && prefix.is_some() { 1531 let anchor = if prefix.is_some() {
1448 Some(line.new_lineno().and_then(|n| { 1532 line.new_lineno().and_then(|n| {
1449 delta 1533 delta
1450 .new_file() 1534 .new_file()
1451 .path() 1535 .path()
1452 .map(|p| format!("{}:{}", p.display(), n)) 1536 .map(|p| (p.display().to_string(), n))
1453 })) 1537 })
1454 } else { 1538 } else {
1455 None 1539 None
1456 }; 1540 };
1457 rows.push((gutter, text)); 1541 rows.push(DiffRow {
1542 text,
1543 body: prefix.is_some(),
1544 anchor,
1545 });
1458 true 1546 true
1459 })?; 1547 })?;
1460 1548
1461 let truncated = rows.len() >= 5000; 1549 if rows.len() >= 5000 {
1462 let width = rows 1550 rows.push(DiffRow {
1463 .iter() 1551 text: "\n[truncated at 5000 lines]".to_string(),
1464 .filter_map(|(g, _)| g.as_ref().and_then(|a| a.as_ref()).map(|s| s.chars().count())) 1552 body: false,
1465 .max() 1553 anchor: None,
1466 .unwrap_or(0); 1554 });
1555 }
1556 Ok(rows)
1557 }
1558
1559 /// Render rows back to a unified diff, optionally with the anchor gutter.
1560 fn render_rows(rows: &[DiffRow], line_numbers: bool) -> String {
1561 let width = if line_numbers {
1562 rows.iter()
1563 .filter_map(|r| r.anchor_token().map(|s| s.chars().count()))
1564 .max()
1565 .unwrap_or(0)
1566 } else {
1567 0
1568 };
1467 1569
1468 let mut output = String::new(); 1570 let mut output = String::new();
1469 for (gutter, text) in &rows { 1571 for row in rows {
1470 match gutter { 1572 if line_numbers && row.body {
1471 Some(Some(anchor)) => { 1573 match row.anchor_token() {
1472 output.push_str(anchor); 1574 Some(anchor) => {
1473 for _ in anchor.chars().count()..width { 1575 output.push_str(&anchor);
1576 for _ in anchor.chars().count()..width {
1577 output.push(' ');
1578 }
1474 output.push(' '); 1579 output.push(' ');
1475 } 1580 }
1476 output.push(' '); 1581 // Anchorable position, no new-side line: keep the body aligned.
1582 None => output.push_str(&" ".repeat(width + 1)),
1477 } 1583 }
1478 // Anchorable position, no new-side line: keep the body aligned.
1479 Some(None) => output.push_str(&" ".repeat(width + 1)),
1480 // Headers and hunk markers stay flush left.
1481 None => {}
1482 } 1584 }
1483 output.push_str(text); 1585 output.push_str(&row.text);
1484 } 1586 }
1485 1587 output
1486 if truncated {
1487 output.push_str("\n[truncated at 5000 lines]");
1488 }
1489
1490 Ok(output)
1491 } 1588 }
1492 1589
1493 /// Patch log: list all revisions with timestamps and file-change summaries. 1590 /// Patch log: list all revisions with timestamps and file-change summaries.
src/tui/events.rs
Old New
@@ -1,28 +1,40 @@
1 use std::io::{self, stdout}; 1 use std::io::{self, stdout};
2 use std::time::Duration; 2 use std::time::{Duration, Instant};
3 3
4 use crossterm::event::{self, Event, KeyCode}; 4 use crossterm::event::{self, Event, KeyCode};
5 use crossterm::terminal::{self, LeaveAlternateScreen}; 5 use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
6 use crossterm::ExecutableCommand; 6 use crossterm::ExecutableCommand;
7 use git2::Repository; 7 use git2::Repository;
8 use ratatui::prelude::*; 8 use ratatui::prelude::*;
9 use ratatui::widgets::ListState; 9 use ratatui::widgets::ListState;
10 10
11 use crate::error::Error; 11 use crate::error::Error;
12 use crate::event::ReviewVerdict;
12 use crate::issue as issue_mod; 13 use crate::issue as issue_mod;
13 use crate::patch as patch_mod; 14 use crate::patch as patch_mod;
14 15
15 use super::state::{App, InputMode, KeyAction, ViewMode}; 16 use super::state::{App, InputMode, KeyAction, ViewMode};
16 use super::widgets::ui; 17 use super::widgets::{ui, RowTarget};
18
19 /// How often the collab tips are compared. The loop already wakes every 100ms
20 /// for input, so this costs a ref read every twentieth wake-up next to a
21 /// redraw that was happening anyway.
22 const TIP_POLL: Duration = Duration::from_secs(2);
17 23
18 pub(crate) fn run_loop( 24 pub(crate) fn run_loop(
19 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, 25 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
20 app: &mut App, 26 app: &mut App,
21 repo: &Repository, 27 repo: &Repository,
22 ) -> Result<(), Error> { 28 ) -> Result<(), Error> {
29 let mut last_poll = Instant::now();
23 loop { 30 loop {
24 terminal.draw(|frame| ui(frame, app, Some(repo)))?; 31 terminal.draw(|frame| ui(frame, app, Some(repo)))?;
25 32
33 if last_poll.elapsed() >= TIP_POLL {
34 last_poll = Instant::now();
35 app.poll_tips(repo);
36 }
37
26 if event::poll(Duration::from_millis(100))? { 38 if event::poll(Duration::from_millis(100))? {
27 if let Event::Key(key) = event::read()? { 39 if let Event::Key(key) = event::read()? {
28 app.status_msg = None; // clear status on any keypress 40 app.status_msg = None; // clear status on any keypress
@@ -60,68 +72,31 @@ pub(crate) fn run_loop(
60 app.input_mode = InputMode::Normal; 72 app.input_mode = InputMode::Normal;
61 app.input_buf.clear(); 73 app.input_buf.clear();
62 } 74 }
75 // The title is one line and is a prompt, so it
76 // stays here. The body is markdown and is prose,
77 // so it goes where prose goes: there is no second,
78 // worse text editor in this program any more.
63 KeyCode::Enter => { 79 KeyCode::Enter => {
64 let title = app.input_buf.trim().to_string(); 80 let title = app.input_buf.trim().to_string();
81 app.input_mode = InputMode::Normal;
82 app.input_buf.clear();
65 if title.is_empty() { 83 if title.is_empty() {
66 app.input_mode = InputMode::Normal; 84 continue;
67 app.input_buf.clear();
68 } else {
69 app.create_title = title;
70 app.input_buf.clear();
71 app.input_mode = InputMode::CreateBody;
72 } 85 }
73 } 86 app.create_title = title.clone();
74 KeyCode::Backspace => { 87 let seed = comment_seed(&format!("New issue: {}", title), vec![]);
75 app.input_buf.pop(); 88 match compose(terminal, &seed) {
76 } 89 Ok(Composed::Body(body)) => create_issue(app, repo, &body),
77 KeyCode::Char(c) => { 90 Ok(Composed::Aborted) => {
78 app.input_buf.push(c); 91 app.create_title.clear();
79 }
80 _ => {}
81 }
82 continue;
83 }
84 InputMode::CreateBody => {
85 match key.code {
86 KeyCode::Esc => {
87 // Submit with title only, no body
88 let title = app.create_title.clone();
89 match issue_mod::open(repo, &title, "", None) {
90 Ok(id) => {
91 app.reload(repo);
92 app.status_msg = Some(format!(
93 "Issue created: {}",
94 app.issue_abbrev.of(&id)
95 ));
96 }
97 Err(e) => {
98 app.status_msg = 92 app.status_msg =
99 Some(format!("Error creating issue: {}", e)); 93 Some("Aborting issue: empty message.".to_string());
100 }
101 }
102 app.input_mode = InputMode::Normal;
103 app.input_buf.clear();
104 app.create_title.clear();
105 }
106 KeyCode::Enter => {
107 let title = app.create_title.clone();
108 let body = app.input_buf.clone();
109 match issue_mod::open(repo, &title, &body, None) {
110 Ok(id) => {
111 app.reload(repo);
112 app.status_msg = Some(format!(
113 "Issue created: {}",
114 app.issue_abbrev.of(&id)
115 ));
116 } 94 }
117 Err(e) => { 95 Err(e) => {
118 app.status_msg = 96 app.create_title.clear();
119 Some(format!("Error creating issue: {}", e)); 97 app.status_msg = Some(e.to_string());
120 } 98 }
121 } 99 }
122 app.input_mode = InputMode::Normal;
123 app.input_buf.clear();
124 app.create_title.clear();
125 } 100 }
126 KeyCode::Backspace => { 101 KeyCode::Backspace => {
127 app.input_buf.pop(); 102 app.input_buf.pop();
@@ -133,6 +108,19 @@ pub(crate) fn run_loop(
133 } 108 }
134 continue; 109 continue;
135 } 110 }
111 InputMode::ReviewVerdict => {
112 let verdict = match key.code {
113 KeyCode::Char('a') => Some(ReviewVerdict::Approve),
114 KeyCode::Char('r') => Some(ReviewVerdict::RequestChanges),
115 KeyCode::Char('c') => Some(ReviewVerdict::Comment),
116 _ => None,
117 };
118 app.input_mode = InputMode::Normal;
119 if let Some(verdict) = verdict {
120 submit_review(terminal, app, repo, verdict);
121 }
122 continue;
123 }
136 InputMode::Normal => {} 124 InputMode::Normal => {}
137 } 125 }
138 126
@@ -152,46 +140,7 @@ pub(crate) fn run_loop(
152 continue; 140 continue;
153 } 141 }
154 KeyCode::Char('o') => { 142 KeyCode::Char('o') => {
155 // Check out the relevant commit for local browsing 143 return checkout_and_leave(app, repo);
156 let checkout_target = {
157 let visible = app.visible_issues();
158 app.list_state
159 .selected()
160 .and_then(|idx| visible.get(idx))
161 .and_then(|issue| {
162 // Try linked patch first
163 app.patches
164 .iter()
165 .find(|p| p.fixes.as_deref() == Some(&issue.id))
166 .map(|p| p.branch.clone())
167 // Fall back to closing commit
168 .or_else(|| issue.closed_by.map(|oid| oid.to_string()))
169 })
170 };
171 if let Some(head) = checkout_target {
172 // Exit TUI, checkout, and return
173 terminal::disable_raw_mode()?;
174 stdout().execute(LeaveAlternateScreen)?;
175 let status = std::process::Command::new("git")
176 .args(["checkout", &head])
177 .status();
178 match status {
179 Ok(s) if s.success() => {
180 println!("Checked out commit: {:.8}", head);
181 println!("Use 'git checkout -' to return.");
182 }
183 Ok(s) => {
184 eprintln!("git checkout exited with {}", s);
185 }
186 Err(e) => {
187 eprintln!("Failed to run git checkout: {}", e);
188 }
189 }
190 return Ok(());
191 } else {
192 app.status_msg = Some("No linked patch to check out".to_string());
193 }
194 continue;
195 } 144 }
196 _ => {} 145 _ => {}
197 } 146 }
@@ -233,6 +182,10 @@ pub(crate) fn run_loop(
233 } 182 }
234 } 183 }
235 } 184 }
185 KeyAction::Comment => submit_comment(terminal, app, repo),
186 KeyAction::ToggleResolve => toggle_resolve(app, repo),
187 KeyAction::ShowAnswers => show_answers(app, repo),
188 KeyAction::Checkout => return checkout_and_leave(app, repo),
236 KeyAction::Continue => {} 189 KeyAction::Continue => {}
237 } 190 }
238 } 191 }
@@ -240,32 +193,329 @@ pub(crate) fn run_loop(
240 } 193 }
241 } 194 }
242 195
196 // ── Writing from the dashboard ──────────────────────────────────────────────
197
198 /// The result of asking the user for prose.
199 enum Composed {
200 Body(String),
201 /// Nothing was left after the `#` lines came out — the same "aborting due
202 /// to empty message" `git commit` gives, and for the same reason: an
203 /// accident recorded in an append-only DAG is permanent.
204 Aborted,
205 }
206
207 /// Leave the alternate screen for as long as this lives.
208 ///
209 /// A `Drop` rather than a pair of calls so the screen comes back however the
210 /// editor ends — a non-zero exit, a signal, or a panic on the way out. The
211 /// terminal belongs to the user, and handing it back is not conditional on
212 /// the write succeeding.
213 struct Suspended;
214
215 impl Suspended {
216 fn enter() -> Result<Self, Error> {
217 terminal::disable_raw_mode()?;
218 stdout().execute(LeaveAlternateScreen)?;
219 Ok(Suspended)
220 }
221 }
222
223 impl Drop for Suspended {
224 fn drop(&mut self) {
225 let _ = stdout().execute(EnterAlternateScreen);
226 let _ = terminal::enable_raw_mode();
227 }
228 }
229
230 /// Suspend the dashboard, compose a body in `$EDITOR`, and come back.
231 ///
232 /// The same convention as `git commit`, and the same one the CLI settled on:
233 /// no second text editor gets written here. `#` lines are stripped on the way
234 /// back, because the buffer is seeded with the thing being commented on — a
235 /// reviewer should not lose the hunk they were reading in order to write about
236 /// it. That does mean a `#` heading cannot be written from this surface; the
237 /// CLI, which seeds nothing, keeps them.
238 fn compose(
239 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
240 seed: &str,
241 ) -> Result<Composed, Error> {
242 // Checked before anything moves: an editor that does not exist must be a
243 // sentence on the status line, never a hang and never a silent no-op.
244 if crate::editor::resolve_editor().is_none() {
245 return Err(Error::Cmd(
246 "No editor configured: set $EDITOR or $VISUAL, or use `git-collab patch comment` \
247 with --body / --body-file."
248 .to_string(),
249 ));
250 }
251
252 let composed = {
253 let _screen = Suspended::enter()?;
254 crate::editor::compose(seed)
255 };
256 // Whatever happened out there, the screen is ours again and holds someone
257 // else's output.
258 terminal.clear()?;
259
260 let body = strip_comments(&composed?);
261 if body.trim().is_empty() {
262 return Ok(Composed::Aborted);
263 }
264 Ok(Composed::Body(body))
265 }
266
267 /// Drop the `#` lines the buffer was seeded with.
268 fn strip_comments(text: &str) -> String {
269 let kept: Vec<&str> = text
270 .lines()
271 .filter(|l| !l.trim_start().starts_with('#'))
272 .collect();
273 kept.join("\n")
274 }
275
276 /// Seed an editor buffer with what is being written about.
277 fn comment_seed(headline: &str, context: Vec<String>) -> String {
278 let mut seed = format!(
279 "# {}\n# Lines starting with # are ignored; an empty message aborts.\n\n\n",
280 headline
281 );
282 if !context.is_empty() {
283 seed.push_str("\n# --- the lines you are commenting on ---\n");
284 for line in context {
285 seed.push_str(&format!("# {}\n", line));
286 }
287 }
288 seed
289 }
290
291 /// The rows around the cursor, as `#` context for the editor buffer.
292 fn cursor_context(app: &App) -> Vec<String> {
293 let start = app.patch_cursor.saturating_sub(5);
294 let end = (app.patch_cursor + 4).min(app.patch_rows.len());
295 app.patch_rows[start..end]
296 .iter()
297 .enumerate()
298 .map(|(i, row)| {
299 let marker = if start + i == app.patch_cursor {
300 ">"
301 } else {
302 " "
303 };
304 format!("{}{}", marker, row.line)
305 })
306 .collect()
307 }
308
309 fn create_issue(app: &mut App, repo: &Repository, body: &str) {
310 let title = app.create_title.clone();
311 match issue_mod::open(repo, &title, body, None) {
312 Ok(id) => {
313 app.reload(repo);
314 app.status_msg = Some(format!("Issue created: {}", app.issue_abbrev.of(&id)));
315 }
316 Err(e) => {
317 app.status_msg = Some(format!("Error creating issue: {}", e));
318 }
319 }
320 app.input_mode = InputMode::Normal;
321 app.input_buf.clear();
322 app.create_title.clear();
323 }
324
325 fn submit_comment(
326 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
327 app: &mut App,
328 repo: &Repository,
329 ) {
330 let Some(patch) = app.current_patch.clone() else {
331 return;
332 };
333 let short = app.patch_abbrev.of(&patch.id).to_string();
334 let revision = patch.revisions.get(app.patch_revision_idx).map(|r| r.number);
335
336 // Inline when the cursor is on a line of the diff, on the thread
337 // otherwise. Both go through `patch::comment`, which is the CLI's own
338 // `--at` path: the anchor is checked against the revision the comment
339 // names, not against whatever the working tree happens to hold.
340 let (headline, anchor) = match app.cursor_target() {
341 RowTarget::Diff { file, line } => (
342 format!("Commenting on {}:{} of patch {}", file, line, short),
343 Some((file, line)),
344 ),
345 _ => (
346 format!("Commenting on patch {}: {}", short, patch.title),
347 None,
348 ),
349 };
350
351 let seed = comment_seed(&headline, cursor_context(app));
352 match compose(terminal, &seed) {
353 Ok(Composed::Body(body)) => {
354 let args = crate::body::BodyArgs::new(Some(&body), None);
355 let result = match &anchor {
356 Some((file, line)) => patch_mod::comment(
357 repo,
358 &patch.id,
359 &args,
360 Some(file),
361 Some(*line),
362 revision,
363 false,
364 ),
365 None => patch_mod::comment(repo, &patch.id, &args, None, None, None, false),
366 };
367 match result {
368 Ok(report) => {
369 reload_patch(app, repo);
370 app.status_msg = Some(report.placement.to_string());
371 }
372 Err(e) => app.status_msg = Some(format!("Comment failed: {}", e)),
373 }
374 }
375 Ok(Composed::Aborted) => {
376 app.status_msg = Some("Aborting comment: empty message.".to_string())
377 }
378 Err(e) => app.status_msg = Some(e.to_string()),
379 }
380 }
381
382 fn submit_review(
383 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
384 app: &mut App,
385 repo: &Repository,
386 verdict: ReviewVerdict,
387 ) {
388 let Some(patch) = app.current_patch.clone() else {
389 return;
390 };
391 let short = app.patch_abbrev.of(&patch.id).to_string();
392 let revision = patch.revisions.get(app.patch_revision_idx).map(|r| r.number);
393 let seed = comment_seed(
394 &format!(
395 "Review of patch {}: {}\n# Verdict: {}",
396 short, patch.title, verdict
397 ),
398 Vec::new(),
399 );
400
401 match compose(terminal, &seed) {
402 Ok(Composed::Body(body)) => {
403 match patch_mod::review(repo, &patch.id, verdict, &body, revision) {
404 Ok(_) => {
405 reload_patch(app, repo);
406 app.status_msg = Some(format!("Review recorded: {}", verdict));
407 }
408 Err(e) => app.status_msg = Some(format!("Review failed: {}", e)),
409 }
410 }
411 Ok(Composed::Aborted) => {
412 app.status_msg = Some("Aborting review: empty message.".to_string())
413 }
414 Err(e) => app.status_msg = Some(e.to_string()),
415 }
416 }
417
418 fn toggle_resolve(app: &mut App, repo: &Repository) {
419 let Some(patch) = app.current_patch.clone() else {
420 return;
421 };
422 let RowTarget::Comment { oid, resolved } = app.cursor_target() else {
423 app.status_msg =
424 Some("Move to an inline comment to mark it answered (j/k).".to_string());
425 return;
426 };
427 let outcome = if resolved {
428 patch_mod::unresolve(repo, &patch.id, &oid).map(|_| "reopened")
429 } else {
430 patch_mod::resolve(repo, &patch.id, &oid, None).map(|_| "resolved")
431 };
432 match outcome {
433 Ok(what) => {
434 reload_patch(app, repo);
435 app.status_msg = Some(format!("Comment {:.8} {}", oid, what));
436 }
437 Err(e) => app.status_msg = Some(format!("Could not update the comment: {}", e)),
438 }
439 }
440
441 fn show_answers(app: &mut App, repo: &Repository) {
442 let Some(patch) = app.current_patch.clone() else {
443 return;
444 };
445 let RowTarget::Comment { oid, .. } = app.cursor_target() else {
446 app.status_msg = Some("Move to an inline comment to see what answered it.".to_string());
447 return;
448 };
449 match patch_mod::diff(repo, &patch.id, None, None, Some(&oid), &Default::default()) {
450 Ok(text) => {
451 app.set_diff_text(&text);
452 app.patch_answers = Some(oid);
453 app.patch_cursor = 0;
454 app.patch_scroll = 0;
455 app.rebuild_patch_rows();
456 }
457 Err(e) => app.status_msg = Some(format!("{}", e)),
458 }
459 }
460
461 /// Leave the dashboard for a checkout of the patch's latest revision.
462 ///
463 /// `patch::checkout` is the same code `git-collab patch checkout` runs: it
464 /// takes the latest revision's commit, which a revision ref pins and which
465 /// therefore always resolves, and leaves a branch behind with instructions for
466 /// getting back. The old behaviour checked out `PatchState.branch`, a
467 /// provenance field naming a worktree that on an agent-produced patch no
468 /// longer exists.
469 fn checkout_and_leave(app: &mut App, repo: &Repository) -> Result<(), Error> {
470 let Some(id) = app.checkout_target() else {
471 app.status_msg = Some("No patch to check out here.".to_string());
472 return Ok(());
473 };
474 terminal::disable_raw_mode()?;
475 stdout().execute(LeaveAlternateScreen)?;
476 match patch_mod::checkout(repo, &id) {
477 Ok(report) => patch_mod::report_checkout(repo, &report),
478 Err(e) => eprintln!("Checkout failed: {}", e),
479 }
480 Ok(())
481 }
482
483 /// Re-read the patch on screen after the dashboard wrote to it.
484 ///
485 /// `reload` re-baselines the tip snapshot as part of its job, which is what
486 /// keeps the staleness banner from announcing the reader's own comment back to
487 /// them as somebody else's news.
488 fn reload_patch(app: &mut App, repo: &Repository) {
489 app.reload(repo);
490 if let Some(id) = app.current_patch.as_ref().map(|p| p.id.clone()) {
491 if let Some(fresh) = app.patches.iter().find(|p| p.id == id).cloned() {
492 app.patch_revision_idx = app
493 .patch_revision_idx
494 .min(fresh.revisions.len().saturating_sub(1));
495 app.current_patch = Some(fresh);
496 }
497 }
498 regenerate_patch_diff(app, repo);
499 }
500
501 // ── Reading ─────────────────────────────────────────────────────────────────
502
243 fn generate_patch_diff_for( 503 fn generate_patch_diff_for(
244 repo: &Repository, 504 repo: &Repository,
245 patch: &crate::state::PatchState, 505 patch: &crate::state::PatchState,
246 rev_idx: usize, 506 rev_idx: usize,
247 interdiff_mode: bool, 507 interdiff_mode: bool,
248 ) -> String { 508 ) -> Result<Vec<patch_mod::DiffRow>, Error> {
249 if patch.revisions.is_empty() { 509 if patch.revisions.is_empty() {
250 return "(no revisions)".to_string(); 510 return Err(Error::Cmd("(no revisions)".to_string()));
251 } 511 }
252
253 let rev = &patch.revisions[rev_idx]; 512 let rev = &patch.revisions[rev_idx];
254 513 let from = if interdiff_mode && rev_idx > 0 {
255 if interdiff_mode && rev_idx > 0 { 514 Some(patch.revisions[rev_idx - 1].number)
256 let from_rev = patch.revisions[rev_idx - 1].number;
257 let to_rev = rev.number;
258 match patch_mod::interdiff(repo, patch, from_rev, to_rev, &Default::default()) {
259 Ok(d) => d,
260 Err(e) => format!("(error generating interdiff: {})", e),
261 }
262 } else { 515 } else {
263 // Diff at specific revision vs base 516 None
264 match patch_mod::diff(repo, &patch.id, Some(rev.number), None, None, &Default::default()) { 517 };
265 Ok(d) => d, 518 patch_mod::diff_rows_for(repo, patch, rev.number, from, &Default::default())
266 Err(e) => format!("(error generating diff: {})", e),
267 }
268 }
269 } 519 }
270 520
271 fn open_patch_detail(app: &mut App, repo: &Repository, patch: crate::state::PatchState) { 521 fn open_patch_detail(app: &mut App, repo: &Repository, patch: crate::state::PatchState) {
@@ -273,11 +523,11 @@ fn open_patch_detail(app: &mut App, repo: &Repository, patch: crate::state::Patc
273 app.patch_revision_idx = patch.revisions.len().saturating_sub(1); 523 app.patch_revision_idx = patch.revisions.len().saturating_sub(1);
274 app.patch_interdiff_mode = false; 524 app.patch_interdiff_mode = false;
275 app.patch_scroll = 0; 525 app.patch_scroll = 0;
276 526 app.patch_cursor = 0;
277 let diff = generate_patch_diff_for(repo, &patch, app.patch_revision_idx, false); 527 app.patch_answers = None;
278 app.patch_diff = diff;
279 app.current_patch = Some(patch); 528 app.current_patch = Some(patch);
280 app.mode = ViewMode::PatchDetail; 529 app.mode = ViewMode::PatchDetail;
530 regenerate_patch_diff(app, repo);
281 531
282 if let Some(w) = warning { 532 if let Some(w) = warning {
283 app.status_msg = Some(w); 533 app.status_msg = Some(w);
@@ -286,12 +536,53 @@ fn open_patch_detail(app: &mut App, repo: &Repository, patch: crate::state::Patc
286 536
287 fn regenerate_patch_diff(app: &mut App, repo: &Repository) { 537 fn regenerate_patch_diff(app: &mut App, repo: &Repository) {
288 if let Some(ref patch) = app.current_patch { 538 if let Some(ref patch) = app.current_patch {
289 let diff = generate_patch_diff_for( 539 match generate_patch_diff_for(repo, patch, app.patch_revision_idx, app.patch_interdiff_mode)
290 repo, 540 {
291 patch, 541 Ok(rows) => app.patch_diff = rows,
292 app.patch_revision_idx, 542 Err(e) => app.set_diff_text(&format!("(error generating diff: {})", e)),
293 app.patch_interdiff_mode, 543 }
544 }
545 app.rebuild_patch_rows();
546 }
547
548 #[cfg(test)]
549 mod tests {
550 use super::*;
551
552 /// The seeded context comes back out, so what the reviewer wrote is what
553 /// gets recorded — not the hunk they were reading.
554 #[test]
555 fn context_lines_do_not_reach_the_body() {
556 let stripped = strip_comments(
557 "# Commenting on src/lib.rs:3\n# Lines starting with # are ignored.\n\nwhy?\n\n# ---\n# let x = 1;\n",
558 );
559 assert_eq!(stripped.trim(), "why?");
560 }
561
562 /// The same rule `git commit` uses: an untouched buffer is an abort, not a
563 /// comment made of hash marks.
564 #[test]
565 fn an_untouched_buffer_is_blank() {
566 let seed = comment_seed("Commenting on src/lib.rs:3", vec![" let x = 1;".to_string()]);
567 assert!(strip_comments(&seed).trim().is_empty());
568 }
569
570 /// Only a leading `#` is a comment marker; a `#` inside a line is prose.
571 #[test]
572 fn a_hash_inside_a_line_survives() {
573 assert_eq!(strip_comments("issue #12 is the same bug"), "issue #12 is the same bug");
574 }
575
576 /// The buffer says what is being written about and where the cursor was,
577 /// so suspending does not cost the reviewer the thing they were reading.
578 #[test]
579 fn the_seed_carries_the_hunk() {
580 let seed = comment_seed(
581 "Commenting on src/lib.rs:3",
582 vec![" fn a() {".to_string(), "+ b();".to_string()],
294 ); 583 );
295 app.patch_diff = diff; 584 assert!(seed.starts_with("# Commenting on src/lib.rs:3\n"));
585 assert!(seed.contains("# fn a() {\n"));
586 assert!(seed.contains("# + b();\n"));
296 } 587 }
297 } 588 }
src/tui/mod.rs
Old New
@@ -1,5 +1,6 @@
1 mod events; 1 mod events;
2 mod state; 2 mod state;
3 mod tips;
3 mod widgets; 4 mod widgets;
4 5
5 use std::io::stdout; 6 use std::io::stdout;
@@ -25,6 +26,9 @@ pub fn run(repo: &Repository) -> Result<(), Error> {
25 let patch_abbrev = crate::abbrev::for_patches(repo); 26 let patch_abbrev = crate::abbrev::for_patches(repo);
26 27
27 let mut app = App::new(issues, patches, issue_abbrev, patch_abbrev); 28 let mut app = App::new(issues, patches, issue_abbrev, patch_abbrev);
29 // Where the refs stood when the lists above were folded. Without this the
30 // first tip poll would report the whole repository as news.
31 app.rebaseline(repo);
28 32
29 terminal::enable_raw_mode()?; 33 terminal::enable_raw_mode()?;
30 stdout().execute(EnterAlternateScreen)?; 34 stdout().execute(EnterAlternateScreen)?;
@@ -199,24 +203,6 @@ mod tests {
199 } 203 }
200 204
201 #[test] 205 #[test]
202 fn test_create_title_transitions_to_body() {
203 let mut app = test_app();
204 app.input_mode = InputMode::CreateTitle;
205 app.input_buf = "My new issue".into();
206
207 // Simulate Enter with non-empty title
208 let title = app.input_buf.trim().to_string();
209 assert!(!title.is_empty());
210 app.create_title = title;
211 app.input_buf.clear();
212 app.input_mode = InputMode::CreateBody;
213
214 assert_eq!(app.input_mode, InputMode::CreateBody);
215 assert_eq!(app.create_title, "My new issue");
216 assert!(app.input_buf.is_empty());
217 }
218
219 #[test]
220 fn test_empty_title_dismissed() { 206 fn test_empty_title_dismissed() {
221 let mut app = test_app(); 207 let mut app = test_app();
222 app.input_mode = InputMode::CreateTitle; 208 app.input_mode = InputMode::CreateTitle;
@@ -1223,7 +1209,7 @@ mod tests {
1223 app.patch_scroll = 5; 1209 app.patch_scroll = 5;
1224 app.patch_revision_idx = 1; 1210 app.patch_revision_idx = 1;
1225 app.patch_interdiff_mode = true; 1211 app.patch_interdiff_mode = true;
1226 app.patch_diff = "some diff".into(); 1212 app.set_diff_text("some diff");
1227 1213
1228 let result = app.handle_key( 1214 let result = app.handle_key(
1229 crossterm::event::KeyCode::Esc, 1215 crossterm::event::KeyCode::Esc,
@@ -1249,45 +1235,76 @@ mod tests {
1249 assert_eq!(result, KeyAction::Quit); 1235 assert_eq!(result, KeyAction::Quit);
1250 } 1236 }
1251 1237
1238 /// `j`/`k` move a cursor now, not the viewport. The pane has to have a
1239 /// selected line for `c` and `x` to mean "this one", and a scroll offset
1240 /// with no cursor cannot say which line a reader is looking at.
1252 #[test] 1241 #[test]
1253 fn test_patch_detail_scroll() { 1242 fn test_patch_detail_cursor_moves_line_by_line() {
1254 let mut app = test_app(); 1243 let mut app = test_app();
1255 app.mode = ViewMode::PatchDetail; 1244 app.mode = ViewMode::PatchDetail;
1256 app.current_patch = Some(make_patch_with_revisions()); 1245 app.current_patch = Some(make_patch_with_revisions());
1257 app.patch_scroll = 0; 1246 app.rebuild_patch_rows();
1258 1247
1259 app.handle_key( 1248 app.handle_key(
1260 crossterm::event::KeyCode::Char('j'), 1249 crossterm::event::KeyCode::Char('j'),
1261 crossterm::event::KeyModifiers::empty(), 1250 crossterm::event::KeyModifiers::empty(),
1262 ); 1251 );
1263 assert_eq!(app.patch_scroll, 1); 1252 assert_eq!(app.patch_cursor, 1);
1264 app.handle_key( 1253 app.handle_key(
1265 crossterm::event::KeyCode::Char('j'), 1254 crossterm::event::KeyCode::Char('j'),
1266 crossterm::event::KeyModifiers::empty(), 1255 crossterm::event::KeyModifiers::empty(),
1267 ); 1256 );
1268 assert_eq!(app.patch_scroll, 2); 1257 assert_eq!(app.patch_cursor, 2);
1269 app.handle_key( 1258 app.handle_key(
1270 crossterm::event::KeyCode::Char('k'), 1259 crossterm::event::KeyCode::Char('k'),
1271 crossterm::event::KeyModifiers::empty(), 1260 crossterm::event::KeyModifiers::empty(),
1272 ); 1261 );
1273 assert_eq!(app.patch_scroll, 1); 1262 assert_eq!(app.patch_cursor, 1);
1263 // Two lines down in a pane twenty lines tall is still on screen, so
1264 // nothing has scrolled: the text under a reader must not jump.
1265 assert_eq!(app.patch_scroll, 0);
1274 } 1266 }
1275 1267
1268 /// The cursor clamps to the last row rather than running off the end.
1276 #[test] 1269 #[test]
1277 fn test_patch_detail_page_scroll() { 1270 fn test_patch_detail_cursor_clamps_at_the_end() {
1278 let mut app = test_app(); 1271 let mut app = test_app();
1279 app.mode = ViewMode::PatchDetail; 1272 app.mode = ViewMode::PatchDetail;
1280 app.patch_scroll = 0; 1273 app.current_patch = Some(make_patch_with_revisions());
1274 app.rebuild_patch_rows();
1275 let last = app.patch_rows.len() - 1;
1276
1277 for _ in 0..500 {
1278 app.handle_key(
1279 crossterm::event::KeyCode::Char('j'),
1280 crossterm::event::KeyModifiers::empty(),
1281 );
1282 }
1283 assert_eq!(app.patch_cursor, last);
1284 }
1285
1286 /// Once the cursor leaves the visible rows, the view follows it — and by
1287 /// exactly as much as it has to.
1288 #[test]
1289 fn test_patch_detail_scroll_follows_the_cursor() {
1290 let mut app = test_app();
1291 app.mode = ViewMode::PatchDetail;
1292 app.current_patch = Some(make_patch_with_revisions());
1293 app.set_diff_text(&(0..100).map(|i| format!(" line {}\n", i)).collect::<String>());
1294 app.rebuild_patch_rows();
1295 app.patch_viewport = 10;
1281 1296
1282 app.handle_key( 1297 app.handle_key(
1283 crossterm::event::KeyCode::PageDown, 1298 crossterm::event::KeyCode::PageDown,
1284 crossterm::event::KeyModifiers::empty(), 1299 crossterm::event::KeyModifiers::empty(),
1285 ); 1300 );
1286 assert_eq!(app.patch_scroll, 20); 1301 assert_eq!(app.patch_cursor, 20);
1302 assert_eq!(app.patch_scroll, 11);
1287 app.handle_key( 1303 app.handle_key(
1288 crossterm::event::KeyCode::PageUp, 1304 crossterm::event::KeyCode::PageUp,
1289 crossterm::event::KeyModifiers::empty(), 1305 crossterm::event::KeyModifiers::empty(),
1290 ); 1306 );
1307 assert_eq!(app.patch_cursor, 0);
1291 assert_eq!(app.patch_scroll, 0); 1308 assert_eq!(app.patch_scroll, 0);
1292 } 1309 }
1293 1310
@@ -1361,7 +1378,8 @@ mod tests {
1361 app.mode = ViewMode::PatchDetail; 1378 app.mode = ViewMode::PatchDetail;
1362 app.current_patch = Some(make_patch_with_revisions()); 1379 app.current_patch = Some(make_patch_with_revisions());
1363 app.patch_revision_idx = 1; 1380 app.patch_revision_idx = 1;
1364 app.patch_diff = "+added line\n-removed line\n context".into(); 1381 app.set_diff_text("+added line\n-removed line\n context");
1382 app.rebuild_patch_rows();
1365 1383
1366 let buf = render_app(&mut app); 1384 let buf = render_app(&mut app);
1367 assert_buffer_contains(&buf, "Patch Detail"); 1385 assert_buffer_contains(&buf, "Patch Detail");
@@ -1375,7 +1393,8 @@ mod tests {
1375 let mut app = make_app(3, 0); 1393 let mut app = make_app(3, 0);
1376 app.mode = ViewMode::PatchDetail; 1394 app.mode = ViewMode::PatchDetail;
1377 app.current_patch = Some(make_patch_with_revisions()); 1395 app.current_patch = Some(make_patch_with_revisions());
1378 app.patch_diff = String::new(); 1396 app.patch_diff.clear();
1397 app.rebuild_patch_rows();
1379 1398
1380 let buf = render_app(&mut app); 1399 let buf = render_app(&mut app);
1381 assert_buffer_contains(&buf, "needs-review"); 1400 assert_buffer_contains(&buf, "needs-review");
@@ -1387,7 +1406,8 @@ mod tests {
1387 app.mode = ViewMode::PatchDetail; 1406 app.mode = ViewMode::PatchDetail;
1388 app.current_patch = Some(make_patch_with_revisions()); 1407 app.current_patch = Some(make_patch_with_revisions());
1389 app.patch_revision_idx = 1; 1408 app.patch_revision_idx = 1;
1390 app.patch_diff = String::new(); 1409 app.patch_diff.clear();
1410 app.rebuild_patch_rows();
1391 1411
1392 let buf = render_app(&mut app); 1412 let buf = render_app(&mut app);
1393 assert_buffer_contains(&buf, "Reviews"); 1413 assert_buffer_contains(&buf, "Reviews");
@@ -1395,15 +1415,27 @@ mod tests {
1395 assert_buffer_contains(&buf, "LGTM"); 1415 assert_buffer_contains(&buf, "LGTM");
1396 } 1416 }
1397 1417
1418 /// The review loop has to be discoverable, so those keys come first and
1419 /// survive an eighty-column terminal. The rest is on the same line and
1420 /// clips there, which is why it is ordered the way it is.
1398 #[test] 1421 #[test]
1399 fn test_render_patch_detail_footer() { 1422 fn test_render_patch_detail_footer() {
1400 let mut app = make_app(3, 0); 1423 let mut app = make_app(3, 0);
1401 app.mode = ViewMode::PatchDetail; 1424 app.mode = ViewMode::PatchDetail;
1402 let buf = render_app(&mut app); 1425 let buf = render_app(&mut app);
1426 assert_buffer_contains(&buf, "c:comment");
1427 assert_buffer_contains(&buf, "R:review");
1428 assert_buffer_contains(&buf, "x:resolve");
1429 assert_buffer_contains(&buf, "a:answers");
1403 assert_buffer_contains(&buf, "Esc:back"); 1430 assert_buffer_contains(&buf, "Esc:back");
1404 assert_buffer_contains(&buf, "[/]:revision"); 1431
1405 // "d:interdiff" may be truncated at 80 cols, check prefix 1432 let backend = TestBackend::new(120, 24);
1406 assert_buffer_contains(&buf, "d:inter"); 1433 let mut terminal = Terminal::new(backend).unwrap();
1434 terminal.draw(|frame| ui(frame, &mut app, None)).unwrap();
1435 let wide = terminal.backend().buffer().clone();
1436 assert_buffer_contains(&wide, "[/]:revision");
1437 assert_buffer_contains(&wide, "d:interdiff");
1438 assert_buffer_contains(&wide, "o:checkout");
1407 } 1439 }
1408 1440
1409 #[test] 1441 #[test]
@@ -1411,6 +1443,7 @@ mod tests {
1411 let mut app = make_app(3, 0); 1443 let mut app = make_app(3, 0);
1412 app.mode = ViewMode::PatchDetail; 1444 app.mode = ViewMode::PatchDetail;
1413 app.current_patch = None; 1445 app.current_patch = None;
1446 app.rebuild_patch_rows();
1414 1447
1415 let buf = render_app(&mut app); 1448 let buf = render_app(&mut app);
1416 assert_buffer_contains(&buf, "No patch loaded"); 1449 assert_buffer_contains(&buf, "No patch loaded");
@@ -1444,8 +1477,9 @@ mod tests {
1444 // Simulate what events.rs does 1477 // Simulate what events.rs does
1445 app.current_patch = Some(make_patch_with_revisions()); 1478 app.current_patch = Some(make_patch_with_revisions());
1446 app.patch_revision_idx = 1; 1479 app.patch_revision_idx = 1;
1447 app.patch_diff = "diff content".into(); 1480 app.set_diff_text("diff content");
1448 app.mode = ViewMode::PatchDetail; 1481 app.mode = ViewMode::PatchDetail;
1482 app.rebuild_patch_rows();
1449 1483
1450 // Navigate revisions 1484 // Navigate revisions
1451 app.handle_key( 1485 app.handle_key(
@@ -1461,12 +1495,12 @@ mod tests {
1461 ); 1495 );
1462 assert!(app.patch_interdiff_mode); 1496 assert!(app.patch_interdiff_mode);
1463 1497
1464 // Scroll 1498 // Walk a line down
1465 app.handle_key( 1499 app.handle_key(
1466 crossterm::event::KeyCode::Char('j'), 1500 crossterm::event::KeyCode::Char('j'),
1467 crossterm::event::KeyModifiers::empty(), 1501 crossterm::event::KeyModifiers::empty(),
1468 ); 1502 );
1469 assert_eq!(app.patch_scroll, 1); 1503 assert_eq!(app.patch_cursor, 1);
1470 1504
1471 // Escape back 1505 // Escape back
1472 app.handle_key( 1506 app.handle_key(
@@ -1477,6 +1511,162 @@ mod tests {
1477 assert!(app.current_patch.is_none()); 1511 assert!(app.current_patch.is_none());
1478 } 1512 }
1479 1513
1514 // ── The review surface ───────────────────────────────────────────────
1515
1516 /// A `+` line of the diff carries the anchor `patch comment --at` accepts,
1517 /// so `c` there is an inline comment and not a thread one. The anchor is
1518 /// the one the diff machinery computed, not one reconstructed here.
1519 #[test]
1520 fn test_a_diff_line_carries_its_anchor() {
1521 let mut app = make_app(0, 0);
1522 app.mode = ViewMode::PatchDetail;
1523 app.current_patch = Some(make_patch_with_revisions());
1524 app.patch_diff = vec![
1525 crate::patch::DiffRow {
1526 text: "@@ -1,2 +1,3 @@\n".into(),
1527 body: false,
1528 anchor: None,
1529 },
1530 crate::patch::DiffRow {
1531 text: "+let x = 1;\n".into(),
1532 body: true,
1533 anchor: Some(("src/main.rs".to_string(), 42)),
1534 },
1535 ];
1536 app.rebuild_patch_rows();
1537
1538 let anchored: Vec<&RowTarget> = app
1539 .patch_rows
1540 .iter()
1541 .map(|r| &r.target)
1542 .filter(|t| matches!(t, RowTarget::Diff { .. }))
1543 .collect();
1544 assert_eq!(
1545 anchored,
1546 vec![&RowTarget::Diff {
1547 file: "src/main.rs".to_string(),
1548 line: 42
1549 }]
1550 );
1551 }
1552
1553 /// A deleted line is not in the revision's tree, so it offers no anchor —
1554 /// the same rule `patch diff --line-numbers` follows. `c` there falls back
1555 /// to a thread comment rather than proposing an anchor that would be
1556 /// rejected.
1557 #[test]
1558 fn test_a_deleted_line_offers_no_anchor() {
1559 let mut app = make_app(0, 0);
1560 app.mode = ViewMode::PatchDetail;
1561 app.current_patch = Some(make_patch_with_revisions());
1562 app.patch_diff = vec![crate::patch::DiffRow {
1563 text: "-gone\n".into(),
1564 body: true,
1565 anchor: None,
1566 }];
1567 app.rebuild_patch_rows();
1568
1569 assert!(app
1570 .patch_rows
1571 .iter()
1572 .all(|r| !matches!(r.target, RowTarget::Diff { .. })));
1573 }
1574
1575 /// `x` needs to name a comment, and it has to name it the way `patch show`
1576 /// does, because a reviewer reads the id off one surface and types it into
1577 /// the other.
1578 #[test]
1579 fn test_an_inline_comment_row_carries_the_id_patch_show_prints() {
1580 let mut app = make_app(0, 0);
1581 app.mode = ViewMode::PatchDetail;
1582 let patch = make_patch_with_revisions();
1583 let expected = patch.inline_comments[0].commit_id.to_string();
1584 app.current_patch = Some(patch);
1585 app.rebuild_patch_rows();
1586
1587 let found = app
1588 .patch_rows
1589 .iter()
1590 .find_map(|r| match &r.target {
1591 RowTarget::Comment { oid, resolved } => Some((oid.clone(), *resolved)),
1592 _ => None,
1593 })
1594 .expect("an inline comment row");
1595 assert_eq!(found, (expected.clone(), false));
1596 // And the same eight characters are on screen, in brackets.
1597 let buf = render_app(&mut app);
1598 assert_buffer_contains(&buf, &format!("[{}]", &expected[..8]));
1599 }
1600
1601 /// Nothing else on the pane is a comment or a line of a file, so `c` there
1602 /// is a thread comment and `x` there has nothing to act on.
1603 #[test]
1604 fn test_prose_rows_have_no_target() {
1605 let mut app = make_app(0, 0);
1606 app.mode = ViewMode::PatchDetail;
1607 app.current_patch = Some(make_patch_with_revisions());
1608 app.rebuild_patch_rows();
1609
1610 app.patch_cursor = 1; // the Title: line
1611 assert_eq!(app.cursor_target(), RowTarget::None);
1612 }
1613
1614 /// `o` names a patch, never a branch. `PatchState.branch` is provenance —
1615 /// on an agent-produced patch it names a worktree that is long gone — and
1616 /// checking it out was the failure this replaces.
1617 #[test]
1618 fn test_checkout_target_is_a_patch_id_not_a_branch() {
1619 let mut app = test_app();
1620 app.list_mode = ListMode::Patches;
1621 app.patch_list_state.select(Some(0));
1622
1623 assert_eq!(app.checkout_target(), Some("p1".to_string()));
1624 }
1625
1626 #[test]
1627 fn test_checkout_target_follows_the_open_patch() {
1628 let mut app = test_app();
1629 app.mode = ViewMode::PatchDetail;
1630 app.current_patch = Some(make_patch_with_revisions());
1631 assert_eq!(app.checkout_target(), Some("deadbeef".to_string()));
1632 }
1633
1634 // ── Staleness ────────────────────────────────────────────────────────
1635
1636 /// Unconditional, whether or not anything changed. This alone is what
1637 /// turns a stale screen from silently wrong into merely old.
1638 #[test]
1639 fn test_render_says_when_it_was_loaded() {
1640 let mut app = make_app(3, 0);
1641 let buf = render_app(&mut app);
1642 assert_buffer_contains(&buf, &format!("as of {}", app.loaded_at.format("%H:%M")));
1643 }
1644
1645 #[test]
1646 fn test_no_banner_when_nothing_moved() {
1647 let mut app = make_app(3, 0);
1648 app.new_events = 0;
1649 let buf = render_app(&mut app);
1650 let text = buffer_to_string(&buf);
1651 assert!(!text.contains("new event"), "{}", text);
1652 }
1653
1654 /// The count is of events rather than of objects: "4 new events" is honest
1655 /// and costs a walk of what moved, where "2 issues changed" would cost the
1656 /// fold the whole design avoids.
1657 #[test]
1658 fn test_banner_counts_events() {
1659 let mut app = make_app(3, 0);
1660 app.new_events = 4;
1661 let buf = render_app(&mut app);
1662 assert_buffer_contains(&buf, "4 new events");
1663 assert_buffer_contains(&buf, "r to reload");
1664
1665 app.new_events = 1;
1666 let buf = render_app(&mut app);
1667 assert_buffer_contains(&buf, "1 new event ");
1668 }
1669
1480 #[test] 1670 #[test]
1481 fn test_linked_patch_for_selected() { 1671 fn test_linked_patch_for_selected() {
1482 let mut app = test_app(); 1672 let mut app = test_app();
src/tui/state.rs
Old New
@@ -1,3 +1,4 @@
1 use chrono::{DateTime, Local};
1 use crossterm::event::{KeyCode, KeyModifiers}; 2 use crossterm::event::{KeyCode, KeyModifiers};
2 use git2::{Oid, Repository}; 3 use git2::{Oid, Repository};
3 use ratatui::widgets::ListState; 4 use ratatui::widgets::ListState;
@@ -5,6 +6,9 @@ use ratatui::widgets::ListState;
5 use crate::abbrev::Abbrev; 6 use crate::abbrev::Abbrev;
6 use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; 7 use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus};
7 8
9 use super::tips::Tips;
10 use super::widgets::{DetailRow, RowTarget};
11
8 #[derive(Debug, PartialEq)] 12 #[derive(Debug, PartialEq)]
9 pub(crate) enum Pane { 13 pub(crate) enum Pane {
10 ItemList, 14 ItemList,
@@ -27,6 +31,16 @@ pub(crate) enum KeyAction {
27 OpenCommitBrowser, 31 OpenCommitBrowser,
28 OpenPatchDetail, 32 OpenPatchDetail,
29 OpenPatchDetailDirect(usize), // index into visible_patches 33 OpenPatchDetailDirect(usize), // index into visible_patches
34 /// Comment on the row under the cursor: inline when it is a line of the
35 /// diff, on the patch's thread otherwise.
36 Comment,
37 /// Claim, or withdraw the claim, that the comment under the cursor was
38 /// answered.
39 ToggleResolve,
40 /// Show the change that answered the comment under the cursor.
41 ShowAnswers,
42 /// Check out the patch's latest revision for local browsing.
43 Checkout,
30 } 44 }
31 45
32 #[derive(Debug, PartialEq, Clone, Copy)] 46 #[derive(Debug, PartialEq, Clone, Copy)]
@@ -65,7 +79,9 @@ pub(crate) enum InputMode {
65 Normal, 79 Normal,
66 Search, 80 Search,
67 CreateTitle, 81 CreateTitle,
68 CreateBody, 82 /// `R` was pressed: waiting for the verdict, which is the one part of a
83 /// review too small to be worth an editor round trip.
84 ReviewVerdict,
69 } 85 }
70 86
71 pub(crate) struct App { 87 pub(crate) struct App {
@@ -99,10 +115,33 @@ pub(crate) struct App {
99 pub(crate) event_list_state: ListState, 115 pub(crate) event_list_state: ListState,
100 // Patch detail view state 116 // Patch detail view state
101 pub(crate) current_patch: Option<PatchState>, 117 pub(crate) current_patch: Option<PatchState>,
102 pub(crate) patch_diff: String, 118 /// The diff on screen, row by row, each carrying the `--at` anchor a
119 /// comment on it would use. Rows rather than a string because the cursor
120 /// has to be able to say which line of which file it is sitting on.
121 pub(crate) patch_diff: Vec<crate::patch::DiffRow>,
122 /// Every line the patch detail pane renders, with what a write key would
123 /// act on. Built when the view is built rather than during a draw: the key
124 /// handler needs it, and the key handler never renders.
125 pub(crate) patch_rows: Vec<DetailRow>,
126 /// Which of `patch_rows` the cursor is on.
127 pub(crate) patch_cursor: usize,
128 /// Rows the detail pane last had room for, so the scroll can follow the
129 /// cursor. Only the renderer knows this; it is recorded on the way past.
130 pub(crate) patch_viewport: u16,
131 /// When set, the diff on screen is the change that answered this comment
132 /// rather than the revision's own diff.
133 pub(crate) patch_answers: Option<String>,
103 pub(crate) patch_scroll: u16, 134 pub(crate) patch_scroll: u16,
104 pub(crate) patch_revision_idx: usize, 135 pub(crate) patch_revision_idx: usize,
105 pub(crate) patch_interdiff_mode: bool, 136 pub(crate) patch_interdiff_mode: bool,
137 /// When the lists on screen were folded, and where the collab refs stood
138 /// at that moment. Together these are the difference between a screen that
139 /// is merely old and one that is silently wrong.
140 pub(crate) loaded_at: DateTime<Local>,
141 pub(crate) tips: Tips,
142 /// Events that landed since `tips` was taken. Never acted on by itself —
143 /// it raises a banner, and the reader reloads when they are ready.
144 pub(crate) new_events: usize,
106 } 145 }
107 146
108 impl App { 147 impl App {
@@ -140,13 +179,91 @@ impl App {
140 event_history: Vec::new(), 179 event_history: Vec::new(),
141 event_list_state: ListState::default(), 180 event_list_state: ListState::default(),
142 current_patch: None, 181 current_patch: None,
143 patch_diff: String::new(), 182 patch_diff: Vec::new(),
183 patch_rows: Vec::new(),
184 patch_cursor: 0,
185 patch_viewport: 20,
186 patch_answers: None,
144 patch_scroll: 0, 187 patch_scroll: 0,
145 patch_revision_idx: 0, 188 patch_revision_idx: 0,
146 patch_interdiff_mode: false, 189 patch_interdiff_mode: false,
190 loaded_at: Local::now(),
191 tips: Tips::default(),
192 new_events: 0,
193 }
194 }
195
196 /// Take the diff on screen from plain text, with no anchors.
197 ///
198 /// For the cases that are prose rather than a diff — an error from the
199 /// diff machinery, or a patch with no revisions. Nothing in them is a line
200 /// of a file, so nothing in them is anchorable.
201 pub(crate) fn set_diff_text(&mut self, text: &str) {
202 self.patch_diff = text
203 .lines()
204 .map(|l| crate::patch::DiffRow {
205 text: format!("{}\n", l),
206 body: false,
207 anchor: None,
208 })
209 .collect();
210 }
211
212 /// Rebuild the patch detail pane's rows from the state behind them.
213 ///
214 /// Called whenever the patch, the revision, the diff mode or the comments
215 /// change — never from a draw. The key handler acts on these rows, so they
216 /// have to be the rows the reader was looking at when they pressed the
217 /// key, not the rows a subsequent redraw would have produced.
218 pub(crate) fn rebuild_patch_rows(&mut self) {
219 self.patch_rows = super::widgets::build_patch_detail_rows(self);
220 if self.patch_cursor >= self.patch_rows.len() {
221 self.patch_cursor = self.patch_rows.len().saturating_sub(1);
222 }
223 self.follow_cursor();
224 }
225
226 /// The row the cursor is on in the patch detail pane.
227 pub(crate) fn cursor_target(&self) -> RowTarget {
228 self.patch_rows
229 .get(self.patch_cursor)
230 .map(|r| r.target.clone())
231 .unwrap_or_default()
232 }
233
234 fn move_cursor(&mut self, delta: i32) {
235 let len = self.patch_rows.len();
236 if len == 0 {
237 self.patch_cursor = 0;
238 return;
239 }
240 self.patch_cursor = if delta > 0 {
241 (self.patch_cursor + delta as usize).min(len - 1)
242 } else {
243 self.patch_cursor.saturating_sub((-delta) as usize)
244 };
245 self.follow_cursor();
246 }
247
248 /// Keep the cursor on screen without moving the view any further than it
249 /// has to: a reader walking a diff should not have the text jump.
250 fn follow_cursor(&mut self) {
251 let height = self.patch_viewport.max(1) as usize;
252 let top = self.patch_scroll as usize;
253 if self.patch_cursor < top {
254 self.patch_scroll = self.patch_cursor as u16;
255 } else if self.patch_cursor >= top + height {
256 self.patch_scroll = (self.patch_cursor + 1 - height) as u16;
147 } 257 }
148 } 258 }
149 259
260 /// Reset what a change of revision or diff mode invalidates.
261 fn reset_patch_view(&mut self) {
262 self.patch_scroll = 0;
263 self.patch_cursor = 0;
264 self.patch_answers = None;
265 }
266
150 pub(crate) fn matches_search(&self, title: &str) -> bool { 267 pub(crate) fn matches_search(&self, title: &str) -> bool {
151 if self.search_query.is_empty() { 268 if self.search_query.is_empty() {
152 return true; 269 return true;
@@ -227,9 +344,10 @@ impl App {
227 self.pane = Pane::ItemList; 344 self.pane = Pane::ItemList;
228 self.current_patch = None; 345 self.current_patch = None;
229 self.patch_diff.clear(); 346 self.patch_diff.clear();
230 self.patch_scroll = 0; 347 self.patch_rows.clear();
231 self.patch_revision_idx = 0; 348 self.patch_revision_idx = 0;
232 self.patch_interdiff_mode = false; 349 self.patch_interdiff_mode = false;
350 self.reset_patch_view();
233 return KeyAction::Continue; 351 return KeyAction::Continue;
234 } 352 }
235 KeyCode::Char('q') => return KeyAction::Quit, 353 KeyCode::Char('q') => return KeyAction::Quit,
@@ -237,27 +355,36 @@ impl App {
237 return KeyAction::Quit; 355 return KeyAction::Quit;
238 } 356 }
239 KeyCode::Char('j') | KeyCode::Down => { 357 KeyCode::Char('j') | KeyCode::Down => {
240 self.patch_scroll = self.patch_scroll.saturating_add(1); 358 self.move_cursor(1);
241 return KeyAction::Continue; 359 return KeyAction::Continue;
242 } 360 }
243 KeyCode::Char('k') | KeyCode::Up => { 361 KeyCode::Char('k') | KeyCode::Up => {
244 self.patch_scroll = self.patch_scroll.saturating_sub(1); 362 self.move_cursor(-1);
245 return KeyAction::Continue; 363 return KeyAction::Continue;
246 } 364 }
247 KeyCode::PageDown => { 365 KeyCode::PageDown => {
248 self.patch_scroll = self.patch_scroll.saturating_add(20); 366 self.move_cursor(20);
249 return KeyAction::Continue; 367 return KeyAction::Continue;
250 } 368 }
251 KeyCode::PageUp => { 369 KeyCode::PageUp => {
252 self.patch_scroll = self.patch_scroll.saturating_sub(20); 370 self.move_cursor(-20);
371 return KeyAction::Continue;
372 }
373 // ── The review loop ──────────────────────────────────────
374 KeyCode::Char('c') => return KeyAction::Comment,
375 KeyCode::Char('R') => {
376 self.input_mode = InputMode::ReviewVerdict;
253 return KeyAction::Continue; 377 return KeyAction::Continue;
254 } 378 }
379 KeyCode::Char('x') => return KeyAction::ToggleResolve,
380 KeyCode::Char('a') => return KeyAction::ShowAnswers,
381 KeyCode::Char('o') => return KeyAction::Checkout,
255 KeyCode::Char(']') => { 382 KeyCode::Char(']') => {
256 if let Some(ref patch) = self.current_patch { 383 if let Some(ref patch) = self.current_patch {
257 let max = patch.revisions.len().saturating_sub(1); 384 let max = patch.revisions.len().saturating_sub(1);
258 if self.patch_revision_idx < max { 385 if self.patch_revision_idx < max {
259 self.patch_revision_idx += 1; 386 self.patch_revision_idx += 1;
260 self.patch_scroll = 0; 387 self.reset_patch_view();
261 return KeyAction::Reload; // signal to regenerate diff 388 return KeyAction::Reload; // signal to regenerate diff
262 } 389 }
263 } 390 }
@@ -266,14 +393,14 @@ impl App {
266 KeyCode::Char('[') => { 393 KeyCode::Char('[') => {
267 if self.patch_revision_idx > 0 { 394 if self.patch_revision_idx > 0 {
268 self.patch_revision_idx -= 1; 395 self.patch_revision_idx -= 1;
269 self.patch_scroll = 0; 396 self.reset_patch_view();
270 return KeyAction::Reload; // signal to regenerate diff 397 return KeyAction::Reload; // signal to regenerate diff
271 } 398 }
272 return KeyAction::Continue; 399 return KeyAction::Continue;
273 } 400 }
274 KeyCode::Char('d') => { 401 KeyCode::Char('d') => {
275 self.patch_interdiff_mode = !self.patch_interdiff_mode; 402 self.patch_interdiff_mode = !self.patch_interdiff_mode;
276 self.patch_scroll = 0; 403 self.reset_patch_view();
277 return KeyAction::Reload; // signal to regenerate diff 404 return KeyAction::Reload; // signal to regenerate diff
278 } 405 }
279 _ => return KeyAction::Continue, 406 _ => return KeyAction::Continue,
@@ -446,6 +573,27 @@ impl App {
446 } 573 }
447 } 574 }
448 575
576 /// The patch `o` would check out, whichever view is on screen.
577 ///
578 /// An id, never a branch name. `PatchState.branch` has been provenance
579 /// only since revision refs landed — it records where the patch was
580 /// authored, and on an agent-produced patch that worktree is long gone —
581 /// so checking it out failed on exactly the patches this tool is for. The
582 /// latest revision's commit is pinned by a revision ref and always
583 /// resolves.
584 pub(crate) fn checkout_target(&self) -> Option<String> {
585 if self.mode == ViewMode::PatchDetail {
586 return self.current_patch.as_ref().map(|p| p.id.clone());
587 }
588 match self.list_mode {
589 ListMode::Issues => self.linked_patch_for_selected().map(|p| p.id.clone()),
590 ListMode::Patches => {
591 let idx = self.patch_list_state.selected()?;
592 self.visible_patches().get(idx).map(|p| p.id.clone())
593 }
594 }
595 }
596
449 pub(crate) fn selected_item_id(&self) -> Option<String> { 597 pub(crate) fn selected_item_id(&self) -> Option<String> {
450 let idx = self.list_state.selected()?; 598 let idx = self.list_state.selected()?;
451 let visible = self.visible_issues(); 599 let visible = self.visible_issues();
@@ -457,7 +605,32 @@ impl App {
457 Some(format!("refs/collab/issues/{}", id)) 605 Some(format!("refs/collab/issues/{}", id))
458 } 606 }
459 607
608 /// Take the tips as the new baseline, and note when.
609 ///
610 /// Called from [`App::reload`] rather than only from the timer, and that
611 /// is the whole of the "your own comment is not news" rule: every write
612 /// the dashboard makes is followed by a reload, and a reload starts
613 /// counting again from where the repository stands now.
614 pub(crate) fn rebaseline(&mut self, repo: &Repository) {
615 self.tips = Tips::read(repo);
616 self.new_events = 0;
617 self.loaded_at = Local::now();
618 }
619
620 /// Compare the collab tips against the baseline, without folding anything.
621 pub(crate) fn poll_tips(&mut self, repo: &Repository) {
622 let now = Tips::read(repo);
623 if now == self.tips {
624 return;
625 }
626 // Count against the baseline, not against the last tick, so the banner
627 // says how far behind the screen is rather than how much arrived in
628 // the last two seconds.
629 self.new_events = self.tips.events_since(repo, &now);
630 }
631
460 pub(crate) fn reload(&mut self, repo: &Repository) { 632 pub(crate) fn reload(&mut self, repo: &Repository) {
633 self.rebaseline(repo);
461 if let Ok(issues) = state::list_issues(repo) { 634 if let Ok(issues) = state::list_issues(repo) {
462 self.issues = issues; 635 self.issues = issues;
463 } 636 }
src/tui/tips.rs
Old New
@@ -0,0 +1,173 @@
1 //! Noticing that somebody else wrote, without reading what they wrote.
2 //!
3 //! The dashboard used to show whatever was true the last time `r` was pressed,
4 //! with nothing on screen admitting it. The fix is not to reload on a timer:
5 //! this is a review surface, and rows appearing, vanishing or reordering while
6 //! someone is halfway through a comment is worse than being briefly out of
7 //! date. So the timer only *compares tips* — a ref read — and when they move,
8 //! a banner says so and the reader presses `r` when they are ready.
9 //!
10 //! Deliberately not a fold. `list_issues`/`list_patches` walk every DAG in the
11 //! repository; doing that every couple of seconds to discover that nothing
12 //! changed is the cost this exists to avoid.
13
14 use git2::{Oid, Repository};
15
16 /// Where every collab DAG stood at a moment in time.
17 #[derive(Debug, Default, Clone, PartialEq)]
18 pub(crate) struct Tips {
19 /// `(ref name, tip)`, sorted by name so two snapshots compare directly.
20 entries: Vec<(String, Oid)>,
21 }
22
23 impl Tips {
24 /// Read the current tips. Purely a ref read: nothing is folded, nothing is
25 /// written, and a repository that cannot be enumerated yields an empty
26 /// snapshot rather than an error the dashboard would have to render.
27 pub(crate) fn read(repo: &Repository) -> Self {
28 let mut entries = Vec::new();
29 if let Ok(refs) = repo.references_glob("refs/collab/**") {
30 for r in refs.flatten() {
31 let Some(name) = r.name() else { continue };
32 // `refs/collab/local/` is this clone's own bookkeeping — the
33 // `seen/` read markers `patch show` moves, and the migration
34 // parking spots. Counting those would make reading a patch in
35 // another terminal look like somebody else said something.
36 if name.starts_with("refs/collab/local/") {
37 continue;
38 }
39 let Some(oid) = r.target() else { continue };
40 entries.push((name.to_string(), oid));
41 }
42 }
43 entries.sort();
44 Tips { entries }
45 }
46
47 /// How many events arrived since `self` was taken.
48 ///
49 /// Only walked for refs that actually moved, which is the whole point of
50 /// comparing tips first: on the common tick nothing moved and this is not
51 /// called at all. A ref that appeared since the snapshot contributes its
52 /// whole history, which for a new issue or patch is the one or two events
53 /// that created it.
54 pub(crate) fn events_since(&self, repo: &Repository, now: &Tips) -> usize {
55 let mut count = 0usize;
56 for (name, tip) in &now.entries {
57 match self.entries.binary_search_by(|(n, _)| n.as_str().cmp(name)) {
58 Ok(idx) => {
59 let was = self.entries[idx].1;
60 if was == *tip {
61 continue;
62 }
63 count += match repo.graph_ahead_behind(*tip, was) {
64 Ok((ahead, _behind)) => ahead.max(1),
65 // Unrelated histories, or an object this clone lacks:
66 // the ref moved, so something happened. Say one rather
67 // than nothing.
68 Err(_) => 1,
69 };
70 }
71 Err(_) => count += walk_len(repo, *tip),
72 }
73 }
74 count
75 }
76 }
77
78 /// Number of commits reachable from `tip`, or 1 when it cannot be walked.
79 fn walk_len(repo: &Repository, tip: Oid) -> usize {
80 let Ok(mut walk) = repo.revwalk() else {
81 return 1;
82 };
83 if walk.push(tip).is_err() {
84 return 1;
85 }
86 walk.count().max(1)
87 }
88
89 #[cfg(test)]
90 mod tests {
91 use super::*;
92
93 /// A repo with one commit, and a helper for pointing collab refs at it.
94 fn scratch_repo() -> (tempfile::TempDir, Repository, Oid) {
95 let dir = tempfile::TempDir::new().unwrap();
96 let repo = Repository::init(dir.path()).unwrap();
97 let first = {
98 let sig = git2::Signature::now("t", "t@t").unwrap();
99 let mut index = repo.index().unwrap();
100 let tree_oid = index.write_tree().unwrap();
101 let tree = repo.find_tree(tree_oid).unwrap();
102 repo.commit(Some("HEAD"), &sig, &sig, "one", &tree, &[])
103 .unwrap()
104 };
105 (dir, repo, first)
106 }
107
108 #[test]
109 fn a_moved_ref_counts_the_events_that_moved_it() {
110 let (_dir, repo, first) = scratch_repo();
111 repo.reference("refs/collab/issues/abc", first, true, "seed")
112 .unwrap();
113 let before = Tips::read(&repo);
114
115 let sig = git2::Signature::now("t", "t@t").unwrap();
116 let tree = repo.find_commit(first).unwrap().tree().unwrap();
117 let parent = repo.find_commit(first).unwrap();
118 let second = repo
119 .commit(None, &sig, &sig, "two", &tree, &[&parent])
120 .unwrap();
121 repo.reference("refs/collab/issues/abc", second, true, "move")
122 .unwrap();
123
124 let after = Tips::read(&repo);
125 assert_ne!(before, after);
126 assert_eq!(before.events_since(&repo, &after), 1);
127 }
128
129 /// The `seen/` markers `patch show` moves are this clone's own
130 /// bookkeeping. Counting them would make reading a patch in another
131 /// terminal look like somebody else had said something.
132 #[test]
133 fn local_bookkeeping_refs_are_not_news() {
134 let (_dir, repo, first) = scratch_repo();
135 repo.reference("refs/collab/issues/abc", first, true, "seed")
136 .unwrap();
137 let before = Tips::read(&repo);
138
139 repo.reference(
140 "refs/collab/local/seen/patches/abc",
141 first,
142 true,
143 "mark seen",
144 )
145 .unwrap();
146
147 let after = Tips::read(&repo);
148 assert_eq!(before, after, "a local marker showed up as a tip");
149 assert_eq!(before.events_since(&repo, &after), 0);
150 }
151
152 /// A ref that did not exist at the baseline contributes its history — for
153 /// a new issue, the one event that opened it.
154 #[test]
155 fn a_new_ref_counts_as_news() {
156 let (_dir, repo, first) = scratch_repo();
157 let before = Tips::read(&repo);
158 repo.reference("refs/collab/issues/abc", first, true, "seed")
159 .unwrap();
160 let after = Tips::read(&repo);
161 assert_eq!(before.events_since(&repo, &after), 1);
162 }
163
164 #[test]
165 fn an_unchanged_repository_is_not_news() {
166 let (_dir, repo, first) = scratch_repo();
167 repo.reference("refs/collab/issues/abc", first, true, "seed")
168 .unwrap();
169 let before = Tips::read(&repo);
170 let after = Tips::read(&repo);
171 assert_eq!(before.events_since(&repo, &after), 0);
172 }
173 }
src/tui/widgets.rs
Old New
@@ -205,25 +205,112 @@ pub(crate) fn format_event_detail(
205 detail 205 detail
206 } 206 }
207 207
208 /// What a write key acts on when the cursor is on a given row.
209 #[derive(Debug, Clone, PartialEq, Default)]
210 pub(crate) enum RowTarget {
211 /// Prose, a heading, a revision — nothing a comment anchors to.
212 #[default]
213 None,
214 /// A new-side line of the diff, carrying the anchor `patch comment --at`
215 /// accepts for it.
216 Diff { file: String, line: u32 },
217 /// An inline comment, named by the id `patch show` prints in brackets.
218 Comment { oid: String, resolved: bool },
219 }
220
221 /// A rendered line of the patch detail pane, and what it stands for.
222 ///
223 /// 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.
225 #[derive(Debug, Clone)]
226 pub(crate) struct DetailRow {
227 pub(crate) line: Line<'static>,
228 pub(crate) target: RowTarget,
229 }
230
231 impl DetailRow {
232 fn plain(line: Line<'static>) -> Self {
233 DetailRow {
234 line,
235 target: RowTarget::None,
236 }
237 }
238 }
239
240 /// Accumulator for the detail pane.
241 ///
242 /// 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
244 /// new-side line of the diff — say so with `push_target`.
245 struct Rows(Vec<DetailRow>);
246
247 impl Rows {
248 fn new() -> Self {
249 Rows(Vec::new())
250 }
251
252 fn push(&mut self, line: Line<'static>) {
253 self.0.push(DetailRow::plain(line));
254 }
255
256 fn push_target(&mut self, line: Line<'static>, target: RowTarget) {
257 self.0.push(DetailRow { line, target });
258 }
259 }
260
208 pub(crate) fn ui(frame: &mut Frame, app: &mut App, repo: Option<&Repository>) { 261 pub(crate) fn ui(frame: &mut Frame, app: &mut App, repo: Option<&Repository>) {
209 let chunks = Layout::default() 262 let chunks = Layout::default()
210 .direction(Direction::Vertical) 263 .direction(Direction::Vertical)
211 .constraints([Constraint::Min(1), Constraint::Length(1)]) 264 .constraints([
265 Constraint::Length(1),
266 Constraint::Min(1),
267 Constraint::Length(1),
268 ])
212 .split(frame.area()); 269 .split(frame.area());
213 270
214 let main_area = chunks[0]; 271 let header_area = chunks[0];
215 let footer_area = chunks[1]; 272 let main_area = chunks[1];
273 let footer_area = chunks[2];
216 274
217 let panes = Layout::default() 275 let panes = Layout::default()
218 .direction(Direction::Horizontal) 276 .direction(Direction::Horizontal)
219 .constraints([Constraint::Percentage(35), Constraint::Percentage(65)]) 277 .constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
220 .split(main_area); 278 .split(main_area);
221 279
280 render_header(frame, app, header_area);
222 render_list(frame, app, panes[0]); 281 render_list(frame, app, panes[0]);
223 render_detail(frame, app, panes[1], repo); 282 render_detail(frame, app, panes[1], repo);
224 render_footer(frame, app, footer_area); 283 render_footer(frame, app, footer_area);
225 } 284 }
226 285
286 /// When the screen was loaded, and whether anything has happened since.
287 ///
288 /// The timestamp is unconditional. That is the point: a dashboard left open
289 /// beside working agents used to show last hour's repository and look
290 /// perfectly healthy, and a screen that admits it is old is merely old.
291 fn render_header(frame: &mut Frame, app: &App, area: Rect) {
292 let mut spans = vec![
293 Span::styled(" DASHBOARD", Style::default().add_modifier(Modifier::BOLD)),
294 Span::styled(
295 format!(" as of {}", app.loaded_at.format("%H:%M")),
296 Style::default().fg(Color::DarkGray),
297 ),
298 ];
299 if app.new_events > 0 {
300 spans.push(Span::styled(
301 format!(
302 " ● {} new event{} · r to reload",
303 app.new_events,
304 if app.new_events == 1 { "" } else { "s" }
305 ),
306 Style::default()
307 .fg(Color::Yellow)
308 .add_modifier(Modifier::BOLD),
309 ));
310 }
311 frame.render_widget(Paragraph::new(Line::from(spans)), area);
312 }
313
227 fn render_list(frame: &mut Frame, app: &mut App, area: Rect) { 314 fn render_list(frame: &mut Frame, app: &mut App, area: Rect) {
228 let border_style = if app.pane == Pane::ItemList { 315 let border_style = if app.pane == Pane::ItemList {
229 Style::default().fg(Color::Yellow) 316 Style::default().fg(Color::Yellow)
@@ -321,14 +408,40 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect, repo: Option<&Rep
321 408
322 // Handle patch detail mode 409 // Handle patch detail mode
323 if app.mode == ViewMode::PatchDetail { 410 if app.mode == ViewMode::PatchDetail {
324 let content = build_patch_detail_text(app); 411 // The rows are built by the key handler, not here, so that `c` and `x`
412 // act on the same row the reader is looking at. A draw that rebuilt
413 // them could disagree with the last keypress.
414 //
415 // Recording the height is the one thing only the renderer knows, and
416 // the scroll needs it to follow the cursor. It moves no ref and reads
417 // no object: a draw stays a draw.
418 app.patch_viewport = area.height.saturating_sub(2);
419 let cursor = app.patch_cursor;
420 let lines: Vec<Line> = app
421 .patch_rows
422 .iter()
423 .enumerate()
424 .map(|(i, row)| {
425 if i == cursor {
426 row.line.clone().style(
427 Style::default()
428 .bg(Color::DarkGray)
429 .add_modifier(Modifier::BOLD),
430 )
431 } else {
432 row.line.clone()
433 }
434 })
435 .collect();
325 let block = Block::default() 436 let block = Block::default()
326 .borders(Borders::ALL) 437 .borders(Borders::ALL)
327 .title("Patch Detail") 438 .title("Patch Detail")
328 .border_style(border_style); 439 .border_style(border_style);
329 let para = Paragraph::new(content) 440 // No wrapping: a wrapped line occupies more rows than it has, which
441 // would put the cursor's highlight on a different line than the one
442 // `c` is about to comment on.
443 let para = Paragraph::new(Text::from(lines))
330 .block(block) 444 .block(block)
331 .wrap(Wrap { trim: false })
332 .scroll((app.patch_scroll, 0)); 445 .scroll((app.patch_scroll, 0));
333 frame.render_widget(para, area); 446 frame.render_widget(para, area);
334 return; 447 return;
@@ -681,10 +794,12 @@ fn build_patch_summary(
681 Text::from(lines) 794 Text::from(lines)
682 } 795 }
683 796
684 fn build_patch_detail_text(app: &App) -> Text<'static> { 797 /// Build every row of the patch detail pane, with what a write key would act
798 /// on for each.
799 pub(crate) fn build_patch_detail_rows(app: &App) -> Vec<DetailRow> {
685 let patch = match &app.current_patch { 800 let patch = match &app.current_patch {
686 Some(p) => p, 801 Some(p) => p,
687 None => return Text::raw("No patch loaded."), 802 None => return vec![DetailRow::plain(Line::raw("No patch loaded."))],
688 }; 803 };
689 804
690 let status_str = patch.status.as_str(); 805 let status_str = patch.status.as_str();
@@ -694,49 +809,48 @@ fn build_patch_detail_text(app: &App) -> Text<'static> {
694 PatchStatus::Merged => Color::Cyan, 809 PatchStatus::Merged => Color::Cyan,
695 }; 810 };
696 811
697 let mut lines: Vec<Line> = vec![ 812 let mut rows = Rows::new();
698 Line::from(vec![ 813 rows.push(Line::from(vec![
699 Span::styled("Patch ", Style::default().add_modifier(Modifier::BOLD)), 814 Span::styled("Patch ", Style::default().add_modifier(Modifier::BOLD)),
700 Span::styled( 815 Span::styled(
701 app.patch_abbrev.of(&patch.id).to_string(), 816 app.patch_abbrev.of(&patch.id).to_string(),
702 Style::default() 817 Style::default()
703 .fg(Color::Yellow) 818 .fg(Color::Yellow)
704 .add_modifier(Modifier::BOLD), 819 .add_modifier(Modifier::BOLD),
705 ), 820 ),
706 Span::raw(" "), 821 Span::raw(" "),
707 Span::styled(status_str, Style::default().fg(status_color)), 822 Span::styled(status_str, Style::default().fg(status_color)),
708 ]), 823 ]));
709 Line::from(vec![ 824 rows.push(Line::from(vec![
710 Span::styled("Title: ", Style::default().fg(Color::DarkGray)), 825 Span::styled("Title: ", Style::default().fg(Color::DarkGray)),
711 Span::raw(patch.title.clone()), 826 Span::raw(patch.title.clone()),
712 ]), 827 ]));
713 Line::from(vec![ 828 rows.push(Line::from(vec![
714 Span::styled("Author: ", Style::default().fg(Color::DarkGray)), 829 Span::styled("Author: ", Style::default().fg(Color::DarkGray)),
715 Span::raw(format!("{} <{}>", patch.author.name, patch.author.email)), 830 Span::raw(format!("{} <{}>", patch.author.name, patch.author.email)),
716 ]), 831 ]));
717 Line::from(vec![ 832 rows.push(Line::from(vec![
718 Span::styled("Branch: ", Style::default().fg(Color::DarkGray)), 833 Span::styled("Branch: ", Style::default().fg(Color::DarkGray)),
719 Span::raw(patch.branch.clone()), 834 Span::raw(patch.branch.clone()),
720 ]), 835 ]));
721 Line::from(vec![ 836 rows.push(Line::from(vec![
722 Span::styled("Base: ", Style::default().fg(Color::DarkGray)), 837 Span::styled("Base: ", Style::default().fg(Color::DarkGray)),
723 Span::raw(patch.base_ref.clone()), 838 Span::raw(patch.base_ref.clone()),
724 ]), 839 ]));
725 Line::from(vec![ 840 rows.push(Line::from(vec![
726 Span::styled("Revisions:", Style::default().fg(Color::DarkGray)), 841 Span::styled("Revisions:", Style::default().fg(Color::DarkGray)),
727 Span::raw(format!(" {}", patch.revisions.len())), 842 Span::raw(format!(" {}", patch.revisions.len())),
728 ]), 843 ]));
729 ];
730 844
731 if !patch.labels.is_empty() { 845 if !patch.labels.is_empty() {
732 lines.push(Line::from(vec![ 846 rows.push(Line::from(vec![
733 Span::styled("Labels: ", Style::default().fg(Color::DarkGray)), 847 Span::styled("Labels: ", Style::default().fg(Color::DarkGray)),
734 Span::raw(patch.labels.join(", ")), 848 Span::raw(patch.labels.join(", ")),
735 ])); 849 ]));
736 } 850 }
737 851
738 if let Some(ref fixes) = patch.fixes { 852 if let Some(ref fixes) = patch.fixes {
739 lines.push(Line::from(vec![ 853 rows.push(Line::from(vec![
740 Span::styled("Fixes: ", Style::default().fg(Color::DarkGray)), 854 Span::styled("Fixes: ", Style::default().fg(Color::DarkGray)),
741 Span::raw(app.issue_abbrev.of(fixes).to_string()), 855 Span::raw(app.issue_abbrev.of(fixes).to_string()),
742 ])); 856 ]));
@@ -745,8 +859,8 @@ fn build_patch_detail_text(app: &App) -> Text<'static> {
745 // Staleness warning (stored in status_msg or computed text) 859 // Staleness warning (stored in status_msg or computed text)
746 if let Some(ref warning) = app.status_msg { 860 if let Some(ref warning) = app.status_msg {
747 if warning.contains("behind") { 861 if warning.contains("behind") {
748 lines.push(Line::raw("")); 862 rows.push(Line::raw(""));
749 lines.push(Line::styled( 863 rows.push(Line::styled(
750 warning.clone(), 864 warning.clone(),
751 Style::default() 865 Style::default()
752 .fg(Color::Yellow) 866 .fg(Color::Yellow)
@@ -757,8 +871,8 @@ fn build_patch_detail_text(app: &App) -> Text<'static> {
757 871
758 // Revision list 872 // Revision list
759 if !patch.revisions.is_empty() { 873 if !patch.revisions.is_empty() {
760 lines.push(Line::raw("")); 874 rows.push(Line::raw(""));
761 lines.push(Line::styled( 875 rows.push(Line::styled(
762 "--- Revisions ---", 876 "--- Revisions ---",
763 Style::default() 877 Style::default()
764 .fg(Color::Magenta) 878 .fg(Color::Magenta)
@@ -774,7 +888,7 @@ fn build_patch_detail_text(app: &App) -> Text<'static> {
774 " " 888 " "
775 }; 889 };
776 let label = if i == 0 { " (initial)" } else { "" }; 890 let label = if i == 0 { " (initial)" } else { "" };
777 lines.push(Line::from(vec![Span::raw(format!( 891 rows.push(Line::from(vec![Span::raw(format!(
778 "{}r{} {} {}{}", 892 "{}r{} {} {}{}",
779 marker, rev.number, short, rev.timestamp, label 893 marker, rev.number, short, rev.timestamp, label
780 ))])); 894 ))]));
@@ -783,8 +897,8 @@ fn build_patch_detail_text(app: &App) -> Text<'static> {
783 897
784 // Reviews 898 // Reviews
785 if !patch.reviews.is_empty() { 899 if !patch.reviews.is_empty() {
786 lines.push(Line::raw("")); 900 rows.push(Line::raw(""));
787 lines.push(Line::styled( 901 rows.push(Line::styled(
788 "--- Reviews ---", 902 "--- Reviews ---",
789 Style::default() 903 Style::default()
790 .fg(Color::Blue) 904 .fg(Color::Blue)
@@ -802,7 +916,7 @@ fn build_patch_detail_text(app: &App) -> Text<'static> {
802 .revision 916 .revision
803 .map(|r| format!(" (r{})", r)) 917 .map(|r| format!(" (r{})", r))
804 .unwrap_or_default(); 918 .unwrap_or_default();
805 lines.push(Line::from(vec![ 919 rows.push(Line::from(vec![
806 Span::styled( 920 Span::styled(
807 review.author.name.clone(), 921 review.author.name.clone(),
808 Style::default().add_modifier(Modifier::BOLD), 922 Style::default().add_modifier(Modifier::BOLD),
@@ -814,7 +928,7 @@ fn build_patch_detail_text(app: &App) -> Text<'static> {
814 ])); 928 ]));
815 if !review.body.is_empty() { 929 if !review.body.is_empty() {
816 for l in review.body.lines() { 930 for l in review.body.lines() {
817 lines.push(Line::raw(format!(" {}", l))); 931 rows.push(Line::raw(format!(" {}", l)));
818 } 932 }
819 } 933 }
820 } 934 }
@@ -822,8 +936,8 @@ fn build_patch_detail_text(app: &App) -> Text<'static> {
822 936
823 // Inline comments 937 // Inline comments
824 if !patch.inline_comments.is_empty() { 938 if !patch.inline_comments.is_empty() {
825 lines.push(Line::raw("")); 939 rows.push(Line::raw(""));
826 lines.push(Line::styled( 940 rows.push(Line::styled(
827 "--- Inline Comments ---", 941 "--- Inline Comments ---",
828 Style::default() 942 Style::default()
829 .fg(Color::Blue) 943 .fg(Color::Blue)
@@ -834,34 +948,53 @@ fn build_patch_detail_text(app: &App) -> Text<'static> {
834 .revision 948 .revision
835 .map(|r| format!(" (r{})", r)) 949 .map(|r| format!(" (r{})", r))
836 .unwrap_or_default(); 950 .unwrap_or_default();
837 lines.push(Line::from(vec![ 951 // The id is the one `patch show` prints in brackets and
838 Span::styled( 952 // `resolve_comment` takes as a prefix, at the same eight
839 ic.author.name.clone(), 953 // characters. The two surfaces have to agree, because a reviewer
840 Style::default().add_modifier(Modifier::BOLD), 954 // reads it off one and types it into the other.
841 ), 955 let resolved = ic.resolved.is_some();
842 Span::raw(format!(" {}:{}", ic.file, ic.line)), 956 rows.push_target(
843 Span::raw(rev_label), 957 Line::from(vec![
844 edited_span(ic.edited), 958 Span::styled(
845 non_blocking_span(ic.non_blocking), 959 ic.author.name.clone(),
846 ])); 960 Style::default().add_modifier(Modifier::BOLD),
961 ),
962 Span::raw(format!(" {}:{}", ic.file, ic.line)),
963 Span::raw(rev_label),
964 Span::styled(
965 format!(" [{:.8}]", ic.commit_id),
966 Style::default().fg(Color::DarkGray),
967 ),
968 Span::styled(
969 if resolved { " resolved" } else { "" },
970 Style::default().fg(Color::Green),
971 ),
972 edited_span(ic.edited),
973 non_blocking_span(ic.non_blocking),
974 ]),
975 RowTarget::Comment {
976 oid: ic.commit_id.to_string(),
977 resolved,
978 },
979 );
847 for l in body_lines(&ic.body, ic.deleted) { 980 for l in body_lines(&ic.body, ic.deleted) {
848 lines.push(Line::raw(format!(" {}", l))); 981 rows.push(Line::raw(format!(" {}", l)));
849 } 982 }
850 } 983 }
851 } 984 }
852 985
853 // Thread comments 986 // Thread comments
854 if !patch.comments.is_empty() { 987 if !patch.comments.is_empty() {
855 lines.push(Line::raw("")); 988 rows.push(Line::raw(""));
856 lines.push(Line::styled( 989 rows.push(Line::styled(
857 "--- Comments ---", 990 "--- Comments ---",
858 Style::default() 991 Style::default()
859 .fg(Color::Blue) 992 .fg(Color::Blue)
860 .add_modifier(Modifier::BOLD), 993 .add_modifier(Modifier::BOLD),
861 )); 994 ));
862 for c in &patch.comments { 995 for c in &patch.comments {
863 lines.push(Line::raw("")); 996 rows.push(Line::raw(""));
864 lines.push(Line::from(vec![ 997 rows.push(Line::from(vec![
865 Span::styled( 998 Span::styled(
866 c.author.name.clone(), 999 c.author.name.clone(),
867 Style::default().add_modifier(Modifier::BOLD), 1000 Style::default().add_modifier(Modifier::BOLD),
@@ -873,13 +1006,23 @@ fn build_patch_detail_text(app: &App) -> Text<'static> {
873 edited_span(c.edited), 1006 edited_span(c.edited),
874 ])); 1007 ]));
875 for l in body_lines(&c.body, c.deleted) { 1008 for l in body_lines(&c.body, c.deleted) {
876 lines.push(Line::raw(format!(" {}", l))); 1009 rows.push(Line::raw(format!(" {}", l)));
877 } 1010 }
878 } 1011 }
879 } 1012 }
880 1013
881 // Diff header 1014 // Diff header
882 lines.push(Line::raw("")); 1015 rows.push(Line::raw(""));
1016 if let Some(ref comment) = app.patch_answers {
1017 rows.push(Line::styled(
1018 format!("--- What answered [{:.8}] ---", comment),
1019 Style::default()
1020 .fg(Color::Green)
1021 .add_modifier(Modifier::BOLD),
1022 ));
1023 push_diff_rows(&mut rows, app);
1024 return rows.0;
1025 }
883 let diff_mode = if app.patch_interdiff_mode { 1026 let diff_mode = if app.patch_interdiff_mode {
884 let rev_idx = app.patch_revision_idx; 1027 let rev_idx = app.patch_revision_idx;
885 if rev_idx > 0 { 1028 if rev_idx > 0 {
@@ -901,36 +1044,51 @@ fn build_patch_detail_text(app: &App) -> Text<'static> {
901 .unwrap_or(1); 1044 .unwrap_or(1);
902 format!("--- Diff r{} vs base ---", rev_num) 1045 format!("--- Diff r{} vs base ---", rev_num)
903 }; 1046 };
904 lines.push(Line::styled( 1047 rows.push(Line::styled(
905 diff_mode, 1048 diff_mode,
906 Style::default() 1049 Style::default()
907 .fg(Color::Green) 1050 .fg(Color::Green)
908 .add_modifier(Modifier::BOLD), 1051 .add_modifier(Modifier::BOLD),
909 )); 1052 ));
910 1053
911 // Diff content 1054 push_diff_rows(&mut rows, app);
1055 rows.0
1056 }
1057
1058 /// Render the diff, carrying each new-side row's anchor onto the row the
1059 /// cursor will land on.
1060 fn push_diff_rows(rows: &mut Rows, app: &App) {
912 if app.patch_diff.is_empty() { 1061 if app.patch_diff.is_empty() {
913 lines.push(Line::raw("(no diff available)")); 1062 rows.push(Line::raw("(no diff available)"));
914 } else { 1063 return;
915 for l in app.patch_diff.lines() { 1064 }
916 let style = if l.starts_with('+') { 1065 for row in &app.patch_diff {
917 Style::default().fg(Color::Green) 1066 let text = row.text.trim_end_matches(['\n', '\r']).to_string();
918 } else if l.starts_with('-') { 1067 let style = if text.starts_with("diff ") {
919 Style::default().fg(Color::Red) 1068 Style::default()
920 } else if l.starts_with("@@") { 1069 .fg(Color::Yellow)
921 Style::default().fg(Color::Cyan) 1070 .add_modifier(Modifier::BOLD)
922 } else if l.starts_with("diff ") { 1071 } else if text.starts_with("@@") {
923 Style::default() 1072 Style::default().fg(Color::Cyan)
924 .fg(Color::Yellow) 1073 } else if text.starts_with('+') {
925 .add_modifier(Modifier::BOLD) 1074 Style::default().fg(Color::Green)
926 } else { 1075 } else if text.starts_with('-') {
927 Style::default() 1076 Style::default().fg(Color::Red)
928 }; 1077 } else {
929 lines.push(Line::styled(l.to_string(), style)); 1078 Style::default()
1079 };
1080 let line = Line::styled(text, style);
1081 match &row.anchor {
1082 Some((file, number)) => rows.push_target(
1083 line,
1084 RowTarget::Diff {
1085 file: file.clone(),
1086 line: *number,
1087 },
1088 ),
1089 None => rows.push(line),
930 } 1090 }
931 } 1091 }
932
933 Text::from(lines)
934 } 1092 }
935 1093
936 fn render_footer(frame: &mut Frame, app: &App, area: Rect) { 1094 fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
@@ -955,22 +1113,17 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
955 } else { 1113 } else {
956 &app.input_buf 1114 &app.input_buf
957 }; 1115 };
958 let text = format!(" New issue - Title: {}_", display); 1116 let text = format!(" New issue - Title: {}_ (Enter: write the body)", display);
959 let style = Style::default().bg(Color::Green).fg(Color::Black); 1117 let style = Style::default().bg(Color::Green).fg(Color::Black);
960 let para = Paragraph::new(text).style(style); 1118 let para = Paragraph::new(text).style(style);
961 frame.render_widget(para, area); 1119 frame.render_widget(para, area);
962 return; 1120 return;
963 } 1121 }
964 InputMode::CreateBody => { 1122 InputMode::ReviewVerdict => {
965 let max_len = (area.width as usize).saturating_sub(21); 1123 let para = Paragraph::new(
966 let display = if app.input_buf.len() > max_len { 1124 " Review verdict: a:approve r:request-changes c:comment Esc:cancel",
967 &app.input_buf[app.input_buf.len() - max_len..] 1125 )
968 } else { 1126 .style(Style::default().bg(Color::Blue).fg(Color::White));
969 &app.input_buf
970 };
971 let text = format!(" New issue - Body: {}_ (Esc: skip)", display);
972 let style = Style::default().bg(Color::Green).fg(Color::Black);
973 let para = Paragraph::new(text).style(style);
974 frame.render_widget(para, area); 1127 frame.render_widget(para, area);
975 return; 1128 return;
976 } 1129 }
@@ -988,8 +1141,13 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
988 let text = match app.mode { 1141 let text = match app.mode {
989 ViewMode::CommitList => " j/k:navigate Enter:detail Esc:back q:quit".to_string(), 1142 ViewMode::CommitList => " j/k:navigate Enter:detail Esc:back q:quit".to_string(),
990 ViewMode::CommitDetail => " j/k:scroll Esc:back q:quit".to_string(), 1143 ViewMode::CommitDetail => " j/k:scroll Esc:back q:quit".to_string(),
1144 // Ordered by what a reviewer reaches for, because this is one line and
1145 // a narrow terminal clips the tail: the review loop first, then the
1146 // way out, then the things you can also get to by other means.
991 ViewMode::PatchDetail => { 1147 ViewMode::PatchDetail => {
992 " j/k:scroll Esc:back [/]:revision d:interdiff q:quit".to_string() 1148 " j/k:line c:comment R:review x:resolve a:answers Esc:back q:quit \
1149 [/]:revision d:interdiff o:checkout"
1150 .to_string()
993 } 1151 }
994 ViewMode::Details => { 1152 ViewMode::Details => {
995 let list_hint = match app.list_mode { 1153 let list_hint = match app.list_mode {
tests/common/mod.rs
Old New
@@ -19,6 +19,52 @@ use git_collab::dag;
19 use git_collab::event::{Action, Author, Event, ReviewVerdict}; 19 use git_collab::event::{Action, Author, Event, ReviewVerdict};
20 use git_collab::signing; 20 use git_collab::signing;
21 21
22 /// Strip terminal control sequences from captured pty output.
23 ///
24 /// A TUI redraw is escape sequences interleaved with the cells that changed,
25 /// so `contains("as of")` on the raw bytes is a coin toss: the words are there
26 /// but a cursor move may sit between them. Removing the control sequences
27 /// leaves the characters that were actually put on the screen, in order.
28 pub fn screen_text(bytes: &[u8]) -> String {
29 let text = String::from_utf8_lossy(bytes);
30 let mut out = String::with_capacity(text.len());
31 let mut chars = text.chars().peekable();
32 while let Some(c) = chars.next() {
33 if c != '\u{1b}' {
34 out.push(c);
35 continue;
36 }
37 match chars.next() {
38 // CSI: parameters and intermediates, then a final byte in @..~.
39 Some('[') => {
40 for c in chars.by_ref() {
41 if ('\u{40}'..='\u{7e}').contains(&c) {
42 break;
43 }
44 }
45 }
46 // OSC: runs to BEL or ST.
47 Some(']') => {
48 while let Some(c) = chars.next() {
49 if c == '\u{7}' {
50 break;
51 }
52 if c == '\u{1b}' && chars.peek() == Some(&'\\') {
53 chars.next();
54 break;
55 }
56 }
57 }
58 // Two-character sequences (ESC =, ESC >, ESC (B, ...).
59 Some('(') | Some(')') => {
60 chars.next();
61 }
62 _ => {}
63 }
64 }
65 out
66 }
67
22 // =========================================================================== 68 // ===========================================================================
23 // Library-level helpers (for collab_test / sync_test) 69 // Library-level helpers (for collab_test / sync_test)
24 // =========================================================================== 70 // ===========================================================================
@@ -623,6 +669,73 @@ impl TestRepo {
623 } 669 }
624 } 670 }
625 671
672 /// Run `git-collab dashboard` in a pseudo-terminal and drive its stdin from
673 /// a callback, so a test can interleave keystrokes with pauses and with
674 /// writes made by another process.
675 ///
676 /// The smoke helper above writes every key at once and waits for the exit,
677 /// which cannot express "open the dashboard, let something else move a ref,
678 /// then look at the screen". `extra_env` is applied last, so a test can set
679 /// `EDITOR` to a script and exercise the suspend path with no human.
680 pub fn run_dashboard_driven<F>(
681 &self,
682 cols: u16,
683 rows: u16,
684 extra_env: &[(&str, &str)],
685 drive: F,
686 ) -> Output
687 where
688 F: FnOnce(&mut std::process::ChildStdin),
689 {
690 let mut command = Command::new("script");
691 self.apply_env(&mut command);
692 for (k, v) in extra_env {
693 command.env(k, v);
694 }
695 let mut child = command
696 .args([
697 "-qec",
698 &format!(
699 "stty cols {} rows {}; {} dashboard",
700 cols,
701 rows,
702 env!("CARGO_BIN_EXE_git-collab")
703 ),
704 "/dev/null",
705 ])
706 .current_dir(self.dir.path())
707 .stdin(Stdio::piped())
708 .stdout(Stdio::piped())
709 .stderr(Stdio::piped())
710 .spawn()
711 .expect("failed to launch dashboard in pty");
712
713 {
714 let mut stdin = child.stdin.take().expect("dashboard stdin piped");
715 // Let the dashboard reach its first draw before anything is typed:
716 // keys delivered before the event loop starts are read by the
717 // terminal, not by the program under test.
718 thread::sleep(Duration::from_millis(600));
719 drive(&mut stdin);
720 }
721
722 let deadline = Instant::now() + Duration::from_secs(30);
723 loop {
724 if child.try_wait().expect("failed to poll dashboard process").is_some() {
725 return child
726 .wait_with_output()
727 .expect("failed to collect dashboard output");
728 }
729 if Instant::now() >= deadline {
730 let _ = child.kill();
731 return child
732 .wait_with_output()
733 .expect("failed to collect timed out dashboard output");
734 }
735 thread::sleep(Duration::from_millis(20));
736 }
737 }
738
626 /// Open an issue and return the 8-char short ID. 739 /// Open an issue and return the 8-char short ID.
627 pub fn issue_open(&self, title: &str) -> String { 740 pub fn issue_open(&self, title: &str) -> String {
628 let out = self.run_ok(&["issue", "open", "-t", title]); 741 let out = self.run_ok(&["issue", "open", "-t", title]);
tests/tui_review_test.rs
Old New
@@ -0,0 +1,460 @@
1 //! The dashboard as a review surface, driven through a real pty.
2 //!
3 //! Two properties are only true end to end and so are only testable here: that
4 //! suspending to `$EDITOR` leaves the alternate screen, runs a process, comes
5 //! back and records what the process wrote; and that a ref another process
6 //! moved is noticed while the dashboard is sitting there. Both are checked by
7 //! looking at the collab DAG afterwards rather than at the screen, because the
8 //! screen can lie and the DAG cannot.
9
10 mod common;
11
12 use common::{screen_text, TestRepo};
13 use std::io::Write;
14 use std::thread;
15 use std::time::Duration;
16
17 /// A fake `$EDITOR` that replaces the buffer with `body` and exits cleanly.
18 fn editor_writing(repo: &TestRepo, name: &str, body: &str) -> String {
19 repo.write_script(
20 name,
21 &format!("#!/bin/sh\ncat > \"$1\" <<'GITCOLLABEOF'\n{}\nGITCOLLABEOF\n", body),
22 )
23 }
24
25 /// Seed a repo with one patch that has a real revision to comment on.
26 fn repo_with_patch() -> (TestRepo, String) {
27 let repo = TestRepo::new("Reviewer", "reviewer@example.com");
28 repo.commit_file("src/lib.rs", "one\ntwo\nthree\nfour\n", "seed");
29 repo.git(&["checkout", "-b", "feature/x"]);
30 repo.commit_file("src/lib.rs", "one\ntwo changed\nthree\nfour\nfive\n", "work");
31 let out = repo.run_ok(&["patch", "create", "-t", "A patch to review", "-B", "feature/x"]);
32 let id = out
33 .trim()
34 .strip_prefix("Created patch ")
35 .expect("patch create output")
36 .to_string();
37 repo.git(&["checkout", "main"]);
38 (repo, id)
39 }
40
41 // ── Staleness (59ab40e8) ────────────────────────────────────────────────────
42
43 /// The property that turns the old behaviour from a limitation into a bug:
44 /// a screen that is out of date has to say when it was loaded.
45 #[test]
46 fn dashboard_always_shows_when_it_was_loaded() {
47 let repo = TestRepo::new("Reader", "reader@example.com");
48 repo.issue_open("something to look at");
49
50 let out = repo.run_dashboard_smoke_sized("q", 100, 24);
51 let text = screen_text(&out.stdout);
52 assert!(
53 text.contains("as of "),
54 "no 'as of' marker on the dashboard:\n{}",
55 text
56 );
57 }
58
59 /// A ref another process moves while the dashboard sits there must be
60 /// announced. The dashboard must not reload under the reader — the banner is
61 /// the whole feature — so the assertion is on the banner, not on the list.
62 #[test]
63 fn an_external_write_raises_the_banner() {
64 let repo = TestRepo::new("Reader", "reader@example.com");
65 repo.issue_open("the one already on screen");
66
67 let out = repo.run_dashboard_driven(100, 24, &[], |stdin| {
68 // The dashboard is up and has folded what exists. Now be the agent
69 // that lands something while the reviewer is reading. `repo.run_ok`
70 // targets the temp repo and its isolated home — running the binary by
71 // hand here would write to whatever directory the test happens to be
72 // started from.
73 thread::sleep(Duration::from_millis(300));
74 repo.issue_open("landed while you were reading");
75 thread::sleep(Duration::from_millis(4000));
76 let _ = stdin.write_all(b"q");
77 });
78 let text = screen_text(&out.stdout);
79 assert!(
80 text.contains("new event"),
81 "no staleness banner after an external write:\n{}",
82 text
83 );
84 }
85
86 /// `n` writes its body through `$EDITOR` too — there is no second, worse text
87 /// input left in the program.
88 #[test]
89 fn n_composes_the_issue_body_in_the_editor() {
90 let repo = TestRepo::new("Reader", "reader@example.com");
91 let editor = editor_writing(&repo, "issue-body.sh", "the long markdown body");
92
93 repo.run_dashboard_driven(100, 24, &[("EDITOR", &editor)], |stdin| {
94 let _ = stdin.write_all(b"nA new issue\r");
95 thread::sleep(Duration::from_millis(2000));
96 let _ = stdin.write_all(b"q");
97 });
98
99 let listed = repo.run_ok(&["issue", "list"]);
100 let id = listed
101 .split_whitespace()
102 .next()
103 .expect("one issue was opened");
104 let shown = repo.run_ok(&["issue", "show", id]);
105 assert!(
106 shown.contains("A new issue") && shown.contains("the long markdown body"),
107 "the editor's body did not reach the issue:\n{}",
108 shown
109 );
110 }
111
112 /// The reader's own comment must not be announced back to them as news. The
113 /// write reloads, and that reload has to re-baseline the tip snapshot.
114 #[test]
115 fn the_dashboards_own_write_does_not_raise_the_banner() {
116 let repo = TestRepo::new("Reader", "reader@example.com");
117 repo.issue_open("existing");
118 let editor = editor_writing(&repo, "issue-editor.sh", "a body for it");
119
120 let out = repo.run_dashboard_driven(100, 24, &[("EDITOR", &editor)], |stdin| {
121 // `n`, a title, Enter — which opens the editor for the body.
122 let _ = stdin.write_all(b"nmine\r");
123 thread::sleep(Duration::from_millis(4000));
124 let _ = stdin.write_all(b"q");
125 });
126 let text = screen_text(&out.stdout);
127 // Vacuously true if nothing was written, so check the write landed first.
128 let issues = repo.run_ok(&["issue", "list"]);
129 assert!(
130 issues.contains("mine"),
131 "the dashboard never made the write this is about:\n{}",
132 issues
133 );
134 assert!(
135 !text.contains("new event"),
136 "the dashboard announced its own write as external:\n{}",
137 text
138 );
139 }
140
141 // ── Writing from the review surface (094b055b) ──────────────────────────────
142
143 /// The whole point of the issue: read a patch and say something about it
144 /// without quitting. The body arrives through `$EDITOR`.
145 #[test]
146 fn r_records_a_review_composed_in_the_editor() {
147 let (repo, id) = repo_with_patch();
148 let editor = editor_writing(&repo, "review-editor.sh", "this reads well");
149
150 repo.run_dashboard_driven(100, 40, &[("EDITOR", &editor)], |stdin| {
151 // Switch to the patch list, open the patch, review, approve.
152 let _ = stdin.write_all(b"P");
153 thread::sleep(Duration::from_millis(300));
154 let _ = stdin.write_all(b"\r");
155 thread::sleep(Duration::from_millis(500));
156 let _ = stdin.write_all(b"R");
157 thread::sleep(Duration::from_millis(300));
158 let _ = stdin.write_all(b"a");
159 thread::sleep(Duration::from_millis(2000));
160 let _ = stdin.write_all(b"q");
161 });
162
163 let shown = repo.run_ok(&["patch", "show", &id]);
164 assert!(
165 shown.contains("this reads well") && shown.contains("approve"),
166 "no review recorded:\n{}",
167 shown
168 );
169 }
170
171 /// `c` on a line of the diff anchors the comment there, through the same
172 /// `--at file:line` path the CLI uses — so the anchor is checked against the
173 /// revision the comment names, and lands as an inline comment rather than a
174 /// thread one.
175 #[test]
176 fn c_on_a_diff_line_records_an_inline_comment() {
177 let (repo, id) = repo_with_patch();
178 let editor = editor_writing(&repo, "comment-editor.sh", "why the rename?");
179
180 repo.run_dashboard_driven(100, 40, &[("EDITOR", &editor)], |stdin| {
181 let _ = stdin.write_all(b"P");
182 thread::sleep(Duration::from_millis(300));
183 let _ = stdin.write_all(b"\r");
184 thread::sleep(Duration::from_millis(500));
185 // Walk down to a line of the diff body. The detail pane opens with the
186 // cursor at the top, and the header, revision list and diff header sit
187 // above the first anchorable line.
188 let _ = stdin.write_all(b"jjjjjjjjjjjjjjjj");
189 thread::sleep(Duration::from_millis(400));
190 let _ = stdin.write_all(b"c");
191 thread::sleep(Duration::from_millis(2000));
192 let _ = stdin.write_all(b"q");
193 });
194
195 let shown = repo.run_ok(&["patch", "show", &id]);
196 assert!(
197 shown.contains("why the rename?"),
198 "no comment recorded:\n{}",
199 shown
200 );
201 assert!(
202 shown.contains("src/lib.rs:"),
203 "the comment did not land on a line of the file:\n{}",
204 shown
205 );
206 }
207
208 /// A body that is empty once the `#` context is stripped aborts the write, the
209 /// way `git commit` does, and nothing is appended.
210 #[test]
211 fn a_comment_only_body_aborts_and_records_nothing() {
212 let (repo, id) = repo_with_patch();
213 // An editor that leaves the seeded `#` lines exactly as they were.
214 let editor = repo.write_script("noop-editor.sh", "#!/bin/sh\nexit 0\n");
215
216 let out = repo.run_dashboard_driven(100, 40, &[("EDITOR", &editor)], |stdin| {
217 let _ = stdin.write_all(b"P");
218 thread::sleep(Duration::from_millis(300));
219 let _ = stdin.write_all(b"\r");
220 thread::sleep(Duration::from_millis(500));
221 let _ = stdin.write_all(b"c");
222 thread::sleep(Duration::from_millis(2000));
223 let _ = stdin.write_all(b"q");
224 });
225
226 let shown = repo.run_ok(&["patch", "show", &id]);
227 assert!(
228 !shown.contains("--- Comments ---") && !shown.contains("--- Inline Comments ---"),
229 "an aborted comment was recorded anyway:\n{}",
230 shown
231 );
232 // ...and the reader is told, rather than left wondering whether the key
233 // did anything at all.
234 let text = screen_text(&out.stdout);
235 assert!(
236 text.contains("Aborting"),
237 "nothing on screen said the comment was abandoned:\n{}",
238 text
239 );
240 }
241
242 /// An editor that dies mid-write must leave the DAG alone and the dashboard
243 /// usable — the screen is restored and the next key still works.
244 #[test]
245 fn an_editor_killed_mid_write_records_nothing() {
246 let (repo, id) = repo_with_patch();
247 let editor = repo.write_script(
248 "dying-editor.sh",
249 "#!/bin/sh\nprintf 'half a thought' > \"$1\"\nkill -TERM $$\n",
250 );
251
252 let out = repo.run_dashboard_driven(100, 40, &[("EDITOR", &editor)], |stdin| {
253 let _ = stdin.write_all(b"P");
254 thread::sleep(Duration::from_millis(300));
255 let _ = stdin.write_all(b"\r");
256 thread::sleep(Duration::from_millis(500));
257 let _ = stdin.write_all(b"c");
258 thread::sleep(Duration::from_millis(2000));
259 let _ = stdin.write_all(b"q");
260 });
261
262 let shown = repo.run_ok(&["patch", "show", &id]);
263 assert!(
264 !shown.contains("half a thought"),
265 "a killed editor's buffer was recorded:\n{}",
266 shown
267 );
268 let text = screen_text(&out.stdout);
269 assert!(
270 text.contains("Editor exited"),
271 "the dashboard did not say the editor died:\n{}",
272 text
273 );
274 // The screen came back, or `q` would never have been read and the harness
275 // would have had to kill the process.
276 assert!(
277 out.status.success(),
278 "the dashboard did not exit cleanly after a killed editor"
279 );
280 }
281
282 /// `x` claims the comment under the cursor was answered, and `x` again
283 /// withdraws the claim. Both go through `PatchState::resolve_comment`, which
284 /// is why the id on screen is the one the CLI takes.
285 #[test]
286 fn x_resolves_and_reopens_the_comment_under_the_cursor() {
287 let (repo, id) = repo_with_patch();
288 repo.run_ok(&[
289 "patch",
290 "comment",
291 &id,
292 "--at",
293 "src/lib.rs:2",
294 "-b",
295 "this line worries me",
296 ]);
297
298 // The detail pane opens with the cursor on row 0. Above the inline comment
299 // sit six header rows, a blank, the Revisions heading, one revision, a
300 // blank and the Inline Comments heading — eleven rows.
301 repo.run_dashboard_driven(100, 40, &[], |stdin| {
302 let _ = stdin.write_all(b"P");
303 thread::sleep(Duration::from_millis(300));
304 let _ = stdin.write_all(b"\r");
305 thread::sleep(Duration::from_millis(500));
306 let _ = stdin.write_all(b"jjjjjjjjjjj");
307 thread::sleep(Duration::from_millis(400));
308 let _ = stdin.write_all(b"x");
309 thread::sleep(Duration::from_millis(1500));
310 let _ = stdin.write_all(b"q");
311 });
312
313 let shown = repo.run_ok(&["patch", "show", &id]);
314 assert!(
315 shown.contains("--- Resolved"),
316 "`x` did not mark the comment answered:\n{}",
317 shown
318 );
319
320 // The same key withdraws the claim. The row moved into the Resolved
321 // section, one line further down.
322 repo.run_dashboard_driven(100, 40, &[], |stdin| {
323 let _ = stdin.write_all(b"P");
324 thread::sleep(Duration::from_millis(300));
325 let _ = stdin.write_all(b"\r");
326 thread::sleep(Duration::from_millis(500));
327 let _ = stdin.write_all(b"jjjjjjjjjjj");
328 thread::sleep(Duration::from_millis(400));
329 let _ = stdin.write_all(b"x");
330 thread::sleep(Duration::from_millis(1500));
331 let _ = stdin.write_all(b"q");
332 });
333
334 let shown = repo.run_ok(&["patch", "show", &id]);
335 assert!(
336 !shown.contains("--- Resolved"),
337 "`x` did not withdraw the claim:\n{}",
338 shown
339 );
340 }
341
342 /// `a` shows the change that answered the comment under the cursor: the
343 /// correspondence between feedback and the revision answering it, which is the
344 /// thing this project exists to keep. The view is the one `--answers` already
345 /// built; the dashboard only points it at the selected comment.
346 #[test]
347 fn a_shows_what_answered_the_comment_under_the_cursor() {
348 let (repo, id) = repo_with_patch();
349 repo.run_ok(&[
350 "patch",
351 "comment",
352 &id,
353 "--at",
354 "src/lib.rs:2",
355 "-b",
356 "this line worries me",
357 ]);
358 // A revision that answers it, then the claim that it did.
359 repo.git(&["checkout", "feature/x"]);
360 repo.commit_file(
361 "src/lib.rs",
362 "one\ntwo reworded\nthree\nfour\nfive\n",
363 "address the comment",
364 );
365 repo.run_ok(&["patch", "revise", &id, "-b", "reworded"]);
366 repo.git(&["checkout", "main"]);
367 let shown = repo.run_ok(&["patch", "show", &id]);
368 let comment_id = shown
369 .lines()
370 .find(|l| l.contains("src/lib.rs:2"))
371 .and_then(|l| l.split('[').nth(1))
372 .and_then(|s| s.split(']').next())
373 .expect("patch show prints the comment id in brackets")
374 .to_string();
375 repo.run_ok(&["patch", "resolve", &id, &comment_id]);
376
377 let out = repo.run_dashboard_driven(100, 40, &[], |stdin| {
378 let _ = stdin.write_all(b"P");
379 thread::sleep(Duration::from_millis(300));
380 let _ = stdin.write_all(b"\r");
381 thread::sleep(Duration::from_millis(500));
382 // Six header rows, blank, Revisions heading, two revisions, blank,
383 // the Resolved-comment heading — twelve rows down to the comment.
384 let _ = stdin.write_all(b"jjjjjjjjjjjj");
385 thread::sleep(Duration::from_millis(400));
386 let _ = stdin.write_all(b"a");
387 thread::sleep(Duration::from_millis(1500));
388 let _ = stdin.write_all(b"q");
389 });
390
391 // Matched without the interior spaces a redraw is free to skip: ratatui
392 // repaints only the cells that changed, so a blank between two words may
393 // never be written at all.
394 let text = screen_text(&out.stdout);
395 assert!(
396 text.contains("answered [") && text.contains("two reworded"),
397 "`a` did not show the change that answered the comment:\n{}",
398 text
399 );
400 }
401
402 /// `o` checks out the latest revision's commit, which a revision ref pins.
403 /// The branch the patch was authored on is deleted here on purpose: that is
404 /// the state every agent-produced patch is in, and checking `PatchState.branch`
405 /// out is what used to fail on them.
406 #[test]
407 fn o_checks_out_the_revision_even_with_the_branch_gone() {
408 let (repo, id) = repo_with_patch();
409 let head = repo.git(&["rev-parse", "feature/x"]).trim().to_string();
410 repo.git(&["branch", "-D", "feature/x"]);
411
412 let out = repo.run_dashboard_driven(100, 40, &[], |stdin| {
413 let _ = stdin.write_all(b"P");
414 thread::sleep(Duration::from_millis(300));
415 let _ = stdin.write_all(b"o");
416 thread::sleep(Duration::from_millis(1500));
417 });
418
419 let at = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
420 assert_eq!(
421 at,
422 head,
423 "not on the latest revision's commit:\n{}",
424 screen_text(&out.stdout)
425 );
426 let branch = repo.git(&["rev-parse", "--abbrev-ref", "HEAD"]);
427 assert!(
428 branch.trim().starts_with("collab/"),
429 "expected a collab/ branch for patch {}, got {}",
430 id,
431 branch
432 );
433 }
434
435 /// No editor configured is a message naming the alternatives, never a hang and
436 /// never a silent no-op.
437 #[test]
438 fn no_editor_configured_says_so() {
439 let (repo, _id) = repo_with_patch();
440
441 let out = repo.run_dashboard_driven(100, 40, &[("EDITOR", ""), ("VISUAL", "")], |stdin| {
442 let _ = stdin.write_all(b"P");
443 thread::sleep(Duration::from_millis(300));
444 let _ = stdin.write_all(b"\r");
445 thread::sleep(Duration::from_millis(500));
446 let _ = stdin.write_all(b"c");
447 thread::sleep(Duration::from_millis(1000));
448 let _ = stdin.write_all(b"q");
449 });
450 let text = screen_text(&out.stdout);
451 assert!(
452 text.contains("$EDITOR") || text.contains("EDITOR"),
453 "no message naming the editor variables:\n{}",
454 text
455 );
456 assert!(
457 out.status.success(),
458 "the dashboard hung or died with no editor configured"
459 );
460 }