f3d6c51a
One table for the dashboard's keys, and an issue side that can answer
a73x 2026-08-14 06:17
Commit message
src/tui/events.rs
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,7 @@ use crate::error::Error; | |||
| 12 | use crate::event::ReviewVerdict; | 12 | use crate::event::ReviewVerdict; |
| 13 | use crate::issue as issue_mod; | 13 | use crate::issue as issue_mod; |
| 14 | use crate::patch as patch_mod; | 14 | use crate::patch as patch_mod; |
| 15 | use crate::state::IssueStatus; | ||
| 15 | 16 | ||
| 16 | use super::state::{App, InputMode, KeyAction, ViewMode}; | 17 | use super::state::{App, InputMode, KeyAction, ViewMode}; |
| 17 | use super::widgets::{ui, RowTarget}; | 18 | use super::widgets::{ui, RowTarget}; |
| @@ -108,42 +109,11 @@ pub(crate) fn run_loop( | |||
| 108 | } | 109 | } |
| 109 | continue; | 110 | continue; |
| 110 | } | 111 | } |
| 111 | InputMode::ReviewVerdict => { | 112 | // The verdict prompt is a binding table like any other |
| 112 | let verdict = match key.code { | 113 | // pane's — three keys and a way out — so it is dispatched |
| 113 | KeyCode::Char('a') => Some(ReviewVerdict::Approve), | 114 | // below rather than intercepted here, and its footer is |
| 114 | KeyCode::Char('r') => Some(ReviewVerdict::RequestChanges), | 115 | // generated from the same table. |
| 115 | KeyCode::Char('c') => Some(ReviewVerdict::Comment), | 116 | InputMode::ReviewVerdict | InputMode::Normal => {} |
| 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 | } | ||
| 124 | InputMode::Normal => {} | ||
| 125 | } | ||
| 126 | |||
| 127 | // Handle keys that need repo access or are run_loop-specific | ||
| 128 | // before delegating to handle_key | ||
| 129 | if app.mode == ViewMode::Details { | ||
| 130 | match key.code { | ||
| 131 | KeyCode::Char('/') => { | ||
| 132 | app.input_mode = InputMode::Search; | ||
| 133 | app.search_query.clear(); | ||
| 134 | continue; | ||
| 135 | } | ||
| 136 | KeyCode::Char('n') => { | ||
| 137 | app.input_mode = InputMode::CreateTitle; | ||
| 138 | app.input_buf.clear(); | ||
| 139 | app.create_title.clear(); | ||
| 140 | continue; | ||
| 141 | } | ||
| 142 | KeyCode::Char('o') => { | ||
| 143 | return checkout_and_leave(app, repo); | ||
| 144 | } | ||
| 145 | _ => {} | ||
| 146 | } | ||
| 147 | } | 117 | } |
| 148 | 118 | ||
| 149 | match app.handle_key(key.code, key.modifiers) { | 119 | match app.handle_key(key.code, key.modifiers) { |
| @@ -164,7 +134,7 @@ pub(crate) fn run_loop( | |||
| 164 | open_patch_detail(app, repo, patch); | 134 | open_patch_detail(app, repo, patch); |
| 165 | } | 135 | } |
| 166 | } | 136 | } |
| 167 | KeyAction::OpenCommitBrowser => { | 137 | KeyAction::OpenEventHistory => { |
| 168 | if let Some(ref_name) = app.selected_ref_name() { | 138 | if let Some(ref_name) = app.selected_ref_name() { |
| 169 | match crate::dag::walk_events(repo, &ref_name) { | 139 | match crate::dag::walk_events(repo, &ref_name) { |
| 170 | Ok(events) => { | 140 | Ok(events) => { |
| @@ -173,7 +143,7 @@ pub(crate) fn run_loop( | |||
| 173 | if !app.event_history.is_empty() { | 143 | if !app.event_history.is_empty() { |
| 174 | app.event_list_state.select(Some(0)); | 144 | app.event_list_state.select(Some(0)); |
| 175 | } | 145 | } |
| 176 | app.mode = ViewMode::CommitList; | 146 | app.mode = ViewMode::EventHistory; |
| 177 | app.scroll = 0; | 147 | app.scroll = 0; |
| 178 | } | 148 | } |
| 179 | Err(e) => { | 149 | Err(e) => { |
| @@ -183,6 +153,9 @@ pub(crate) fn run_loop( | |||
| 183 | } | 153 | } |
| 184 | } | 154 | } |
| 185 | KeyAction::Comment => submit_comment(terminal, app, repo), | 155 | KeyAction::Comment => submit_comment(terminal, app, repo), |
| 156 | KeyAction::CommentOnIssue => submit_issue_comment(terminal, app, repo), | ||
| 157 | KeyAction::CloseOrReopenIssue => close_or_reopen_issue(terminal, app, repo), | ||
| 158 | KeyAction::Review(verdict) => submit_review(terminal, app, repo, verdict), | ||
| 186 | KeyAction::ToggleResolve => toggle_resolve(app, repo), | 159 | KeyAction::ToggleResolve => toggle_resolve(app, repo), |
| 187 | KeyAction::ShowAnswers => show_answers(app, repo), | 160 | KeyAction::ShowAnswers => show_answers(app, repo), |
| 188 | KeyAction::Checkout => return checkout_and_leave(app, repo), | 161 | KeyAction::Checkout => return checkout_and_leave(app, repo), |
| @@ -326,6 +299,97 @@ fn create_issue(app: &mut App, repo: &Repository, body: &str) { | |||
| 326 | app.create_title.clear(); | 299 | app.create_title.clear(); |
| 327 | } | 300 | } |
| 328 | 301 | ||
| 302 | /// Comment on the issue under the cursor. | ||
| 303 | /// | ||
| 304 | /// The same `$EDITOR` round trip the patch side has used since `094b055b`, | ||
| 305 | /// against `issue::comment` — which is exactly what `git-collab issue comment | ||
| 306 | /// <id> -b …` calls, so the dashboard is not a second way of writing the same | ||
| 307 | /// event. Until this existed the dashboard could read an issue and not answer | ||
| 308 | /// it, and the key that looked like the answer opened the Event History. | ||
| 309 | fn submit_issue_comment( | ||
| 310 | terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, | ||
| 311 | app: &mut App, | ||
| 312 | repo: &Repository, | ||
| 313 | ) { | ||
| 314 | let Some(issue) = app.selected_issue().cloned() else { | ||
| 315 | app.status_msg = Some("No issue is selected to comment on.".to_string()); | ||
| 316 | return; | ||
| 317 | }; | ||
| 318 | let short = app.issue_abbrev.of(&issue.id).to_string(); | ||
| 319 | let seed = comment_seed( | ||
| 320 | &format!("Commenting on issue {}: {}", short, issue.title), | ||
| 321 | Vec::new(), | ||
| 322 | ); | ||
| 323 | |||
| 324 | match compose(terminal, &seed) { | ||
| 325 | Ok(Composed::Body(body)) => match issue_mod::comment(repo, &issue.id, &body) { | ||
| 326 | Ok(_) => { | ||
| 327 | app.reload(repo); | ||
| 328 | app.status_msg = Some(format!("Comment added on issue {}", short)); | ||
| 329 | } | ||
| 330 | Err(e) => app.status_msg = Some(format!("Comment failed: {}", e)), | ||
| 331 | }, | ||
| 332 | Ok(Composed::Aborted) => { | ||
| 333 | app.status_msg = Some("Aborting comment: empty message.".to_string()) | ||
| 334 | } | ||
| 335 | Err(e) => app.status_msg = Some(e.to_string()), | ||
| 336 | } | ||
| 337 | } | ||
| 338 | |||
| 339 | /// Close the issue under the cursor, or reopen it if it is already closed. | ||
| 340 | /// | ||
| 341 | /// One key, because it is one decision and the row on screen already says | ||
| 342 | /// which way it goes. Closing composes a reason in `$EDITOR` and an empty | ||
| 343 | /// buffer abandons it — the same abort `git commit` gives, for the same | ||
| 344 | /// reason: this DAG is append-only and a `C` pressed by accident is permanent. | ||
| 345 | /// Reopening asks nothing, because `issue::reopen` records no reason and | ||
| 346 | /// because it is itself the undo of a close. | ||
| 347 | fn close_or_reopen_issue( | ||
| 348 | terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, | ||
| 349 | app: &mut App, | ||
| 350 | repo: &Repository, | ||
| 351 | ) { | ||
| 352 | let Some(issue) = app.selected_issue().cloned() else { | ||
| 353 | app.status_msg = Some("No issue is selected.".to_string()); | ||
| 354 | return; | ||
| 355 | }; | ||
| 356 | let short = app.issue_abbrev.of(&issue.id).to_string(); | ||
| 357 | |||
| 358 | if issue.status == IssueStatus::Closed { | ||
| 359 | match issue_mod::reopen(repo, &issue.id) { | ||
| 360 | Ok(_) => { | ||
| 361 | app.reload(repo); | ||
| 362 | app.status_msg = Some(format!("Issue {} reopened", short)); | ||
| 363 | } | ||
| 364 | Err(e) => app.status_msg = Some(format!("Reopen failed: {}", e)), | ||
| 365 | } | ||
| 366 | return; | ||
| 367 | } | ||
| 368 | |||
| 369 | let seed = comment_seed( | ||
| 370 | &format!( | ||
| 371 | "Closing issue {}: {}\n# What is written here is recorded as the reason.", | ||
| 372 | short, issue.title | ||
| 373 | ), | ||
| 374 | Vec::new(), | ||
| 375 | ); | ||
| 376 | match compose(terminal, &seed) { | ||
| 377 | Ok(Composed::Body(reason)) => { | ||
| 378 | match issue_mod::close(repo, &issue.id, Some(reason.trim())) { | ||
| 379 | Ok(_) => { | ||
| 380 | app.reload(repo); | ||
| 381 | app.status_msg = Some(format!("Issue {} closed", short)); | ||
| 382 | } | ||
| 383 | Err(e) => app.status_msg = Some(format!("Close failed: {}", e)), | ||
| 384 | } | ||
| 385 | } | ||
| 386 | Ok(Composed::Aborted) => { | ||
| 387 | app.status_msg = Some("Aborting close: empty message.".to_string()) | ||
| 388 | } | ||
| 389 | Err(e) => app.status_msg = Some(e.to_string()), | ||
| 390 | } | ||
| 391 | } | ||
| 392 | |||
| 329 | fn submit_comment( | 393 | fn submit_comment( |
| 330 | terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, | 394 | terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, |
| 331 | app: &mut App, | 395 | app: &mut App, |
src/tui/keys.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,889 @@ | |||
| 1 | //! Every key this dashboard binds, in one table. | ||
| 2 | //! | ||
| 3 | //! Before this, key handling was fifty-seven `KeyCode::` arms spread across | ||
| 4 | //! two files and nested by mode and pane, and the footer describing them was a | ||
| 5 | //! separate set of string literals. Nothing connected the two, so nothing | ||
| 6 | //! could notice when they disagreed — and they did. `c` meant "comment" on a | ||
| 7 | //! patch, "open the Event History" on an issue, and nothing at all one pane | ||
| 8 | //! over, where it fell through a `pane == Detail` guard into silence. | ||
| 9 | //! | ||
| 10 | //! So the bindings are data here, and everything else is derived from them: | ||
| 11 | //! | ||
| 12 | //! - [`lookup`] is the dispatcher. A key that is not in the table for the | ||
| 13 | //! current context is *unbound*, and the reader is told so; there is no | ||
| 14 | //! longer a silent fall-through. | ||
| 15 | //! - [`footer`] and [`overlay`] are generated. Help cannot drift from the | ||
| 16 | //! bindings because it is not written down anywhere else. This is the move | ||
| 17 | //! `tests/cli_surface_test.rs` made when it took clap's command tree as the | ||
| 18 | //! oracle for command citations instead of a hand-kept list. | ||
| 19 | //! - The tests at the bottom assert what the old structure could not express: | ||
| 20 | //! that no context binds a key twice, and that no context is bound but | ||
| 21 | //! unreachable. | ||
| 22 | //! | ||
| 23 | //! Text entry is deliberately *not* in the table. In [`InputMode::Search`] and | ||
| 24 | //! [`InputMode::CreateTitle`] every printable key is data rather than a | ||
| 25 | //! command, so there is nothing to bind and nothing to advertise; those two | ||
| 26 | //! are intercepted before dispatch in `events.rs`. | ||
| 27 | |||
| 28 | use crossterm::event::KeyCode; | ||
| 29 | |||
| 30 | use crate::state::IssueStatus; | ||
| 31 | |||
| 32 | use super::state::{App, InputMode, ListMode, Pane, StatusFilter, ViewMode}; | ||
| 33 | |||
| 34 | // ── Contexts ──────────────────────────────────────────────────────────────── | ||
| 35 | |||
| 36 | /// Where a key is being pressed. | ||
| 37 | /// | ||
| 38 | /// The (`ViewMode`, `Pane`) pair that already governed dispatch, with | ||
| 39 | /// `ListMode` folded in where it genuinely changes what a key means: the | ||
| 40 | /// issue and patch lists share a `ViewMode` and a `Pane` and disagree about | ||
| 41 | /// `c`, `e`, `p` and `u`, so treating them as one context is what let those | ||
| 42 | /// disagreements be settled by an `if` buried in an arm. | ||
| 43 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 44 | pub(crate) enum Context { | ||
| 45 | /// The issue list, with focus on the list. | ||
| 46 | IssueList, | ||
| 47 | /// The issue list, with focus on the detail pane beside it. | ||
| 48 | IssueDetail, | ||
| 49 | /// The patch list, with focus on the list. | ||
| 50 | PatchList, | ||
| 51 | /// The patch list, with focus on the summary pane beside it. Reachable | ||
| 52 | /// only when there is no patch to open — `Tab` opens the patch itself | ||
| 53 | /// otherwise — but reachable, so it is bound. | ||
| 54 | PatchSummary, | ||
| 55 | /// One patch, its revisions and its diff: the review surface. | ||
| 56 | PatchDetail, | ||
| 57 | /// An issue's event stream, listed. | ||
| 58 | EventHistory, | ||
| 59 | /// One event from that stream. | ||
| 60 | EventDetail, | ||
| 61 | /// The modal prompt `R` opens, waiting for a verdict. | ||
| 62 | ReviewVerdict, | ||
| 63 | /// The `?` overlay, listing the context behind it. | ||
| 64 | Help, | ||
| 65 | } | ||
| 66 | |||
| 67 | impl Context { | ||
| 68 | /// Every context, densely indexed. The inventory the invariant tests walk, | ||
| 69 | /// and nothing the program itself needs — dispatch always has an [`App`] | ||
| 70 | /// to ask. | ||
| 71 | /// | ||
| 72 | /// The match in [`Context::ordinal`] is exhaustive, so a new variant | ||
| 73 | /// cannot be added without giving it an index, and `all_contexts_are_listed` | ||
| 74 | /// checks that index is this array's. | ||
| 75 | #[cfg(test)] | ||
| 76 | pub(crate) const ALL: [Context; 9] = [ | ||
| 77 | Context::IssueList, | ||
| 78 | Context::IssueDetail, | ||
| 79 | Context::PatchList, | ||
| 80 | Context::PatchSummary, | ||
| 81 | Context::PatchDetail, | ||
| 82 | Context::EventHistory, | ||
| 83 | Context::EventDetail, | ||
| 84 | Context::ReviewVerdict, | ||
| 85 | Context::Help, | ||
| 86 | ]; | ||
| 87 | |||
| 88 | #[cfg(test)] | ||
| 89 | fn ordinal(self) -> usize { | ||
| 90 | match self { | ||
| 91 | Context::IssueList => 0, | ||
| 92 | Context::IssueDetail => 1, | ||
| 93 | Context::PatchList => 2, | ||
| 94 | Context::PatchSummary => 3, | ||
| 95 | Context::PatchDetail => 4, | ||
| 96 | Context::EventHistory => 5, | ||
| 97 | Context::EventDetail => 6, | ||
| 98 | Context::ReviewVerdict => 7, | ||
| 99 | Context::Help => 8, | ||
| 100 | } | ||
| 101 | } | ||
| 102 | |||
| 103 | /// What the overlay calls this pane. | ||
| 104 | pub(crate) fn title(self) -> &'static str { | ||
| 105 | match self { | ||
| 106 | Context::IssueList => "Issues — list", | ||
| 107 | Context::IssueDetail => "Issues — detail", | ||
| 108 | Context::PatchList => "Patches — list", | ||
| 109 | Context::PatchSummary => "Patches — detail", | ||
| 110 | Context::PatchDetail => "Patch detail", | ||
| 111 | Context::EventHistory => "Event History", | ||
| 112 | Context::EventDetail => "Event detail", | ||
| 113 | Context::ReviewVerdict => "Review verdict", | ||
| 114 | Context::Help => "Keys", | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | /// The context a key pressed right now belongs to. | ||
| 119 | pub(crate) fn of(app: &App) -> Context { | ||
| 120 | if app.show_help { | ||
| 121 | return Context::Help; | ||
| 122 | } | ||
| 123 | if app.input_mode == InputMode::ReviewVerdict { | ||
| 124 | return Context::ReviewVerdict; | ||
| 125 | } | ||
| 126 | Context::surface(app) | ||
| 127 | } | ||
| 128 | |||
| 129 | /// The context underneath any overlay — the one the `?` list is about. | ||
| 130 | pub(crate) fn surface(app: &App) -> Context { | ||
| 131 | match app.mode { | ||
| 132 | ViewMode::PatchDetail => Context::PatchDetail, | ||
| 133 | ViewMode::EventHistory => Context::EventHistory, | ||
| 134 | ViewMode::EventDetail => Context::EventDetail, | ||
| 135 | ViewMode::Details => match (app.list_mode, &app.pane) { | ||
| 136 | (ListMode::Issues, Pane::ItemList) => Context::IssueList, | ||
| 137 | (ListMode::Issues, Pane::Detail) => Context::IssueDetail, | ||
| 138 | (ListMode::Patches, Pane::ItemList) => Context::PatchList, | ||
| 139 | (ListMode::Patches, Pane::Detail) => Context::PatchSummary, | ||
| 140 | }, | ||
| 141 | } | ||
| 142 | } | ||
| 143 | } | ||
| 144 | |||
| 145 | // ── Keys ──────────────────────────────────────────────────────────────────── | ||
| 146 | |||
| 147 | /// A key as a binding names it. | ||
| 148 | /// | ||
| 149 | /// Control is part of the binding; Shift is not. The terminal has already | ||
| 150 | /// folded Shift into the character by the time this sees it, which is why `R` | ||
| 151 | /// and `r` are two different keys in the table rather than one key and a | ||
| 152 | /// modifier. | ||
| 153 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 154 | pub(crate) struct Key { | ||
| 155 | pub(crate) code: KeyCode, | ||
| 156 | pub(crate) ctrl: bool, | ||
| 157 | } | ||
| 158 | |||
| 159 | const fn k(c: char) -> Key { | ||
| 160 | Key { | ||
| 161 | code: KeyCode::Char(c), | ||
| 162 | ctrl: false, | ||
| 163 | } | ||
| 164 | } | ||
| 165 | |||
| 166 | const fn ctrl(c: char) -> Key { | ||
| 167 | Key { | ||
| 168 | code: KeyCode::Char(c), | ||
| 169 | ctrl: true, | ||
| 170 | } | ||
| 171 | } | ||
| 172 | |||
| 173 | const fn c(code: KeyCode) -> Key { | ||
| 174 | Key { code, ctrl: false } | ||
| 175 | } | ||
| 176 | |||
| 177 | impl Key { | ||
| 178 | /// How help prints it. | ||
| 179 | pub(crate) fn name(self) -> String { | ||
| 180 | let base = match self.code { | ||
| 181 | KeyCode::Char(' ') => "Space".to_string(), | ||
| 182 | KeyCode::Char(ch) => ch.to_string(), | ||
| 183 | KeyCode::Enter => "Enter".to_string(), | ||
| 184 | KeyCode::Tab => "Tab".to_string(), | ||
| 185 | KeyCode::BackTab => "Shift-Tab".to_string(), | ||
| 186 | KeyCode::Esc => "Esc".to_string(), | ||
| 187 | KeyCode::Backspace => "Backspace".to_string(), | ||
| 188 | KeyCode::Up => "Up".to_string(), | ||
| 189 | KeyCode::Down => "Down".to_string(), | ||
| 190 | KeyCode::Left => "Left".to_string(), | ||
| 191 | KeyCode::Right => "Right".to_string(), | ||
| 192 | KeyCode::PageUp => "PgUp".to_string(), | ||
| 193 | KeyCode::PageDown => "PgDn".to_string(), | ||
| 194 | KeyCode::Home => "Home".to_string(), | ||
| 195 | KeyCode::End => "End".to_string(), | ||
| 196 | KeyCode::Delete => "Del".to_string(), | ||
| 197 | KeyCode::Insert => "Ins".to_string(), | ||
| 198 | KeyCode::F(n) => format!("F{}", n), | ||
| 199 | other => format!("{:?}", other), | ||
| 200 | }; | ||
| 201 | if self.ctrl { | ||
| 202 | format!("Ctrl-{}", base) | ||
| 203 | } else { | ||
| 204 | base | ||
| 205 | } | ||
| 206 | } | ||
| 207 | } | ||
| 208 | |||
| 209 | // ── Actions ───────────────────────────────────────────────────────────────── | ||
| 210 | |||
| 211 | /// What a key means. One variant per distinct meaning, which is why moving | ||
| 212 | /// down has four of them: the footer used to say `j/k:navigate` on a pane | ||
| 213 | /// where `j` scrolled, because "move down" was one arm that did different | ||
| 214 | /// things and one string that described only the first of them. | ||
| 215 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| 216 | pub(crate) enum Action { | ||
| 217 | Quit, | ||
| 218 | /// Leave this view for the one behind it. | ||
| 219 | Back, | ||
| 220 | SelectNext, | ||
| 221 | SelectPrev, | ||
| 222 | ScrollDown, | ||
| 223 | ScrollUp, | ||
| 224 | ScrollPageDown, | ||
| 225 | ScrollPageUp, | ||
| 226 | CursorDown, | ||
| 227 | CursorUp, | ||
| 228 | CursorPageDown, | ||
| 229 | CursorPageUp, | ||
| 230 | /// Move the view a row without moving the cursor: the only way through a | ||
| 231 | /// line that wrapped taller than the pane. | ||
| 232 | ScrollRowDown, | ||
| 233 | ScrollRowUp, | ||
| 234 | EventNext, | ||
| 235 | EventPrev, | ||
| 236 | OpenEvent, | ||
| 237 | TogglePane, | ||
| 238 | OpenSelectedPatch, | ||
| 239 | OpenLinkedPatch, | ||
| 240 | SwitchList, | ||
| 241 | CycleStatusFilter, | ||
| 242 | ToggleUnresolvedOnly, | ||
| 243 | Reload, | ||
| 244 | BeginSearch, | ||
| 245 | BeginCreateIssue, | ||
| 246 | CommentOnIssue, | ||
| 247 | CloseOrReopenIssue, | ||
| 248 | OpenEventHistory, | ||
| 249 | CommentOnPatch, | ||
| 250 | BeginReviewVerdict, | ||
| 251 | ToggleResolve, | ||
| 252 | ShowAnswers, | ||
| 253 | Checkout, | ||
| 254 | NextRevision, | ||
| 255 | PrevRevision, | ||
| 256 | ToggleInterdiff, | ||
| 257 | ToggleWrap, | ||
| 258 | ShowHelp, | ||
| 259 | CloseHelp, | ||
| 260 | VerdictApprove, | ||
| 261 | VerdictRequestChanges, | ||
| 262 | VerdictComment, | ||
| 263 | CancelVerdict, | ||
| 264 | } | ||
| 265 | |||
| 266 | // ── The table ─────────────────────────────────────────────────────────────── | ||
| 267 | |||
| 268 | /// What help calls a binding. Some labels depend on what the key would do | ||
| 269 | /// next — `a` cycles a filter, so it has to say which way — and those are a | ||
| 270 | /// function of the app rather than a literal, so that the footer stays right | ||
| 271 | /// without anyone maintaining a parallel `match`. | ||
| 272 | #[derive(Clone, Copy)] | ||
| 273 | pub(crate) enum Label { | ||
| 274 | Fixed(&'static str), | ||
| 275 | OfApp(fn(&App) -> &'static str), | ||
| 276 | } | ||
| 277 | |||
| 278 | impl Label { | ||
| 279 | fn text(self, app: &App) -> &'static str { | ||
| 280 | match self { | ||
| 281 | Label::Fixed(s) => s, | ||
| 282 | Label::OfApp(f) => f(app), | ||
| 283 | } | ||
| 284 | } | ||
| 285 | } | ||
| 286 | |||
| 287 | pub(crate) struct Entry { | ||
| 288 | /// The keys that mean this here. More than one when they are aliases — | ||
| 289 | /// `j` and `Down` — and the first is the one help prints. | ||
| 290 | pub(crate) keys: &'static [Key], | ||
| 291 | pub(crate) action: Action, | ||
| 292 | pub(crate) label: Label, | ||
| 293 | /// Whether the one-line footer advertises it. The overlay lists all of it. | ||
| 294 | pub(crate) in_footer: bool, | ||
| 295 | /// Another action whose key is printed alongside this one's, for the pairs | ||
| 296 | /// that read as one entry: `j/k`, `[/]`. Still generated from the table, so | ||
| 297 | /// rebinding either half moves the help with it. | ||
| 298 | pub(crate) pairs_with: Option<Action>, | ||
| 299 | } | ||
| 300 | |||
| 301 | /// Shorthand for the common shape: one key, one action, one fixed label. | ||
| 302 | const fn e(keys: &'static [Key], action: Action, label: &'static str, in_footer: bool) -> Entry { | ||
| 303 | Entry { | ||
| 304 | keys, | ||
| 305 | action, | ||
| 306 | label: Label::Fixed(label), | ||
| 307 | in_footer, | ||
| 308 | pairs_with: None, | ||
| 309 | } | ||
| 310 | } | ||
| 311 | |||
| 312 | /// Bound in every context that is not a modal prompt. | ||
| 313 | /// | ||
| 314 | /// Kept out of the per-context tables so that `q` cannot come to mean | ||
| 315 | /// something else on one pane by accident — and the duplicate check below | ||
| 316 | /// tests these against every context, so it cannot come to mean something else | ||
| 317 | /// on purpose either without the test saying so. | ||
| 318 | const COMMON: &[Entry] = &[ | ||
| 319 | e(&[k('?')], Action::ShowHelp, "keys", true), | ||
| 320 | e(&[k('q')], Action::Quit, "quit", true), | ||
| 321 | e(&[ctrl('c')], Action::Quit, "quit", false), | ||
| 322 | ]; | ||
| 323 | |||
| 324 | fn filter_label(app: &App) -> &'static str { | ||
| 325 | match app.status_filter { | ||
| 326 | StatusFilter::Open => "show all", | ||
| 327 | StatusFilter::All => "closed", | ||
| 328 | StatusFilter::Closed => "open only", | ||
| 329 | } | ||
| 330 | } | ||
| 331 | |||
| 332 | fn list_label(app: &App) -> &'static str { | ||
| 333 | match app.list_mode { | ||
| 334 | ListMode::Issues => "patches", | ||
| 335 | ListMode::Patches => "issues", | ||
| 336 | } | ||
| 337 | } | ||
| 338 | |||
| 339 | /// `C` is one key because closing and reopening are one decision, and the | ||
| 340 | /// issue on screen already says which way it goes. | ||
| 341 | fn close_label(app: &App) -> &'static str { | ||
| 342 | match app.selected_issue() { | ||
| 343 | Some(issue) if issue.status == IssueStatus::Closed => "reopen", | ||
| 344 | _ => "close", | ||
| 345 | } | ||
| 346 | } | ||
| 347 | |||
| 348 | /// Down and up, with their arrow-key aliases. Named once so that a rebinding | ||
| 349 | /// moves every pane's navigation together, and so that the footer's `j/k` is | ||
| 350 | /// these keys rather than two characters someone typed into a string. | ||
| 351 | const DOWN: &[Key] = &[k('j'), c(KeyCode::Down)]; | ||
| 352 | const UP: &[Key] = &[k('k'), c(KeyCode::Up)]; | ||
| 353 | |||
| 354 | const fn nav_pair(next: Action, prev: Action, label: &'static str) -> [Entry; 2] { | ||
| 355 | [ | ||
| 356 | Entry { | ||
| 357 | keys: DOWN, | ||
| 358 | action: next, | ||
| 359 | label: Label::Fixed(label), | ||
| 360 | in_footer: true, | ||
| 361 | pairs_with: Some(prev), | ||
| 362 | }, | ||
| 363 | Entry { | ||
| 364 | keys: UP, | ||
| 365 | action: prev, | ||
| 366 | label: Label::Fixed(label), | ||
| 367 | in_footer: false, | ||
| 368 | pairs_with: None, | ||
| 369 | }, | ||
| 370 | ] | ||
| 371 | } | ||
| 372 | |||
| 373 | const ISSUE_NAV: [Entry; 2] = nav_pair(Action::SelectNext, Action::SelectPrev, "navigate"); | ||
| 374 | const SCROLL_NAV: [Entry; 2] = nav_pair(Action::ScrollDown, Action::ScrollUp, "scroll"); | ||
| 375 | |||
| 376 | /// The keys the two issue contexts share. Everything about an issue is | ||
| 377 | /// addressed by the row under the cursor, and the row under the cursor does | ||
| 378 | /// not change when focus moves to the pane beside it — which is exactly the | ||
| 379 | /// assumption the old `pane == Pane::Detail` guard got wrong. | ||
| 380 | const ISSUE_VERBS: &[Entry] = &[ | ||
| 381 | e(&[k('c')], Action::CommentOnIssue, "comment", true), | ||
| 382 | e(&[k('e')], Action::OpenEventHistory, "events", true), | ||
| 383 | e(&[k('p')], Action::OpenLinkedPatch, "patch", true), | ||
| 384 | Entry { | ||
| 385 | keys: &[k('P')], | ||
| 386 | action: Action::SwitchList, | ||
| 387 | label: Label::OfApp(list_label), | ||
| 388 | in_footer: true, | ||
| 389 | pairs_with: None, | ||
| 390 | }, | ||
| 391 | Entry { | ||
| 392 | keys: &[k('a')], | ||
| 393 | action: Action::CycleStatusFilter, | ||
| 394 | label: Label::OfApp(filter_label), | ||
| 395 | in_footer: true, | ||
| 396 | pairs_with: None, | ||
| 397 | }, | ||
| 398 | Entry { | ||
| 399 | keys: &[k('C')], | ||
| 400 | action: Action::CloseOrReopenIssue, | ||
| 401 | label: Label::OfApp(close_label), | ||
| 402 | in_footer: true, | ||
| 403 | pairs_with: None, | ||
| 404 | }, | ||
| 405 | e(&[k('/')], Action::BeginSearch, "search", true), | ||
| 406 | e(&[k('r')], Action::Reload, "refresh", true), | ||
| 407 | e(&[k('n')], Action::BeginCreateIssue, "new issue", true), | ||
| 408 | e(&[k('o')], Action::Checkout, "check out the linked patch", false), | ||
| 409 | e( | ||
| 410 | &[c(KeyCode::PageDown)], | ||
| 411 | Action::ScrollPageDown, | ||
| 412 | "page down the detail", | ||
| 413 | false, | ||
| 414 | ), | ||
| 415 | e( | ||
| 416 | &[c(KeyCode::PageUp)], | ||
| 417 | Action::ScrollPageUp, | ||
| 418 | "page up the detail", | ||
| 419 | false, | ||
| 420 | ), | ||
| 421 | // Esc is "back" wherever there is something behind. On the top-level | ||
| 422 | // lists there is not, so it keeps the meaning it has always had here. | ||
| 423 | e(&[c(KeyCode::Esc)], Action::Quit, "quit", false), | ||
| 424 | ]; | ||
| 425 | |||
| 426 | /// The same, for the patch list. `u` lives here and only here: issues carry no | ||
| 427 | /// unresolved count, so on that pane it would narrow nothing while looking | ||
| 428 | /// like it had. | ||
| 429 | const PATCH_LIST_VERBS: &[Entry] = &[ | ||
| 430 | e(&[k('u')], Action::ToggleUnresolvedOnly, "unresolved", true), | ||
| 431 | Entry { | ||
| 432 | keys: &[k('P')], | ||
| 433 | action: Action::SwitchList, | ||
| 434 | label: Label::OfApp(list_label), | ||
| 435 | in_footer: true, | ||
| 436 | pairs_with: None, | ||
| 437 | }, | ||
| 438 | Entry { | ||
| 439 | keys: &[k('a')], | ||
| 440 | action: Action::CycleStatusFilter, | ||
| 441 | label: Label::OfApp(filter_label), | ||
| 442 | in_footer: true, | ||
| 443 | pairs_with: None, | ||
| 444 | }, | ||
| 445 | e(&[k('/')], Action::BeginSearch, "search", true), | ||
| 446 | e(&[k('r')], Action::Reload, "refresh", true), | ||
| 447 | e(&[k('n')], Action::BeginCreateIssue, "new issue", true), | ||
| 448 | e(&[k('o')], Action::Checkout, "check out this patch", false), | ||
| 449 | e( | ||
| 450 | &[c(KeyCode::PageDown)], | ||
| 451 | Action::ScrollPageDown, | ||
| 452 | "page down the detail", | ||
| 453 | false, | ||
| 454 | ), | ||
| 455 | e( | ||
| 456 | &[c(KeyCode::PageUp)], | ||
| 457 | Action::ScrollPageUp, | ||
| 458 | "page up the detail", | ||
| 459 | false, | ||
| 460 | ), | ||
| 461 | // Esc is "back" wherever there is something behind. On the top-level | ||
| 462 | // lists there is not, so it keeps the meaning it has always had here. | ||
| 463 | e(&[c(KeyCode::Esc)], Action::Quit, "quit", false), | ||
| 464 | ]; | ||
| 465 | |||
| 466 | const PATCH_DETAIL: &[Entry] = &[ | ||
| 467 | Entry { | ||
| 468 | keys: DOWN, | ||
| 469 | action: Action::CursorDown, | ||
| 470 | label: Label::Fixed("line"), | ||
| 471 | in_footer: true, | ||
| 472 | pairs_with: Some(Action::CursorUp), | ||
| 473 | }, | ||
| 474 | Entry { | ||
| 475 | keys: UP, | ||
| 476 | action: Action::CursorUp, | ||
| 477 | label: Label::Fixed("line"), | ||
| 478 | in_footer: false, | ||
| 479 | pairs_with: None, | ||
| 480 | }, | ||
| 481 | e(&[k('c')], Action::CommentOnPatch, "comment", true), | ||
| 482 | e(&[k('R')], Action::BeginReviewVerdict, "review", true), | ||
| 483 | e(&[k('x')], Action::ToggleResolve, "resolve", true), | ||
| 484 | e(&[k('a')], Action::ShowAnswers, "answers", true), | ||
| 485 | e(&[c(KeyCode::Esc)], Action::Back, "back", true), | ||
| 486 | Entry { | ||
| 487 | keys: &[k('[')], | ||
| 488 | action: Action::PrevRevision, | ||
| 489 | label: Label::Fixed("revision"), | ||
| 490 | in_footer: true, | ||
| 491 | pairs_with: Some(Action::NextRevision), | ||
| 492 | }, | ||
| 493 | Entry { | ||
| 494 | keys: &[k(']')], | ||
| 495 | action: Action::NextRevision, | ||
| 496 | label: Label::Fixed("revision"), | ||
| 497 | in_footer: false, | ||
| 498 | pairs_with: None, | ||
| 499 | }, | ||
| 500 | e(&[k('d')], Action::ToggleInterdiff, "interdiff", true), | ||
| 501 | e(&[k('o')], Action::Checkout, "checkout", true), | ||
| 502 | e(&[k('w')], Action::ToggleWrap, "wrap", true), | ||
| 503 | e(&[ctrl('e')], Action::ScrollRowDown, "scroll a row", false), | ||
| 504 | e(&[ctrl('y')], Action::ScrollRowUp, "scroll a row back", false), | ||
| 505 | e( | ||
| 506 | &[c(KeyCode::PageDown)], | ||
| 507 | Action::CursorPageDown, | ||
| 508 | "page down", | ||
| 509 | false, | ||
| 510 | ), | ||
| 511 | e(&[c(KeyCode::PageUp)], Action::CursorPageUp, "page up", false), | ||
| 512 | ]; | ||
| 513 | |||
| 514 | const EVENT_HISTORY: &[Entry] = &[ | ||
| 515 | Entry { | ||
| 516 | keys: DOWN, | ||
| 517 | action: Action::EventNext, | ||
| 518 | label: Label::Fixed("navigate"), | ||
| 519 | in_footer: true, | ||
| 520 | pairs_with: Some(Action::EventPrev), | ||
| 521 | }, | ||
| 522 | Entry { | ||
| 523 | keys: UP, | ||
| 524 | action: Action::EventPrev, | ||
| 525 | label: Label::Fixed("navigate"), | ||
| 526 | in_footer: false, | ||
| 527 | pairs_with: None, | ||
| 528 | }, | ||
| 529 | e(&[c(KeyCode::Enter)], Action::OpenEvent, "detail", true), | ||
| 530 | e(&[c(KeyCode::Esc)], Action::Back, "back", true), | ||
| 531 | ]; | ||
| 532 | |||
| 533 | const EVENT_DETAIL: &[Entry] = &[ | ||
| 534 | e(&[c(KeyCode::Esc)], Action::Back, "back", true), | ||
| 535 | e( | ||
| 536 | &[c(KeyCode::PageDown)], | ||
| 537 | Action::ScrollPageDown, | ||
| 538 | "page down", | ||
| 539 | false, | ||
| 540 | ), | ||
| 541 | e(&[c(KeyCode::PageUp)], Action::ScrollPageUp, "page up", false), | ||
| 542 | ]; | ||
| 543 | |||
| 544 | const REVIEW_VERDICT: &[Entry] = &[ | ||
| 545 | e(&[k('a')], Action::VerdictApprove, "approve", true), | ||
| 546 | e( | ||
| 547 | &[k('r')], | ||
| 548 | Action::VerdictRequestChanges, | ||
| 549 | "request-changes", | ||
| 550 | true, | ||
| 551 | ), | ||
| 552 | e(&[k('c')], Action::VerdictComment, "comment", true), | ||
| 553 | e(&[c(KeyCode::Esc)], Action::CancelVerdict, "cancel", true), | ||
| 554 | ]; | ||
| 555 | |||
| 556 | const HELP: &[Entry] = &[ | ||
| 557 | Entry { | ||
| 558 | keys: &[c(KeyCode::Esc), k('?')], | ||
| 559 | action: Action::CloseHelp, | ||
| 560 | label: Label::Fixed("close"), | ||
| 561 | in_footer: true, | ||
| 562 | pairs_with: None, | ||
| 563 | }, | ||
| 564 | e(&[k('q')], Action::Quit, "quit", true), | ||
| 565 | ]; | ||
| 566 | |||
| 567 | /// The bindings of one context, in footer order. | ||
| 568 | /// | ||
| 569 | /// Ordered by what the reader reaches for, because the footer is one line and | ||
| 570 | /// a narrow terminal clips the tail. `?` and `q` lead: one line cannot hold | ||
| 571 | /// this surface, so its first duty is to name the key that can, and the second | ||
| 572 | /// is the way out. | ||
| 573 | pub(crate) fn entries(context: Context) -> Vec<&'static Entry> { | ||
| 574 | let own: Vec<&'static Entry> = match context { | ||
| 575 | Context::IssueList => ISSUE_NAV | ||
| 576 | .iter() | ||
| 577 | .chain(TAB_PANE.iter()) | ||
| 578 | .chain(ISSUE_VERBS.iter()) | ||
| 579 | .collect(), | ||
| 580 | Context::IssueDetail => SCROLL_NAV | ||
| 581 | .iter() | ||
| 582 | .chain(TAB_PANE.iter()) | ||
| 583 | .chain(ISSUE_VERBS.iter()) | ||
| 584 | .collect(), | ||
| 585 | Context::PatchList => ISSUE_NAV | ||
| 586 | .iter() | ||
| 587 | .chain(OPEN_PATCH.iter()) | ||
| 588 | .chain(PATCH_LIST_VERBS.iter()) | ||
| 589 | .collect(), | ||
| 590 | Context::PatchSummary => SCROLL_NAV | ||
| 591 | .iter() | ||
| 592 | .chain(TAB_PANE.iter()) | ||
| 593 | .chain(PATCH_LIST_VERBS.iter()) | ||
| 594 | .collect(), | ||
| 595 | Context::PatchDetail => PATCH_DETAIL.iter().collect(), | ||
| 596 | Context::EventHistory => EVENT_HISTORY.iter().collect(), | ||
| 597 | Context::EventDetail => SCROLL_NAV.iter().chain(EVENT_DETAIL.iter()).collect(), | ||
| 598 | Context::ReviewVerdict => REVIEW_VERDICT.iter().collect(), | ||
| 599 | Context::Help => HELP.iter().collect(), | ||
| 600 | }; | ||
| 601 | let mut all: Vec<&'static Entry> = common(context).iter().collect(); | ||
| 602 | all.extend(own); | ||
| 603 | all | ||
| 604 | } | ||
| 605 | |||
| 606 | const TAB_PANE: [Entry; 1] = [Entry { | ||
| 607 | keys: &[c(KeyCode::Tab), c(KeyCode::Enter)], | ||
| 608 | action: Action::TogglePane, | ||
| 609 | label: Label::Fixed("pane"), | ||
| 610 | in_footer: true, | ||
| 611 | pairs_with: None, | ||
| 612 | }]; | ||
| 613 | |||
| 614 | /// In the patch list, `Enter` opens the patch rather than moving focus: the | ||
| 615 | /// pane beside the list is a summary of the thing `Enter` opens, so stopping | ||
| 616 | /// there would be a step to nowhere. | ||
| 617 | const OPEN_PATCH: [Entry; 1] = [Entry { | ||
| 618 | keys: &[c(KeyCode::Enter), c(KeyCode::Tab)], | ||
| 619 | action: Action::OpenSelectedPatch, | ||
| 620 | label: Label::Fixed("view patch"), | ||
| 621 | in_footer: true, | ||
| 622 | pairs_with: None, | ||
| 623 | }]; | ||
| 624 | |||
| 625 | fn common(context: Context) -> &'static [Entry] { | ||
| 626 | match context { | ||
| 627 | // Modal prompts. `q` in a verdict prompt is a verdict-shaped mistake, | ||
| 628 | // and the overlay's own `q` is bound below. | ||
| 629 | Context::Help | Context::ReviewVerdict => &[], | ||
| 630 | _ => COMMON, | ||
| 631 | } | ||
| 632 | } | ||
| 633 | |||
| 634 | // ── Dispatch ──────────────────────────────────────────────────────────────── | ||
| 635 | |||
| 636 | /// What this key means here, or `None` if it means nothing here. | ||
| 637 | /// | ||
| 638 | /// `None` is a real answer, and the caller owes the reader a sentence for it. | ||
| 639 | /// That is the whole difference from the structure this replaces, where a key | ||
| 640 | /// with no binding and a key whose binding was gated out both arrived at the | ||
| 641 | /// same `_ => KeyAction::Continue`. | ||
| 642 | pub(crate) fn lookup(context: Context, key: Key) -> Option<Action> { | ||
| 643 | entries(context) | ||
| 644 | .into_iter() | ||
| 645 | .find(|entry| entry.keys.contains(&key)) | ||
| 646 | .map(|entry| entry.action) | ||
| 647 | } | ||
| 648 | |||
| 649 | // ── Generated help ────────────────────────────────────────────────────────── | ||
| 650 | |||
| 651 | /// The key names printed for one entry, `j/k` and `[/]` included. | ||
| 652 | fn hint_keys(context: Context, entry: &Entry) -> String { | ||
| 653 | let mut names = vec![entry.keys[0].name()]; | ||
| 654 | if let Some(partner) = entry.pairs_with { | ||
| 655 | if let Some(other) = entries(context) | ||
| 656 | .into_iter() | ||
| 657 | .find(|candidate| candidate.action == partner) | ||
| 658 | { | ||
| 659 | names.push(other.keys[0].name()); | ||
| 660 | } | ||
| 661 | } | ||
| 662 | names.join("/") | ||
| 663 | } | ||
| 664 | |||
| 665 | /// Whether some other entry already prints this one as the tail of its pair, | ||
| 666 | /// so `j/k` and `[/]` appear once rather than twice. | ||
| 667 | fn is_a_partner(context: Context, entry: &Entry) -> bool { | ||
| 668 | entries(context) | ||
| 669 | .into_iter() | ||
| 670 | .any(|other| other.pairs_with == Some(entry.action)) | ||
| 671 | } | ||
| 672 | |||
| 673 | /// The one-line footer for wherever the reader is. | ||
| 674 | pub(crate) fn footer(app: &App) -> String { | ||
| 675 | let context = Context::of(app); | ||
| 676 | let mut items: Vec<String> = Vec::new(); | ||
| 677 | for entry in entries(context) { | ||
| 678 | if !entry.in_footer { | ||
| 679 | continue; | ||
| 680 | } | ||
| 681 | items.push(format!( | ||
| 682 | "{}:{}", | ||
| 683 | hint_keys(context, entry), | ||
| 684 | entry.label.text(app) | ||
| 685 | )); | ||
| 686 | } | ||
| 687 | items.join(" ") | ||
| 688 | } | ||
| 689 | |||
| 690 | /// Every binding of the context behind the overlay, as `(keys, meaning)`. | ||
| 691 | /// | ||
| 692 | /// `surface` rather than `of`, because the list the overlay draws is the list | ||
| 693 | /// for the pane it is covering — the overlay's own two keys are on its footer. | ||
| 694 | pub(crate) fn overlay(app: &App) -> Vec<(String, String)> { | ||
| 695 | let context = Context::surface(app); | ||
| 696 | entries(context) | ||
| 697 | .into_iter() | ||
| 698 | .filter(|entry| !is_a_partner(context, entry)) | ||
| 699 | .map(|entry| { | ||
| 700 | let keys = if entry.pairs_with.is_some() { | ||
| 701 | hint_keys(context, entry) | ||
| 702 | } else { | ||
| 703 | entry | ||
| 704 | .keys | ||
| 705 | .iter() | ||
| 706 | .map(|key| key.name()) | ||
| 707 | .collect::<Vec<_>>() | ||
| 708 | .join(" / ") | ||
| 709 | }; | ||
| 710 | (keys, entry.label.text(app).to_string()) | ||
| 711 | }) | ||
| 712 | .collect() | ||
| 713 | } | ||
| 714 | |||
| 715 | #[cfg(test)] | ||
| 716 | mod tests { | ||
| 717 | use super::*; | ||
| 718 | use crate::abbrev::Abbrev; | ||
| 719 | |||
| 720 | fn app() -> App { | ||
| 721 | App::new(Vec::new(), Vec::new(), Abbrev::minimal(), Abbrev::minimal()) | ||
| 722 | } | ||
| 723 | |||
| 724 | /// The invariant the old structure could not express. | ||
| 725 | /// | ||
| 726 | /// Two keys meaning the same thing is fine — `j` and `Down` are aliases. | ||
| 727 | /// One key meaning two things is the bug this whole table exists to make | ||
| 728 | /// impossible, and it was live: `c` was comment, Event History, and | ||
| 729 | /// silence, depending on where you stood. | ||
| 730 | #[test] | ||
| 731 | fn no_context_binds_a_key_twice() { | ||
| 732 | for context in Context::ALL { | ||
| 733 | let mut seen: Vec<(Key, Action)> = Vec::new(); | ||
| 734 | for entry in entries(context) { | ||
| 735 | for key in entry.keys { | ||
| 736 | if let Some((_, other)) = seen.iter().find(|(k, _)| k == key) { | ||
| 737 | panic!( | ||
| 738 | "{:?} binds {} twice: {:?} and {:?}", | ||
| 739 | context, | ||
| 740 | key.name(), | ||
| 741 | other, | ||
| 742 | entry.action | ||
| 743 | ); | ||
| 744 | } | ||
| 745 | seen.push((*key, entry.action)); | ||
| 746 | } | ||
| 747 | } | ||
| 748 | } | ||
| 749 | } | ||
| 750 | |||
| 751 | /// A binding in a context nothing can reach is a binding nobody can press. | ||
| 752 | /// Every context in the table has to be one `Context::of` can produce. | ||
| 753 | #[test] | ||
| 754 | fn every_context_is_reachable() { | ||
| 755 | for context in Context::ALL { | ||
| 756 | let mut app = app(); | ||
| 757 | match context { | ||
| 758 | Context::IssueList => { | ||
| 759 | app.list_mode = ListMode::Issues; | ||
| 760 | app.pane = Pane::ItemList; | ||
| 761 | } | ||
| 762 | Context::IssueDetail => { | ||
| 763 | app.list_mode = ListMode::Issues; | ||
| 764 | app.pane = Pane::Detail; | ||
| 765 | } | ||
| 766 | Context::PatchList => { | ||
| 767 | app.list_mode = ListMode::Patches; | ||
| 768 | app.pane = Pane::ItemList; | ||
| 769 | } | ||
| 770 | Context::PatchSummary => { | ||
| 771 | app.list_mode = ListMode::Patches; | ||
| 772 | app.pane = Pane::Detail; | ||
| 773 | } | ||
| 774 | Context::PatchDetail => app.mode = ViewMode::PatchDetail, | ||
| 775 | Context::EventHistory => app.mode = ViewMode::EventHistory, | ||
| 776 | Context::EventDetail => app.mode = ViewMode::EventDetail, | ||
| 777 | Context::ReviewVerdict => app.input_mode = InputMode::ReviewVerdict, | ||
| 778 | Context::Help => app.show_help = true, | ||
| 779 | } | ||
| 780 | assert_eq!( | ||
| 781 | Context::of(&app), | ||
| 782 | context, | ||
| 783 | "no state produces {:?}, so its bindings are unreachable", | ||
| 784 | context | ||
| 785 | ); | ||
| 786 | } | ||
| 787 | } | ||
| 788 | |||
| 789 | /// `ALL` is what the other tests iterate, so it has to be all of them. | ||
| 790 | #[test] | ||
| 791 | fn all_contexts_are_listed() { | ||
| 792 | for (index, context) in Context::ALL.iter().enumerate() { | ||
| 793 | assert_eq!( | ||
| 794 | context.ordinal(), | ||
| 795 | index, | ||
| 796 | "Context::ALL and Context::ordinal disagree about {:?}", | ||
| 797 | context | ||
| 798 | ); | ||
| 799 | } | ||
| 800 | } | ||
| 801 | |||
| 802 | /// No context is a room with no door. Every one of them can be left, by | ||
| 803 | /// `Esc`, by `q`, or — for the overlay — by the key that opened it. | ||
| 804 | #[test] | ||
| 805 | fn every_context_has_a_way_out() { | ||
| 806 | for context in Context::ALL { | ||
| 807 | let ways: Vec<Action> = entries(context) | ||
| 808 | .into_iter() | ||
| 809 | .map(|entry| entry.action) | ||
| 810 | .filter(|action| { | ||
| 811 | matches!( | ||
| 812 | action, | ||
| 813 | Action::Quit | Action::Back | Action::CloseHelp | Action::CancelVerdict | ||
| 814 | ) | ||
| 815 | }) | ||
| 816 | .collect(); | ||
| 817 | assert!(!ways.is_empty(), "{:?} cannot be left", context); | ||
| 818 | } | ||
| 819 | } | ||
| 820 | |||
| 821 | /// Every binding says what it does, because the overlay prints it. | ||
| 822 | #[test] | ||
| 823 | fn every_binding_is_labelled() { | ||
| 824 | let app = app(); | ||
| 825 | for context in Context::ALL { | ||
| 826 | for entry in entries(context) { | ||
| 827 | assert!( | ||
| 828 | !entry.keys.is_empty(), | ||
| 829 | "{:?} has a binding with no key", | ||
| 830 | context | ||
| 831 | ); | ||
| 832 | assert!( | ||
| 833 | !entry.label.text(&app).is_empty(), | ||
| 834 | "{:?} binds {} to nothing it can name", | ||
| 835 | context, | ||
| 836 | entry.keys[0].name() | ||
| 837 | ); | ||
| 838 | } | ||
| 839 | } | ||
| 840 | } | ||
| 841 | |||
| 842 | /// A pair's partner has to be in the same context, or the footer prints | ||
| 843 | /// half of a `j/k`. | ||
| 844 | #[test] | ||
| 845 | fn every_pair_has_its_partner() { | ||
| 846 | for context in Context::ALL { | ||
| 847 | for entry in entries(context) { | ||
| 848 | if let Some(partner) = entry.pairs_with { | ||
| 849 | assert!( | ||
| 850 | entries(context) | ||
| 851 | .into_iter() | ||
| 852 | .any(|other| other.action == partner), | ||
| 853 | "{:?} pairs {} with {:?}, which is not bound here", | ||
| 854 | context, | ||
| 855 | entry.keys[0].name(), | ||
| 856 | partner | ||
| 857 | ); | ||
| 858 | } | ||
| 859 | } | ||
| 860 | } | ||
| 861 | } | ||
| 862 | |||
| 863 | /// The footer is generated, so this is a property of the table rather than | ||
| 864 | /// of a string literal: what it prints is what the keys do. | ||
| 865 | #[test] | ||
| 866 | fn the_footer_names_the_bindings_it_has() { | ||
| 867 | let mut app = app(); | ||
| 868 | app.list_mode = ListMode::Issues; | ||
| 869 | let text = footer(&app); | ||
| 870 | assert!(text.starts_with("?:keys"), "{}", text); | ||
| 871 | assert!(text.contains("c:comment"), "{}", text); | ||
| 872 | assert!(text.contains("e:events"), "{}", text); | ||
| 873 | assert!(!text.contains("c:events"), "{}", text); | ||
| 874 | } | ||
| 875 | |||
| 876 | /// The same key, two panes, one meaning. | ||
| 877 | #[test] | ||
| 878 | fn c_means_comment_on_both_issue_contexts() { | ||
| 879 | for context in [Context::IssueList, Context::IssueDetail] { | ||
| 880 | assert_eq!(lookup(context, k('c')), Some(Action::CommentOnIssue)); | ||
| 881 | } | ||
| 882 | } | ||
| 883 | |||
| 884 | /// And nothing that is not bound comes back as if it were. | ||
| 885 | #[test] | ||
| 886 | fn an_unbound_key_looks_up_to_nothing() { | ||
| 887 | assert_eq!(lookup(Context::IssueList, k('Z')), None); | ||
| 888 | } | ||
| 889 | } | ||
src/tui/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,4 +1,5 @@ | |||
| 1 | mod events; | 1 | mod events; |
| 2 | mod keys; | ||
| 2 | mod state; | 3 | mod state; |
| 3 | mod tips; | 4 | mod tips; |
| 4 | mod widgets; | 5 | mod widgets; |
| @@ -17,8 +18,10 @@ use self::events::run_loop; | |||
| 17 | use self::state::App; | 18 | use self::state::App; |
| 18 | 19 | ||
| 19 | pub fn run(repo: &Repository) -> Result<(), Error> { | 20 | pub fn run(repo: &Repository) -> Result<(), Error> { |
| 20 | let issues = app_state::list_issues(repo)?; | 21 | // Archived included, and narrowed by the status filter on the way to the |
| 21 | let patches = app_state::list_patches(repo)?; | 22 | // screen — see the note in `App::reload`. |
| 23 | let issues = app_state::list_issues_with_archived(repo)?; | ||
| 24 | let patches = app_state::list_patches_with_archived(repo)?; | ||
| 22 | // A separate query from the two above, and that is the point: those list | 25 | // A separate query from the two above, and that is the point: those list |
| 23 | // what the dashboard can display, this counts what the ids have to be | 26 | // what the dashboard can display, this counts what the ids have to be |
| 24 | // unique against. | 27 | // unique against. |
| @@ -326,7 +329,7 @@ mod tests { | |||
| 326 | ); | 329 | ); |
| 327 | } | 330 | } |
| 328 | 331 | ||
| 329 | /// Create sample event history for testing commit browser | 332 | /// Create sample event history for the Event History panes |
| 330 | fn make_test_event_history() -> Vec<(Oid, crate::event::Event)> { | 333 | fn make_test_event_history() -> Vec<(Oid, crate::event::Event)> { |
| 331 | let oid1 = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(); | 334 | let oid1 = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(); |
| 332 | let oid2 = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(); | 335 | let oid2 = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(); |
| @@ -523,39 +526,49 @@ mod tests { | |||
| 523 | 526 | ||
| 524 | // ── handle_key tests for 'c' key ───────────────────────────────────── | 527 | // ── handle_key tests for 'c' key ───────────────────────────────────── |
| 525 | 528 | ||
| 526 | #[test] | 529 | /// The Event History is on `e` now, and on both issue panes rather than |
| 527 | fn test_handle_key_c_in_detail_pane_returns_open_commit_browser() { | 530 | /// only the one with focus. |
| 528 | let mut app = make_app(3, 0); | 531 | /// |
| 529 | app.pane = Pane::Detail; | 532 | /// This test used to assert `c`. It was not wrong about the code; it was |
| 530 | app.list_state.select(Some(0)); | 533 | /// wrong about what the code should do, which is why it passed for as long |
| 531 | let result = app.handle_key( | 534 | /// as it did — `c` had already become the comment key on the patch pane, |
| 532 | crossterm::event::KeyCode::Char('c'), | 535 | /// and nothing here could see that. |
| 533 | crossterm::event::KeyModifiers::empty(), | 536 | #[test] |
| 534 | ); | 537 | fn test_handle_key_e_opens_the_event_history() { |
| 535 | assert_eq!(result, KeyAction::OpenCommitBrowser); | 538 | for pane in [Pane::ItemList, Pane::Detail] { |
| 536 | } | 539 | let mut app = make_app(3, 0); |
| 537 | 540 | app.pane = pane; | |
| 538 | #[test] | 541 | app.list_state.select(Some(0)); |
| 539 | fn test_handle_key_c_in_item_list_pane_is_noop() { | 542 | let result = app.handle_key( |
| 540 | let mut app = make_app(3, 0); | 543 | crossterm::event::KeyCode::Char('e'), |
| 541 | app.pane = Pane::ItemList; | 544 | crossterm::event::KeyModifiers::empty(), |
| 542 | app.list_state.select(Some(0)); | 545 | ); |
| 543 | let result = app.handle_key( | 546 | assert_eq!(result, KeyAction::OpenEventHistory); |
| 544 | crossterm::event::KeyCode::Char('c'), | 547 | } |
| 545 | crossterm::event::KeyModifiers::empty(), | ||
| 546 | ); | ||
| 547 | assert_eq!(result, KeyAction::Continue); | ||
| 548 | } | 548 | } |
| 549 | 549 | ||
| 550 | /// With no issue under the cursor there is nothing to comment on or to | ||
| 551 | /// show the history of — but the reader is told that, rather than left to | ||
| 552 | /// guess whether the key exists. | ||
| 550 | #[test] | 553 | #[test] |
| 551 | fn test_handle_key_c_no_selection_is_noop() { | 554 | fn test_no_selection_says_there_is_no_issue() { |
| 552 | let mut app = make_app(0, 0); | 555 | for key in ['c', 'e', 'C'] { |
| 553 | app.pane = Pane::Detail; | 556 | let mut app = make_app(0, 0); |
| 554 | let result = app.handle_key( | 557 | app.pane = Pane::Detail; |
| 555 | crossterm::event::KeyCode::Char('c'), | 558 | let result = app.handle_key( |
| 556 | crossterm::event::KeyModifiers::empty(), | 559 | crossterm::event::KeyCode::Char(key), |
| 557 | ); | 560 | crossterm::event::KeyModifiers::empty(), |
| 558 | assert_eq!(result, KeyAction::Continue); | 561 | ); |
| 562 | assert_eq!(result, KeyAction::Continue); | ||
| 563 | assert!( | ||
| 564 | app.status_msg | ||
| 565 | .as_deref() | ||
| 566 | .is_some_and(|m| m.contains("No issue")), | ||
| 567 | "`{}` with nothing selected said {:?}", | ||
| 568 | key, | ||
| 569 | app.status_msg | ||
| 570 | ); | ||
| 571 | } | ||
| 559 | } | 572 | } |
| 560 | 573 | ||
| 561 | #[test] | 574 | #[test] |
| @@ -568,14 +581,14 @@ mod tests { | |||
| 568 | assert_eq!(result, KeyAction::Quit); | 581 | assert_eq!(result, KeyAction::Quit); |
| 569 | } | 582 | } |
| 570 | 583 | ||
| 571 | // ── CommitList navigation tests ────────────────────────────────────── | 584 | // ── Event history navigation tests ────────────────────────────────────── |
| 572 | 585 | ||
| 573 | #[test] | 586 | #[test] |
| 574 | fn test_commit_list_navigate_down() { | 587 | fn test_commit_list_navigate_down() { |
| 575 | let mut app = make_app(3, 0); | 588 | let mut app = make_app(3, 0); |
| 576 | app.event_history = make_test_event_history(); | 589 | app.event_history = make_test_event_history(); |
| 577 | app.event_list_state.select(Some(0)); | 590 | app.event_list_state.select(Some(0)); |
| 578 | app.mode = ViewMode::CommitList; | 591 | app.mode = ViewMode::EventHistory; |
| 579 | 592 | ||
| 580 | app.handle_key( | 593 | app.handle_key( |
| 581 | crossterm::event::KeyCode::Char('j'), | 594 | crossterm::event::KeyCode::Char('j'), |
| @@ -589,7 +602,7 @@ mod tests { | |||
| 589 | let mut app = make_app(3, 0); | 602 | let mut app = make_app(3, 0); |
| 590 | app.event_history = make_test_event_history(); | 603 | app.event_history = make_test_event_history(); |
| 591 | app.event_list_state.select(Some(2)); | 604 | app.event_list_state.select(Some(2)); |
| 592 | app.mode = ViewMode::CommitList; | 605 | app.mode = ViewMode::EventHistory; |
| 593 | 606 | ||
| 594 | app.handle_key( | 607 | app.handle_key( |
| 595 | crossterm::event::KeyCode::Char('k'), | 608 | crossterm::event::KeyCode::Char('k'), |
| @@ -603,7 +616,7 @@ mod tests { | |||
| 603 | let mut app = make_app(3, 0); | 616 | let mut app = make_app(3, 0); |
| 604 | app.event_history = make_test_event_history(); | 617 | app.event_history = make_test_event_history(); |
| 605 | app.event_list_state.select(Some(2)); | 618 | app.event_list_state.select(Some(2)); |
| 606 | app.mode = ViewMode::CommitList; | 619 | app.mode = ViewMode::EventHistory; |
| 607 | 620 | ||
| 608 | app.handle_key( | 621 | app.handle_key( |
| 609 | crossterm::event::KeyCode::Down, | 622 | crossterm::event::KeyCode::Down, |
| @@ -617,7 +630,7 @@ mod tests { | |||
| 617 | let mut app = make_app(3, 0); | 630 | let mut app = make_app(3, 0); |
| 618 | app.event_history = make_test_event_history(); | 631 | app.event_history = make_test_event_history(); |
| 619 | app.event_list_state.select(Some(0)); | 632 | app.event_list_state.select(Some(0)); |
| 620 | app.mode = ViewMode::CommitList; | 633 | app.mode = ViewMode::EventHistory; |
| 621 | 634 | ||
| 622 | app.handle_key( | 635 | app.handle_key( |
| 623 | crossterm::event::KeyCode::Up, | 636 | crossterm::event::KeyCode::Up, |
| @@ -631,7 +644,7 @@ mod tests { | |||
| 631 | let mut app = make_app(3, 0); | 644 | let mut app = make_app(3, 0); |
| 632 | app.event_history = make_test_event_history(); | 645 | app.event_history = make_test_event_history(); |
| 633 | app.event_list_state.select(Some(1)); | 646 | app.event_list_state.select(Some(1)); |
| 634 | app.mode = ViewMode::CommitList; | 647 | app.mode = ViewMode::EventHistory; |
| 635 | 648 | ||
| 636 | let result = app.handle_key( | 649 | let result = app.handle_key( |
| 637 | crossterm::event::KeyCode::Esc, | 650 | crossterm::event::KeyCode::Esc, |
| @@ -646,7 +659,7 @@ mod tests { | |||
| 646 | #[test] | 659 | #[test] |
| 647 | fn test_commit_list_q_quits() { | 660 | fn test_commit_list_q_quits() { |
| 648 | let mut app = make_app(3, 0); | 661 | let mut app = make_app(3, 0); |
| 649 | app.mode = ViewMode::CommitList; | 662 | app.mode = ViewMode::EventHistory; |
| 650 | let result = app.handle_key( | 663 | let result = app.handle_key( |
| 651 | crossterm::event::KeyCode::Char('q'), | 664 | crossterm::event::KeyCode::Char('q'), |
| 652 | crossterm::event::KeyModifiers::empty(), | 665 | crossterm::event::KeyModifiers::empty(), |
| @@ -654,21 +667,21 @@ mod tests { | |||
| 654 | assert_eq!(result, KeyAction::Quit); | 667 | assert_eq!(result, KeyAction::Quit); |
| 655 | } | 668 | } |
| 656 | 669 | ||
| 657 | // ── CommitDetail tests ─────────────────────────────────────────────── | 670 | // ── Event detail tests ─────────────────────────────────────────────── |
| 658 | 671 | ||
| 659 | #[test] | 672 | #[test] |
| 660 | fn test_commit_list_enter_opens_detail() { | 673 | fn test_commit_list_enter_opens_detail() { |
| 661 | let mut app = make_app(3, 0); | 674 | let mut app = make_app(3, 0); |
| 662 | app.event_history = make_test_event_history(); | 675 | app.event_history = make_test_event_history(); |
| 663 | app.event_list_state.select(Some(1)); | 676 | app.event_list_state.select(Some(1)); |
| 664 | app.mode = ViewMode::CommitList; | 677 | app.mode = ViewMode::EventHistory; |
| 665 | 678 | ||
| 666 | let result = app.handle_key( | 679 | let result = app.handle_key( |
| 667 | crossterm::event::KeyCode::Enter, | 680 | crossterm::event::KeyCode::Enter, |
| 668 | crossterm::event::KeyModifiers::empty(), | 681 | crossterm::event::KeyModifiers::empty(), |
| 669 | ); | 682 | ); |
| 670 | assert_eq!(result, KeyAction::Continue); | 683 | assert_eq!(result, KeyAction::Continue); |
| 671 | assert_eq!(app.mode, ViewMode::CommitDetail); | 684 | assert_eq!(app.mode, ViewMode::EventDetail); |
| 672 | assert_eq!(app.scroll, 0); | 685 | assert_eq!(app.scroll, 0); |
| 673 | } | 686 | } |
| 674 | 687 | ||
| @@ -677,13 +690,13 @@ mod tests { | |||
| 677 | let mut app = make_app(3, 0); | 690 | let mut app = make_app(3, 0); |
| 678 | app.event_history = make_test_event_history(); | 691 | app.event_history = make_test_event_history(); |
| 679 | app.event_list_state = ListState::default(); | 692 | app.event_list_state = ListState::default(); |
| 680 | app.mode = ViewMode::CommitList; | 693 | app.mode = ViewMode::EventHistory; |
| 681 | 694 | ||
| 682 | app.handle_key( | 695 | app.handle_key( |
| 683 | crossterm::event::KeyCode::Enter, | 696 | crossterm::event::KeyCode::Enter, |
| 684 | crossterm::event::KeyModifiers::empty(), | 697 | crossterm::event::KeyModifiers::empty(), |
| 685 | ); | 698 | ); |
| 686 | assert_eq!(app.mode, ViewMode::CommitList); | 699 | assert_eq!(app.mode, ViewMode::EventHistory); |
| 687 | } | 700 | } |
| 688 | 701 | ||
| 689 | #[test] | 702 | #[test] |
| @@ -691,7 +704,7 @@ mod tests { | |||
| 691 | let mut app = make_app(3, 0); | 704 | let mut app = make_app(3, 0); |
| 692 | app.event_history = make_test_event_history(); | 705 | app.event_history = make_test_event_history(); |
| 693 | app.event_list_state.select(Some(0)); | 706 | app.event_list_state.select(Some(0)); |
| 694 | app.mode = ViewMode::CommitDetail; | 707 | app.mode = ViewMode::EventDetail; |
| 695 | app.scroll = 5; | 708 | app.scroll = 5; |
| 696 | 709 | ||
| 697 | let result = app.handle_key( | 710 | let result = app.handle_key( |
| @@ -699,7 +712,7 @@ mod tests { | |||
| 699 | crossterm::event::KeyModifiers::empty(), | 712 | crossterm::event::KeyModifiers::empty(), |
| 700 | ); | 713 | ); |
| 701 | assert_eq!(result, KeyAction::Continue); | 714 | assert_eq!(result, KeyAction::Continue); |
| 702 | assert_eq!(app.mode, ViewMode::CommitList); | 715 | assert_eq!(app.mode, ViewMode::EventHistory); |
| 703 | assert_eq!(app.scroll, 0); | 716 | assert_eq!(app.scroll, 0); |
| 704 | assert_eq!(app.event_history.len(), 3); | 717 | assert_eq!(app.event_history.len(), 3); |
| 705 | } | 718 | } |
| @@ -707,7 +720,7 @@ mod tests { | |||
| 707 | #[test] | 720 | #[test] |
| 708 | fn test_commit_detail_scroll() { | 721 | fn test_commit_detail_scroll() { |
| 709 | let mut app = make_app(3, 0); | 722 | let mut app = make_app(3, 0); |
| 710 | app.mode = ViewMode::CommitDetail; | 723 | app.mode = ViewMode::EventDetail; |
| 711 | app.scroll = 0; | 724 | app.scroll = 0; |
| 712 | 725 | ||
| 713 | app.handle_key( | 726 | app.handle_key( |
| @@ -730,7 +743,7 @@ mod tests { | |||
| 730 | #[test] | 743 | #[test] |
| 731 | fn test_commit_detail_page_scroll() { | 744 | fn test_commit_detail_page_scroll() { |
| 732 | let mut app = make_app(3, 0); | 745 | let mut app = make_app(3, 0); |
| 733 | app.mode = ViewMode::CommitDetail; | 746 | app.mode = ViewMode::EventDetail; |
| 734 | app.scroll = 0; | 747 | app.scroll = 0; |
| 735 | 748 | ||
| 736 | app.handle_key( | 749 | app.handle_key( |
| @@ -748,7 +761,7 @@ mod tests { | |||
| 748 | #[test] | 761 | #[test] |
| 749 | fn test_commit_detail_q_quits() { | 762 | fn test_commit_detail_q_quits() { |
| 750 | let mut app = make_app(3, 0); | 763 | let mut app = make_app(3, 0); |
| 751 | app.mode = ViewMode::CommitDetail; | 764 | app.mode = ViewMode::EventDetail; |
| 752 | let result = app.handle_key( | 765 | let result = app.handle_key( |
| 753 | crossterm::event::KeyCode::Char('q'), | 766 | crossterm::event::KeyCode::Char('q'), |
| 754 | crossterm::event::KeyModifiers::empty(), | 767 | crossterm::event::KeyModifiers::empty(), |
| @@ -761,25 +774,25 @@ mod tests { | |||
| 761 | #[test] | 774 | #[test] |
| 762 | fn test_c_ignored_in_commit_list_mode() { | 775 | fn test_c_ignored_in_commit_list_mode() { |
| 763 | let mut app = make_app(3, 0); | 776 | let mut app = make_app(3, 0); |
| 764 | app.mode = ViewMode::CommitList; | 777 | app.mode = ViewMode::EventHistory; |
| 765 | let result = app.handle_key( | 778 | let result = app.handle_key( |
| 766 | crossterm::event::KeyCode::Char('c'), | 779 | crossterm::event::KeyCode::Char('c'), |
| 767 | crossterm::event::KeyModifiers::empty(), | 780 | crossterm::event::KeyModifiers::empty(), |
| 768 | ); | 781 | ); |
| 769 | assert_eq!(result, KeyAction::Continue); | 782 | assert_eq!(result, KeyAction::Continue); |
| 770 | assert_eq!(app.mode, ViewMode::CommitList); | 783 | assert_eq!(app.mode, ViewMode::EventHistory); |
| 771 | } | 784 | } |
| 772 | 785 | ||
| 773 | #[test] | 786 | #[test] |
| 774 | fn test_c_ignored_in_commit_detail_mode() { | 787 | fn test_c_ignored_in_commit_detail_mode() { |
| 775 | let mut app = make_app(3, 0); | 788 | let mut app = make_app(3, 0); |
| 776 | app.mode = ViewMode::CommitDetail; | 789 | app.mode = ViewMode::EventDetail; |
| 777 | let result = app.handle_key( | 790 | let result = app.handle_key( |
| 778 | crossterm::event::KeyCode::Char('c'), | 791 | crossterm::event::KeyCode::Char('c'), |
| 779 | crossterm::event::KeyModifiers::empty(), | 792 | crossterm::event::KeyModifiers::empty(), |
| 780 | ); | 793 | ); |
| 781 | assert_eq!(result, KeyAction::Continue); | 794 | assert_eq!(result, KeyAction::Continue); |
| 782 | assert_eq!(app.mode, ViewMode::CommitDetail); | 795 | assert_eq!(app.mode, ViewMode::EventDetail); |
| 783 | } | 796 | } |
| 784 | 797 | ||
| 785 | // ── handle_key basic tests ─────────────────────────────────────────── | 798 | // ── handle_key basic tests ─────────────────────────────────────────── |
| @@ -973,7 +986,8 @@ mod tests { | |||
| 973 | let buf = render_app(&mut app); | 986 | let buf = render_app(&mut app); |
| 974 | assert_buffer_contains(&buf, "j/k:navigate"); | 987 | assert_buffer_contains(&buf, "j/k:navigate"); |
| 975 | assert_buffer_contains(&buf, "Tab:pane"); | 988 | assert_buffer_contains(&buf, "Tab:pane"); |
| 976 | assert_buffer_contains(&buf, "c:events"); | 989 | assert_buffer_contains(&buf, "e:events"); |
| 990 | assert_buffer_contains(&buf, "c:comment"); | ||
| 977 | } | 991 | } |
| 978 | 992 | ||
| 979 | #[test] | 993 | #[test] |
| @@ -981,7 +995,7 @@ mod tests { | |||
| 981 | let mut app = make_app(3, 0); | 995 | let mut app = make_app(3, 0); |
| 982 | app.event_history = make_test_event_history(); | 996 | app.event_history = make_test_event_history(); |
| 983 | app.event_list_state.select(Some(0)); | 997 | app.event_list_state.select(Some(0)); |
| 984 | app.mode = ViewMode::CommitList; | 998 | app.mode = ViewMode::EventHistory; |
| 985 | let buf = render_app(&mut app); | 999 | let buf = render_app(&mut app); |
| 986 | assert_buffer_contains(&buf, "Event History"); | 1000 | assert_buffer_contains(&buf, "Event History"); |
| 987 | assert_buffer_contains(&buf, "Issue Open"); | 1001 | assert_buffer_contains(&buf, "Issue Open"); |
| @@ -994,7 +1008,7 @@ mod tests { | |||
| 994 | let mut app = make_app(3, 0); | 1008 | let mut app = make_app(3, 0); |
| 995 | app.event_history = make_test_event_history(); | 1009 | app.event_history = make_test_event_history(); |
| 996 | app.event_list_state.select(Some(0)); | 1010 | app.event_list_state.select(Some(0)); |
| 997 | app.mode = ViewMode::CommitDetail; | 1011 | app.mode = ViewMode::EventDetail; |
| 998 | let buf = render_app(&mut app); | 1012 | let buf = render_app(&mut app); |
| 999 | assert_buffer_contains(&buf, "Event Detail"); | 1013 | assert_buffer_contains(&buf, "Event Detail"); |
| 1000 | assert_buffer_contains(&buf, "aaaaaaa"); | 1014 | assert_buffer_contains(&buf, "aaaaaaa"); |
| @@ -1005,7 +1019,7 @@ mod tests { | |||
| 1005 | #[test] | 1019 | #[test] |
| 1006 | fn test_render_commit_list_footer() { | 1020 | fn test_render_commit_list_footer() { |
| 1007 | let mut app = make_app(3, 0); | 1021 | let mut app = make_app(3, 0); |
| 1008 | app.mode = ViewMode::CommitList; | 1022 | app.mode = ViewMode::EventHistory; |
| 1009 | let buf = render_app(&mut app); | 1023 | let buf = render_app(&mut app); |
| 1010 | assert_buffer_contains(&buf, "Esc:back"); | 1024 | assert_buffer_contains(&buf, "Esc:back"); |
| 1011 | } | 1025 | } |
| @@ -1013,7 +1027,7 @@ mod tests { | |||
| 1013 | #[test] | 1027 | #[test] |
| 1014 | fn test_render_commit_detail_footer() { | 1028 | fn test_render_commit_detail_footer() { |
| 1015 | let mut app = make_app(3, 0); | 1029 | let mut app = make_app(3, 0); |
| 1016 | app.mode = ViewMode::CommitDetail; | 1030 | app.mode = ViewMode::EventDetail; |
| 1017 | let buf = render_app(&mut app); | 1031 | let buf = render_app(&mut app); |
| 1018 | assert_buffer_contains(&buf, "Esc:back"); | 1032 | assert_buffer_contains(&buf, "Esc:back"); |
| 1019 | assert_buffer_contains(&buf, "j/k:scroll"); | 1033 | assert_buffer_contains(&buf, "j/k:scroll"); |
| @@ -1036,14 +1050,14 @@ mod tests { | |||
| 1036 | app.list_state.select(Some(0)); | 1050 | app.list_state.select(Some(0)); |
| 1037 | 1051 | ||
| 1038 | let action = app.handle_key( | 1052 | let action = app.handle_key( |
| 1039 | crossterm::event::KeyCode::Char('c'), | 1053 | crossterm::event::KeyCode::Char('e'), |
| 1040 | crossterm::event::KeyModifiers::empty(), | 1054 | crossterm::event::KeyModifiers::empty(), |
| 1041 | ); | 1055 | ); |
| 1042 | assert_eq!(action, KeyAction::OpenCommitBrowser); | 1056 | assert_eq!(action, KeyAction::OpenEventHistory); |
| 1043 | 1057 | ||
| 1044 | app.event_history = make_test_event_history(); | 1058 | app.event_history = make_test_event_history(); |
| 1045 | app.event_list_state.select(Some(0)); | 1059 | app.event_list_state.select(Some(0)); |
| 1046 | app.mode = ViewMode::CommitList; | 1060 | app.mode = ViewMode::EventHistory; |
| 1047 | app.scroll = 0; | 1061 | app.scroll = 0; |
| 1048 | 1062 | ||
| 1049 | app.handle_key( | 1063 | app.handle_key( |
| @@ -1056,7 +1070,7 @@ mod tests { | |||
| 1056 | crossterm::event::KeyCode::Enter, | 1070 | crossterm::event::KeyCode::Enter, |
| 1057 | crossterm::event::KeyModifiers::empty(), | 1071 | crossterm::event::KeyModifiers::empty(), |
| 1058 | ); | 1072 | ); |
| 1059 | assert_eq!(app.mode, ViewMode::CommitDetail); | 1073 | assert_eq!(app.mode, ViewMode::EventDetail); |
| 1060 | 1074 | ||
| 1061 | app.handle_key( | 1075 | app.handle_key( |
| 1062 | crossterm::event::KeyCode::Char('j'), | 1076 | crossterm::event::KeyCode::Char('j'), |
| @@ -1068,7 +1082,7 @@ mod tests { | |||
| 1068 | crossterm::event::KeyCode::Esc, | 1082 | crossterm::event::KeyCode::Esc, |
| 1069 | crossterm::event::KeyModifiers::empty(), | 1083 | crossterm::event::KeyModifiers::empty(), |
| 1070 | ); | 1084 | ); |
| 1071 | assert_eq!(app.mode, ViewMode::CommitList); | 1085 | assert_eq!(app.mode, ViewMode::EventHistory); |
| 1072 | assert_eq!(app.scroll, 0); | 1086 | assert_eq!(app.scroll, 0); |
| 1073 | 1087 | ||
| 1074 | app.handle_key( | 1088 | app.handle_key( |
| @@ -1192,18 +1206,24 @@ mod tests { | |||
| 1192 | assert_eq!(result, KeyAction::Continue); | 1206 | assert_eq!(result, KeyAction::Continue); |
| 1193 | } | 1207 | } |
| 1194 | 1208 | ||
| 1209 | /// `p` opens the linked patch from either issue pane, for the same reason | ||
| 1210 | /// `c` comments from either: the issue under the cursor does not change | ||
| 1211 | /// when focus moves to the pane beside the list. This test asserted the | ||
| 1212 | /// opposite, which is the `pane == Pane::Detail` guard written down twice. | ||
| 1195 | #[test] | 1213 | #[test] |
| 1196 | fn test_p_key_noop_in_item_list_pane() { | 1214 | fn test_p_opens_the_linked_patch_from_either_pane() { |
| 1197 | let mut app = test_app(); | 1215 | for pane in [Pane::ItemList, Pane::Detail] { |
| 1198 | app.patches[0].fixes = Some("i1".into()); | 1216 | let mut app = test_app(); |
| 1199 | app.pane = Pane::ItemList; | 1217 | app.patches[0].fixes = Some("i1".into()); |
| 1200 | app.list_state.select(Some(0)); | 1218 | app.pane = pane; |
| 1219 | app.list_state.select(Some(0)); | ||
| 1201 | 1220 | ||
| 1202 | let result = app.handle_key( | 1221 | let result = app.handle_key( |
| 1203 | crossterm::event::KeyCode::Char('p'), | 1222 | crossterm::event::KeyCode::Char('p'), |
| 1204 | crossterm::event::KeyModifiers::empty(), | 1223 | crossterm::event::KeyModifiers::empty(), |
| 1205 | ); | 1224 | ); |
| 1206 | assert_eq!(result, KeyAction::Continue); | 1225 | assert_eq!(result, KeyAction::OpenPatchDetail); |
| 1226 | } | ||
| 1207 | } | 1227 | } |
| 1208 | 1228 | ||
| 1209 | #[test] | 1229 | #[test] |
| @@ -2265,12 +2285,12 @@ mod tests { | |||
| 2265 | app.event_history = make_test_event_history(); | 2285 | app.event_history = make_test_event_history(); |
| 2266 | app.event_list_state.select(Some(0)); | 2286 | app.event_list_state.select(Some(0)); |
| 2267 | 2287 | ||
| 2268 | app.mode = ViewMode::CommitList; | 2288 | app.mode = ViewMode::EventHistory; |
| 2269 | let buf = render_app_sized(&mut app, 120, 24); | 2289 | let buf = render_app_sized(&mut app, 120, 24); |
| 2270 | assert_buffer_contains(&buf, "2026-01-01 00:00"); | 2290 | assert_buffer_contains(&buf, "2026-01-01 00:00"); |
| 2271 | assert_buffer_lacks(&buf, "2026-01-01T00:00:00Z"); | 2291 | assert_buffer_lacks(&buf, "2026-01-01T00:00:00Z"); |
| 2272 | 2292 | ||
| 2273 | app.mode = ViewMode::CommitDetail; | 2293 | app.mode = ViewMode::EventDetail; |
| 2274 | let buf = render_app_sized(&mut app, 120, 24); | 2294 | let buf = render_app_sized(&mut app, 120, 24); |
| 2275 | assert_buffer_contains(&buf, "2026-01-01 00:00"); | 2295 | assert_buffer_contains(&buf, "2026-01-01 00:00"); |
| 2276 | assert_buffer_lacks(&buf, "2026-01-01T00:00:00Z"); | 2296 | assert_buffer_lacks(&buf, "2026-01-01T00:00:00Z"); |
| @@ -2514,4 +2534,199 @@ mod tests { | |||
| 2514 | text | 2534 | text |
| 2515 | ); | 2535 | ); |
| 2516 | } | 2536 | } |
| 2537 | |||
| 2538 | // ── One key, one meaning (02d3eb34 / d7158619) ─────────────────────── | ||
| 2539 | |||
| 2540 | /// A key that means nothing here has to say so. | ||
| 2541 | /// | ||
| 2542 | /// The failure this replaces is not "the key did nothing" — it is that the | ||
| 2543 | /// reader cannot tell "nothing is bound" from "the binding is broken". | ||
| 2544 | /// `c` from the issue list pane was the live instance: no message, no | ||
| 2545 | /// beep, no hint. | ||
| 2546 | #[test] | ||
| 2547 | fn an_unbound_key_says_so() { | ||
| 2548 | let mut app = make_app(3, 0); | ||
| 2549 | app.handle_key( | ||
| 2550 | crossterm::event::KeyCode::Char('z'), | ||
| 2551 | crossterm::event::KeyModifiers::empty(), | ||
| 2552 | ); | ||
| 2553 | let msg = app.status_msg.clone().unwrap_or_default(); | ||
| 2554 | assert!( | ||
| 2555 | msg.contains('z') && msg.contains('?'), | ||
| 2556 | "an unbound key must name itself and point at the help key, got {:?}", | ||
| 2557 | app.status_msg | ||
| 2558 | ); | ||
| 2559 | } | ||
| 2560 | |||
| 2561 | /// `c` is the review surface's comment key. It cannot also be the key that | ||
| 2562 | /// opens the Event History one pane over. | ||
| 2563 | #[test] | ||
| 2564 | fn c_on_an_issue_does_not_open_the_event_history() { | ||
| 2565 | let mut app = make_app(3, 0); | ||
| 2566 | app.pane = Pane::Detail; | ||
| 2567 | app.list_state.select(Some(0)); | ||
| 2568 | let result = app.handle_key( | ||
| 2569 | crossterm::event::KeyCode::Char('c'), | ||
| 2570 | crossterm::event::KeyModifiers::empty(), | ||
| 2571 | ); | ||
| 2572 | assert_ne!( | ||
| 2573 | result, | ||
| 2574 | KeyAction::OpenEventHistory, | ||
| 2575 | "`c` must mean comment everywhere it is bound" | ||
| 2576 | ); | ||
| 2577 | } | ||
| 2578 | |||
| 2579 | /// The same key, from the list pane, where it used to fall through the | ||
| 2580 | /// `pane == Detail` guard into silence. The issue under the cursor is the | ||
| 2581 | /// same issue whichever pane has focus, so the binding is the same too. | ||
| 2582 | #[test] | ||
| 2583 | fn c_comments_on_the_issue_from_either_pane() { | ||
| 2584 | for pane in [Pane::ItemList, Pane::Detail] { | ||
| 2585 | let mut app = make_app(3, 0); | ||
| 2586 | app.pane = pane; | ||
| 2587 | app.list_state.select(Some(0)); | ||
| 2588 | let result = app.handle_key( | ||
| 2589 | crossterm::event::KeyCode::Char('c'), | ||
| 2590 | crossterm::event::KeyModifiers::empty(), | ||
| 2591 | ); | ||
| 2592 | assert_eq!(result, KeyAction::CommentOnIssue); | ||
| 2593 | } | ||
| 2594 | } | ||
| 2595 | |||
| 2596 | /// The footer is generated, so the key that opens the help overlay is in | ||
| 2597 | /// it by construction rather than by anyone remembering. | ||
| 2598 | #[test] | ||
| 2599 | fn the_footer_offers_the_help_key() { | ||
| 2600 | let mut app = make_app(3, 0); | ||
| 2601 | let buf = render_app_sized(&mut app, 200, 24); | ||
| 2602 | assert_buffer_contains(&buf, "?:keys"); | ||
| 2603 | } | ||
| 2604 | |||
| 2605 | /// And it no longer advertises `c` as the way to the Event History. | ||
| 2606 | #[test] | ||
| 2607 | fn the_footer_does_not_still_call_c_the_event_key() { | ||
| 2608 | let mut app = make_app(3, 0); | ||
| 2609 | let buf = render_app_sized(&mut app, 200, 24); | ||
| 2610 | let text = buffer_to_string(&buf); | ||
| 2611 | assert!( | ||
| 2612 | !text.contains("c:events"), | ||
| 2613 | "the footer still names the old binding:\n{}", | ||
| 2614 | text | ||
| 2615 | ); | ||
| 2616 | } | ||
| 2617 | |||
| 2618 | /// `?` lists the current pane's keys — including the ones the footer has | ||
| 2619 | /// no room for. That is the point of it: this surface grew `o`, `w`, | ||
| 2620 | /// `Ctrl-E` and the rest without anywhere to advertise them. | ||
| 2621 | #[test] | ||
| 2622 | fn question_mark_lists_the_keys_the_footer_cannot_fit() { | ||
| 2623 | let mut app = make_app(3, 0); | ||
| 2624 | app.mode = ViewMode::PatchDetail; | ||
| 2625 | |||
| 2626 | let footer_only = buffer_to_string(&render_app(&mut app)); | ||
| 2627 | assert!( | ||
| 2628 | !footer_only.contains("Ctrl-e"), | ||
| 2629 | "eighty columns should not have fitted Ctrl-e:\n{}", | ||
| 2630 | footer_only | ||
| 2631 | ); | ||
| 2632 | |||
| 2633 | app.handle_key( | ||
| 2634 | crossterm::event::KeyCode::Char('?'), | ||
| 2635 | crossterm::event::KeyModifiers::empty(), | ||
| 2636 | ); | ||
| 2637 | let text = buffer_to_string(&render_app(&mut app)); | ||
| 2638 | assert!(text.contains("Ctrl-e"), "no Ctrl-e in the overlay:\n{}", text); | ||
| 2639 | assert!( | ||
| 2640 | text.contains("Patch detail"), | ||
| 2641 | "the overlay does not say which pane it is about:\n{}", | ||
| 2642 | text | ||
| 2643 | ); | ||
| 2644 | } | ||
| 2645 | |||
| 2646 | /// The overlay is about the pane underneath it, not about itself. | ||
| 2647 | #[test] | ||
| 2648 | fn the_overlay_lists_the_pane_it_covers() { | ||
| 2649 | let mut app = make_app(3, 0); | ||
| 2650 | app.show_help = true; | ||
| 2651 | let text = buffer_to_string(&render_app_sized(&mut app, 100, 30)); | ||
| 2652 | assert!(text.contains("check out"), "{}", text); | ||
| 2653 | assert!(text.contains("Issues"), "{}", text); | ||
| 2654 | } | ||
| 2655 | |||
| 2656 | /// And it closes again, on `?`, on `Esc`, and on anything else — an | ||
| 2657 | /// overlay covering what the reader wants is one that should go away. | ||
| 2658 | #[test] | ||
| 2659 | fn the_overlay_closes_on_any_key() { | ||
| 2660 | for key in ['?', 'x', 'j'] { | ||
| 2661 | let mut app = make_app(3, 0); | ||
| 2662 | app.show_help = true; | ||
| 2663 | app.handle_key( | ||
| 2664 | crossterm::event::KeyCode::Char(key), | ||
| 2665 | crossterm::event::KeyModifiers::empty(), | ||
| 2666 | ); | ||
| 2667 | assert!(!app.show_help, "`{}` left the overlay up", key); | ||
| 2668 | } | ||
| 2669 | let mut app = make_app(3, 0); | ||
| 2670 | app.show_help = true; | ||
| 2671 | app.handle_key( | ||
| 2672 | crossterm::event::KeyCode::Esc, | ||
| 2673 | crossterm::event::KeyModifiers::empty(), | ||
| 2674 | ); | ||
| 2675 | assert!(!app.show_help); | ||
| 2676 | } | ||
| 2677 | |||
| 2678 | /// A key that is not a verdict abandons the prompt and says so, rather | ||
| 2679 | /// than cancelling it silently and leaving the reader wondering whether | ||
| 2680 | /// `R` worked. | ||
| 2681 | #[test] | ||
| 2682 | fn a_non_verdict_key_abandons_the_prompt_out_loud() { | ||
| 2683 | let mut app = make_app(3, 0); | ||
| 2684 | app.input_mode = InputMode::ReviewVerdict; | ||
| 2685 | app.handle_key( | ||
| 2686 | crossterm::event::KeyCode::Char('z'), | ||
| 2687 | crossterm::event::KeyModifiers::empty(), | ||
| 2688 | ); | ||
| 2689 | assert_eq!(app.input_mode, InputMode::Normal); | ||
| 2690 | assert!( | ||
| 2691 | app.status_msg | ||
| 2692 | .as_deref() | ||
| 2693 | .is_some_and(|m| m.contains("abandoned")), | ||
| 2694 | "{:?}", | ||
| 2695 | app.status_msg | ||
| 2696 | ); | ||
| 2697 | } | ||
| 2698 | |||
| 2699 | /// The verdict prompt's own footer is generated from the same table, so | ||
| 2700 | /// it cannot come to name a key the prompt does not take. | ||
| 2701 | #[test] | ||
| 2702 | fn the_verdict_prompt_footer_is_generated() { | ||
| 2703 | let mut app = make_app(3, 0); | ||
| 2704 | app.input_mode = InputMode::ReviewVerdict; | ||
| 2705 | let buf = render_app(&mut app); | ||
| 2706 | assert_buffer_contains(&buf, "Review verdict:"); | ||
| 2707 | assert_buffer_contains(&buf, "a:approve"); | ||
| 2708 | assert_buffer_contains(&buf, "r:request-changes"); | ||
| 2709 | assert_buffer_contains(&buf, "Esc:cancel"); | ||
| 2710 | } | ||
| 2711 | |||
| 2712 | /// `u` is a patch-list key. Pressed on the issue list it is unbound, and | ||
| 2713 | /// being unbound now means being told — the property that separates "this | ||
| 2714 | /// pane has no such filter" from "the filter is broken". | ||
| 2715 | #[test] | ||
| 2716 | fn u_on_the_issue_pane_says_it_does_nothing_there() { | ||
| 2717 | let mut app = make_app(3, 0); | ||
| 2718 | app.list_mode = ListMode::Issues; | ||
| 2719 | app.handle_key( | ||
| 2720 | crossterm::event::KeyCode::Char('u'), | ||
| 2721 | crossterm::event::KeyModifiers::empty(), | ||
| 2722 | ); | ||
| 2723 | assert!(!app.unresolved_only); | ||
| 2724 | assert!( | ||
| 2725 | app.status_msg | ||
| 2726 | .as_deref() | ||
| 2727 | .is_some_and(|m| m.contains("does nothing")), | ||
| 2728 | "{:?}", | ||
| 2729 | app.status_msg | ||
| 2730 | ); | ||
| 2731 | } | ||
| 2517 | } | 2732 | } |
src/tui/state.rs
| Old | New | ||
|---|---|---|---|
| @@ -4,8 +4,10 @@ use git2::{Oid, Repository}; | |||
| 4 | use ratatui::widgets::ListState; | 4 | use ratatui::widgets::ListState; |
| 5 | 5 | ||
| 6 | use crate::abbrev::Abbrev; | 6 | use crate::abbrev::Abbrev; |
| 7 | use crate::event::ReviewVerdict; | ||
| 7 | use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; | 8 | use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; |
| 8 | 9 | ||
| 10 | use super::keys::{self, Action, Context, Key}; | ||
| 9 | use super::tips::Tips; | 11 | use super::tips::Tips; |
| 10 | use super::widgets::{DetailRow, DisplayRow, RowTarget}; | 12 | use super::widgets::{DetailRow, DisplayRow, RowTarget}; |
| 11 | 13 | ||
| @@ -18,22 +20,38 @@ pub(crate) enum Pane { | |||
| 18 | #[derive(Debug, PartialEq)] | 20 | #[derive(Debug, PartialEq)] |
| 19 | pub(crate) enum ViewMode { | 21 | pub(crate) enum ViewMode { |
| 20 | Details, | 22 | Details, |
| 21 | CommitList, | 23 | /// The issue's event stream, listed. Titled `Event History` on screen; it |
| 22 | CommitDetail, | 24 | /// was called `CommitList` until the name was read as "a list of commits" |
| 25 | /// by someone who then concluded the wrong thing about the key that opens | ||
| 26 | /// it. | ||
| 27 | EventHistory, | ||
| 28 | /// One event from that stream, in full. | ||
| 29 | EventDetail, | ||
| 23 | PatchDetail, | 30 | PatchDetail, |
| 24 | } | 31 | } |
| 25 | 32 | ||
| 33 | /// What the key handler needs the event loop to do. | ||
| 34 | /// | ||
| 35 | /// Everything the [`App`] can do to itself it has already done by the time | ||
| 36 | /// this is returned; these are the things that need the repository, the | ||
| 37 | /// terminal, or the loop's own control flow. | ||
| 26 | #[derive(Debug, PartialEq)] | 38 | #[derive(Debug, PartialEq)] |
| 27 | pub(crate) enum KeyAction { | 39 | pub(crate) enum KeyAction { |
| 28 | Continue, | 40 | Continue, |
| 29 | Quit, | 41 | Quit, |
| 30 | Reload, | 42 | Reload, |
| 31 | OpenCommitBrowser, | 43 | OpenEventHistory, |
| 44 | /// Comment on the issue under the cursor. | ||
| 45 | CommentOnIssue, | ||
| 46 | /// Close the issue under the cursor, or reopen it if it is closed. | ||
| 47 | CloseOrReopenIssue, | ||
| 32 | OpenPatchDetail, | 48 | OpenPatchDetail, |
| 33 | OpenPatchDetailDirect(usize), // index into visible_patches | 49 | OpenPatchDetailDirect(usize), // index into visible_patches |
| 34 | /// Comment on the row under the cursor: inline when it is a line of the | 50 | /// Comment on the row under the cursor: inline when it is a line of the |
| 35 | /// diff, on the patch's thread otherwise. | 51 | /// diff, on the patch's thread otherwise. |
| 36 | Comment, | 52 | Comment, |
| 53 | /// A verdict was chosen at the `R` prompt. | ||
| 54 | Review(ReviewVerdict), | ||
| 37 | /// Claim, or withdraw the claim, that the comment under the cursor was | 55 | /// Claim, or withdraw the claim, that the comment under the cursor was |
| 38 | /// answered. | 56 | /// answered. |
| 39 | ToggleResolve, | 57 | ToggleResolve, |
| @@ -173,6 +191,12 @@ pub(crate) struct App { | |||
| 173 | /// Events that landed since `tips` was taken. Never acted on by itself — | 191 | /// Events that landed since `tips` was taken. Never acted on by itself — |
| 174 | /// it raises a banner, and the reader reloads when they are ready. | 192 | /// it raises a banner, and the reader reloads when they are ready. |
| 175 | pub(crate) new_events: usize, | 193 | pub(crate) new_events: usize, |
| 194 | /// Whether the `?` overlay is up. | ||
| 195 | /// | ||
| 196 | /// An overlay rather than a `ViewMode`, because it is drawn over a pane | ||
| 197 | /// and lists that pane's keys: the thing underneath has to still be the | ||
| 198 | /// current surface while it is up. | ||
| 199 | pub(crate) show_help: bool, | ||
| 176 | } | 200 | } |
| 177 | 201 | ||
| 178 | impl App { | 202 | impl App { |
| @@ -226,6 +250,7 @@ impl App { | |||
| 226 | loaded_at: Local::now(), | 250 | loaded_at: Local::now(), |
| 227 | tips: Tips::default(), | 251 | tips: Tips::default(), |
| 228 | new_events: 0, | 252 | new_events: 0, |
| 253 | show_help: false, | ||
| 229 | } | 254 | } |
| 230 | } | 255 | } |
| 231 | 256 | ||
| @@ -439,249 +464,206 @@ impl App { | |||
| 439 | .find(|p| p.fixes.as_deref() == Some(&issue.id)) | 464 | .find(|p| p.fixes.as_deref() == Some(&issue.id)) |
| 440 | } | 465 | } |
| 441 | 466 | ||
| 467 | /// The issue under the cursor, whichever of the two issue panes has focus. | ||
| 468 | /// | ||
| 469 | /// The selection is a property of the list, not of which pane is | ||
| 470 | /// highlighted, and that is why `c` is bound in both: the old `pane == | ||
| 471 | /// Pane::Detail` guard treated focus as if it decided what the key had to | ||
| 472 | /// work with. | ||
| 473 | pub(crate) fn selected_issue(&self) -> Option<&IssueState> { | ||
| 474 | let idx = self.list_state.selected()?; | ||
| 475 | self.visible_issues().get(idx).copied() | ||
| 476 | } | ||
| 477 | |||
| 478 | /// Turn a keypress into what it means here, and do it. | ||
| 479 | /// | ||
| 480 | /// One lookup in one table, in place of fifty-seven `KeyCode::` arms | ||
| 481 | /// nested by mode and pane. A key the current context does not bind | ||
| 482 | /// arrives here as `None`, and says so — the structure this replaces sent | ||
| 483 | /// "not bound" and "bound but gated out" to the same silent | ||
| 484 | /// `_ => KeyAction::Continue`, which is how `c` came to do nothing at all | ||
| 485 | /// on the issue list without anyone being able to tell. | ||
| 442 | pub(crate) fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> KeyAction { | 486 | pub(crate) fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> KeyAction { |
| 443 | // Handle PatchDetail mode | 487 | let key = Key { |
| 444 | if self.mode == ViewMode::PatchDetail { | 488 | code, |
| 445 | match code { | 489 | ctrl: modifiers.contains(KeyModifiers::CONTROL), |
| 446 | KeyCode::Esc => { | 490 | }; |
| 447 | self.mode = ViewMode::Details; | 491 | let context = Context::of(self); |
| 448 | self.pane = Pane::ItemList; | 492 | match keys::lookup(context, key) { |
| 449 | self.current_patch = None; | 493 | Some(action) => self.apply(action), |
| 450 | self.patch_diff.clear(); | 494 | None => self.unbound(context, key), |
| 451 | self.patch_lines.clear(); | ||
| 452 | self.patch_rows.clear(); | ||
| 453 | self.patch_revision_idx = 0; | ||
| 454 | self.patch_interdiff_mode = false; | ||
| 455 | self.reset_patch_view(); | ||
| 456 | return KeyAction::Continue; | ||
| 457 | } | ||
| 458 | KeyCode::Char('q') => return KeyAction::Quit, | ||
| 459 | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { | ||
| 460 | return KeyAction::Quit; | ||
| 461 | } | ||
| 462 | // Rows, not lines, and the cursor stays where it is — the only | ||
| 463 | // way through a line that wrapped taller than the pane. | ||
| 464 | KeyCode::Char('e') if modifiers.contains(KeyModifiers::CONTROL) => { | ||
| 465 | self.scroll_rows(1); | ||
| 466 | return KeyAction::Continue; | ||
| 467 | } | ||
| 468 | KeyCode::Char('y') if modifiers.contains(KeyModifiers::CONTROL) => { | ||
| 469 | self.scroll_rows(-1); | ||
| 470 | return KeyAction::Continue; | ||
| 471 | } | ||
| 472 | KeyCode::Char('j') | KeyCode::Down => { | ||
| 473 | self.move_cursor(1); | ||
| 474 | return KeyAction::Continue; | ||
| 475 | } | ||
| 476 | KeyCode::Char('k') | KeyCode::Up => { | ||
| 477 | self.move_cursor(-1); | ||
| 478 | return KeyAction::Continue; | ||
| 479 | } | ||
| 480 | KeyCode::PageDown => { | ||
| 481 | self.move_cursor(20); | ||
| 482 | return KeyAction::Continue; | ||
| 483 | } | ||
| 484 | KeyCode::PageUp => { | ||
| 485 | self.move_cursor(-20); | ||
| 486 | return KeyAction::Continue; | ||
| 487 | } | ||
| 488 | // ── The review loop ────────────────────────────────────── | ||
| 489 | KeyCode::Char('c') => return KeyAction::Comment, | ||
| 490 | KeyCode::Char('R') => { | ||
| 491 | self.input_mode = InputMode::ReviewVerdict; | ||
| 492 | return KeyAction::Continue; | ||
| 493 | } | ||
| 494 | KeyCode::Char('x') => return KeyAction::ToggleResolve, | ||
| 495 | KeyCode::Char('a') => return KeyAction::ShowAnswers, | ||
| 496 | KeyCode::Char('o') => return KeyAction::Checkout, | ||
| 497 | KeyCode::Char(']') => { | ||
| 498 | if let Some(ref patch) = self.current_patch { | ||
| 499 | let max = patch.revisions.len().saturating_sub(1); | ||
| 500 | if self.patch_revision_idx < max { | ||
| 501 | self.patch_revision_idx += 1; | ||
| 502 | self.reset_patch_view(); | ||
| 503 | return KeyAction::Reload; // signal to regenerate diff | ||
| 504 | } | ||
| 505 | } | ||
| 506 | return KeyAction::Continue; | ||
| 507 | } | ||
| 508 | KeyCode::Char('[') => { | ||
| 509 | if self.patch_revision_idx > 0 { | ||
| 510 | self.patch_revision_idx -= 1; | ||
| 511 | self.reset_patch_view(); | ||
| 512 | return KeyAction::Reload; // signal to regenerate diff | ||
| 513 | } | ||
| 514 | return KeyAction::Continue; | ||
| 515 | } | ||
| 516 | KeyCode::Char('d') => { | ||
| 517 | self.patch_interdiff_mode = !self.patch_interdiff_mode; | ||
| 518 | self.reset_patch_view(); | ||
| 519 | return KeyAction::Reload; // signal to regenerate diff | ||
| 520 | } | ||
| 521 | // The escape hatch for reading `+`/`-` in column. Deliberately | ||
| 522 | // not reset by `reset_patch_view`: a reader who turned wrapping | ||
| 523 | // off did not mean "until the next revision". | ||
| 524 | KeyCode::Char('w') => { | ||
| 525 | self.patch_wrap = !self.patch_wrap; | ||
| 526 | self.rewrap(); | ||
| 527 | return KeyAction::Continue; | ||
| 528 | } | ||
| 529 | _ => return KeyAction::Continue, | ||
| 530 | } | ||
| 531 | } | 495 | } |
| 496 | } | ||
| 532 | 497 | ||
| 533 | // Handle CommitDetail mode first | 498 | /// What happens when a key means nothing here. |
| 534 | if self.mode == ViewMode::CommitDetail { | 499 | /// |
| 535 | match code { | 500 | /// A sentence, not silence. The reader is told which key they pressed, |
| 536 | KeyCode::Esc => { | 501 | /// that this pane does not use it, and where the pane's keys are — because |
| 537 | self.mode = ViewMode::CommitList; | 502 | /// the failure being fixed is not that the key did nothing, it is that the |
| 538 | self.scroll = 0; | 503 | /// reader could not tell "nothing is bound" from "the binding is broken". |
| 539 | return KeyAction::Continue; | 504 | fn unbound(&mut self, context: Context, key: Key) -> KeyAction { |
| 540 | } | 505 | match context { |
| 541 | KeyCode::Char('q') => return KeyAction::Quit, | 506 | // Any key dismisses the overlay: it is covering the thing the |
| 542 | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { | 507 | // reader wants, so getting rid of it is the one universally right |
| 543 | return KeyAction::Quit; | 508 | // answer, and it is a visible effect rather than silence. |
| 544 | } | 509 | Context::Help => { |
| 545 | KeyCode::Char('j') | KeyCode::Down => { | 510 | self.show_help = false; |
| 546 | self.scroll = self.scroll.saturating_add(1); | 511 | KeyAction::Continue |
| 547 | return KeyAction::Continue; | ||
| 548 | } | ||
| 549 | KeyCode::Char('k') | KeyCode::Up => { | ||
| 550 | self.scroll = self.scroll.saturating_sub(1); | ||
| 551 | return KeyAction::Continue; | ||
| 552 | } | ||
| 553 | KeyCode::PageDown => { | ||
| 554 | self.scroll = self.scroll.saturating_add(20); | ||
| 555 | return KeyAction::Continue; | ||
| 556 | } | ||
| 557 | KeyCode::PageUp => { | ||
| 558 | self.scroll = self.scroll.saturating_sub(20); | ||
| 559 | return KeyAction::Continue; | ||
| 560 | } | ||
| 561 | _ => return KeyAction::Continue, | ||
| 562 | } | 512 | } |
| 563 | } | 513 | // A modal prompt that swallowed the key would leave the reader in |
| 564 | 514 | // it with no sign of why. Cancelling and saying so is the honest | |
| 565 | // Handle CommitList mode | 515 | // reading of "that was not one of the three". |
| 566 | if self.mode == ViewMode::CommitList { | 516 | Context::ReviewVerdict => { |
| 567 | match code { | 517 | self.input_mode = InputMode::Normal; |
| 568 | KeyCode::Esc => { | 518 | self.status_msg = Some(format!( |
| 569 | self.event_history.clear(); | 519 | "{} is not a verdict — review abandoned. a approves, r requests changes, \ |
| 570 | self.event_list_state = ListState::default(); | 520 | c comments.", |
| 571 | self.mode = ViewMode::Details; | 521 | key.name() |
| 572 | self.scroll = 0; | 522 | )); |
| 573 | return KeyAction::Continue; | 523 | KeyAction::Continue |
| 574 | } | 524 | } |
| 575 | KeyCode::Char('q') => return KeyAction::Quit, | 525 | _ => { |
| 576 | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { | 526 | self.status_msg = Some(format!( |
| 577 | return KeyAction::Quit; | 527 | "{} does nothing on {} — press ? for this pane's keys.", |
| 578 | } | 528 | key.name(), |
| 579 | KeyCode::Char('j') | KeyCode::Down => { | 529 | context.title() |
| 580 | let len = self.event_history.len(); | 530 | )); |
| 581 | if len > 0 { | 531 | KeyAction::Continue |
| 582 | let current = self.event_list_state.selected().unwrap_or(0); | ||
| 583 | let new = (current + 1).min(len - 1); | ||
| 584 | self.event_list_state.select(Some(new)); | ||
| 585 | } | ||
| 586 | return KeyAction::Continue; | ||
| 587 | } | ||
| 588 | KeyCode::Char('k') | KeyCode::Up => { | ||
| 589 | if !self.event_history.is_empty() { | ||
| 590 | let current = self.event_list_state.selected().unwrap_or(0); | ||
| 591 | let new = current.saturating_sub(1); | ||
| 592 | self.event_list_state.select(Some(new)); | ||
| 593 | } | ||
| 594 | return KeyAction::Continue; | ||
| 595 | } | ||
| 596 | KeyCode::Enter => { | ||
| 597 | if self.event_list_state.selected().is_some() { | ||
| 598 | self.mode = ViewMode::CommitDetail; | ||
| 599 | self.scroll = 0; | ||
| 600 | } | ||
| 601 | return KeyAction::Continue; | ||
| 602 | } | ||
| 603 | _ => return KeyAction::Continue, | ||
| 604 | } | 532 | } |
| 605 | } | 533 | } |
| 534 | } | ||
| 606 | 535 | ||
| 607 | // Normal Details mode handling | 536 | /// Carry out one meaning. |
| 608 | match code { | 537 | /// |
| 609 | KeyCode::Char('q') | KeyCode::Esc => KeyAction::Quit, | 538 | /// Exhaustive over [`Action`] on purpose: a new binding cannot be added to |
| 610 | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => KeyAction::Quit, | 539 | /// the table and left doing nothing, because this will not compile until |
| 611 | KeyCode::Char('P') => { | 540 | /// it is given a meaning here. |
| 612 | // Toggle between Issues and Patches list mode | 541 | fn apply(&mut self, action: Action) -> KeyAction { |
| 613 | self.list_mode = match self.list_mode { | 542 | match action { |
| 614 | ListMode::Issues => ListMode::Patches, | 543 | Action::Quit => KeyAction::Quit, |
| 615 | ListMode::Patches => ListMode::Issues, | 544 | Action::Back => { |
| 616 | }; | 545 | self.go_back(); |
| 617 | self.scroll = 0; | ||
| 618 | self.pane = Pane::ItemList; | ||
| 619 | self.mode = ViewMode::Details; | ||
| 620 | KeyAction::Continue | 546 | KeyAction::Continue |
| 621 | } | 547 | } |
| 622 | KeyCode::Char('c') => { | 548 | |
| 623 | // Open commit browser: only when in detail pane with an item selected | 549 | // ── Moving ────────────────────────────────────────────────── |
| 624 | if self.pane == Pane::Detail | 550 | Action::SelectNext => { |
| 625 | && self.list_mode == ListMode::Issues | 551 | self.move_selection(1); |
| 626 | && self.list_state.selected().is_some() | 552 | KeyAction::Continue |
| 627 | { | ||
| 628 | KeyAction::OpenCommitBrowser | ||
| 629 | } else { | ||
| 630 | KeyAction::Continue | ||
| 631 | } | ||
| 632 | } | 553 | } |
| 633 | KeyCode::Char('p') => { | 554 | Action::SelectPrev => { |
| 634 | // Open patch detail: only when in detail pane with a linked patch (issues mode) | 555 | self.move_selection(-1); |
| 635 | if self.pane == Pane::Detail | 556 | KeyAction::Continue |
| 636 | && self.list_mode == ListMode::Issues | ||
| 637 | && self.linked_patch_for_selected().is_some() | ||
| 638 | { | ||
| 639 | KeyAction::OpenPatchDetail | ||
| 640 | } else { | ||
| 641 | KeyAction::Continue | ||
| 642 | } | ||
| 643 | } | 557 | } |
| 644 | KeyCode::Char('j') | KeyCode::Down => { | 558 | Action::ScrollDown => { |
| 645 | if self.pane == Pane::ItemList { | 559 | self.scroll = self.scroll.saturating_add(1); |
| 646 | self.move_selection(1); | ||
| 647 | } else { | ||
| 648 | self.scroll = self.scroll.saturating_add(1); | ||
| 649 | } | ||
| 650 | KeyAction::Continue | 560 | KeyAction::Continue |
| 651 | } | 561 | } |
| 652 | KeyCode::Char('k') | KeyCode::Up => { | 562 | Action::ScrollUp => { |
| 653 | if self.pane == Pane::ItemList { | 563 | self.scroll = self.scroll.saturating_sub(1); |
| 654 | self.move_selection(-1); | ||
| 655 | } else { | ||
| 656 | self.scroll = self.scroll.saturating_sub(1); | ||
| 657 | } | ||
| 658 | KeyAction::Continue | 564 | KeyAction::Continue |
| 659 | } | 565 | } |
| 660 | KeyCode::PageDown => { | 566 | Action::ScrollPageDown => { |
| 661 | self.scroll = self.scroll.saturating_add(20); | 567 | self.scroll = self.scroll.saturating_add(20); |
| 662 | KeyAction::Continue | 568 | KeyAction::Continue |
| 663 | } | 569 | } |
| 664 | KeyCode::PageUp => { | 570 | Action::ScrollPageUp => { |
| 665 | self.scroll = self.scroll.saturating_sub(20); | 571 | self.scroll = self.scroll.saturating_sub(20); |
| 666 | KeyAction::Continue | 572 | KeyAction::Continue |
| 667 | } | 573 | } |
| 668 | KeyCode::Tab | KeyCode::Enter => { | 574 | Action::CursorDown => { |
| 669 | // In Patches list mode, entering detail pane opens patch detail directly | 575 | self.move_cursor(1); |
| 670 | if self.list_mode == ListMode::Patches && self.pane == Pane::ItemList { | 576 | KeyAction::Continue |
| 671 | if let Some(idx) = self.patch_list_state.selected() { | 577 | } |
| 672 | let visible_len = self.visible_patches().len(); | 578 | Action::CursorUp => { |
| 673 | if idx < visible_len { | 579 | self.move_cursor(-1); |
| 674 | return KeyAction::OpenPatchDetailDirect(idx); | 580 | KeyAction::Continue |
| 675 | } | 581 | } |
| 676 | } | 582 | Action::CursorPageDown => { |
| 583 | self.move_cursor(20); | ||
| 584 | KeyAction::Continue | ||
| 585 | } | ||
| 586 | Action::CursorPageUp => { | ||
| 587 | self.move_cursor(-20); | ||
| 588 | KeyAction::Continue | ||
| 589 | } | ||
| 590 | Action::ScrollRowDown => { | ||
| 591 | self.scroll_rows(1); | ||
| 592 | KeyAction::Continue | ||
| 593 | } | ||
| 594 | Action::ScrollRowUp => { | ||
| 595 | self.scroll_rows(-1); | ||
| 596 | KeyAction::Continue | ||
| 597 | } | ||
| 598 | Action::EventNext => { | ||
| 599 | let len = self.event_history.len(); | ||
| 600 | if len > 0 { | ||
| 601 | let current = self.event_list_state.selected().unwrap_or(0); | ||
| 602 | self.event_list_state.select(Some((current + 1).min(len - 1))); | ||
| 603 | } | ||
| 604 | KeyAction::Continue | ||
| 605 | } | ||
| 606 | Action::EventPrev => { | ||
| 607 | if !self.event_history.is_empty() { | ||
| 608 | let current = self.event_list_state.selected().unwrap_or(0); | ||
| 609 | self.event_list_state.select(Some(current.saturating_sub(1))); | ||
| 610 | } | ||
| 611 | KeyAction::Continue | ||
| 612 | } | ||
| 613 | Action::OpenEvent => { | ||
| 614 | if self.event_list_state.selected().is_some() { | ||
| 615 | self.mode = ViewMode::EventDetail; | ||
| 616 | self.scroll = 0; | ||
| 677 | } | 617 | } |
| 618 | KeyAction::Continue | ||
| 619 | } | ||
| 620 | Action::TogglePane => { | ||
| 678 | self.pane = match self.pane { | 621 | self.pane = match self.pane { |
| 679 | Pane::ItemList => Pane::Detail, | 622 | Pane::ItemList => Pane::Detail, |
| 680 | Pane::Detail => Pane::ItemList, | 623 | Pane::Detail => Pane::ItemList, |
| 681 | }; | 624 | }; |
| 682 | KeyAction::Continue | 625 | KeyAction::Continue |
| 683 | } | 626 | } |
| 684 | KeyCode::Char('a') => { | 627 | |
| 628 | // ── Opening ───────────────────────────────────────────────── | ||
| 629 | Action::OpenSelectedPatch => match self.patch_list_state.selected() { | ||
| 630 | Some(idx) if idx < self.visible_patches().len() => { | ||
| 631 | KeyAction::OpenPatchDetailDirect(idx) | ||
| 632 | } | ||
| 633 | // Nothing to open: fall back to moving focus, which is what | ||
| 634 | // the pane beside an empty list is for. | ||
| 635 | _ => self.apply(Action::TogglePane), | ||
| 636 | }, | ||
| 637 | Action::OpenLinkedPatch => { | ||
| 638 | if self.linked_patch_for_selected().is_some() { | ||
| 639 | KeyAction::OpenPatchDetail | ||
| 640 | } else { | ||
| 641 | self.status_msg = | ||
| 642 | Some("No patch is linked to this issue yet.".to_string()); | ||
| 643 | KeyAction::Continue | ||
| 644 | } | ||
| 645 | } | ||
| 646 | Action::OpenEventHistory => { | ||
| 647 | if self.selected_item_id().is_some() { | ||
| 648 | KeyAction::OpenEventHistory | ||
| 649 | } else { | ||
| 650 | self.status_msg = Some("No issue is selected.".to_string()); | ||
| 651 | KeyAction::Continue | ||
| 652 | } | ||
| 653 | } | ||
| 654 | |||
| 655 | // ── Filtering the lists ───────────────────────────────────── | ||
| 656 | Action::SwitchList => { | ||
| 657 | self.list_mode = match self.list_mode { | ||
| 658 | ListMode::Issues => ListMode::Patches, | ||
| 659 | ListMode::Patches => ListMode::Issues, | ||
| 660 | }; | ||
| 661 | self.scroll = 0; | ||
| 662 | self.pane = Pane::ItemList; | ||
| 663 | self.mode = ViewMode::Details; | ||
| 664 | KeyAction::Continue | ||
| 665 | } | ||
| 666 | Action::CycleStatusFilter => { | ||
| 685 | self.status_filter = self.status_filter.next(); | 667 | self.status_filter = self.status_filter.next(); |
| 686 | let count = self.visible_count(); | 668 | let count = self.visible_count(); |
| 687 | let state = match self.list_mode { | 669 | let state = match self.list_mode { |
| @@ -691,9 +673,7 @@ impl App { | |||
| 691 | state.select(if count > 0 { Some(0) } else { None }); | 673 | state.select(if count > 0 { Some(0) } else { None }); |
| 692 | KeyAction::Continue | 674 | KeyAction::Continue |
| 693 | } | 675 | } |
| 694 | // Patches only: issues carry no unresolved count, so on that pane | 676 | Action::ToggleUnresolvedOnly => { |
| 695 | // this would silently do nothing to a list the user is looking at. | ||
| 696 | KeyCode::Char('u') if self.list_mode == ListMode::Patches => { | ||
| 697 | self.unresolved_only = !self.unresolved_only; | 677 | self.unresolved_only = !self.unresolved_only; |
| 698 | // The row under the cursor may have just left the list; the | 678 | // The row under the cursor may have just left the list; the |
| 699 | // status filter resets the selection for the same reason. | 679 | // status filter resets the selection for the same reason. |
| @@ -702,8 +682,129 @@ impl App { | |||
| 702 | .select(if count > 0 { Some(0) } else { None }); | 682 | .select(if count > 0 { Some(0) } else { None }); |
| 703 | KeyAction::Continue | 683 | KeyAction::Continue |
| 704 | } | 684 | } |
| 705 | KeyCode::Char('r') => KeyAction::Reload, | 685 | Action::Reload => KeyAction::Reload, |
| 706 | _ => KeyAction::Continue, | 686 | Action::BeginSearch => { |
| 687 | self.input_mode = InputMode::Search; | ||
| 688 | self.search_query.clear(); | ||
| 689 | KeyAction::Continue | ||
| 690 | } | ||
| 691 | Action::BeginCreateIssue => { | ||
| 692 | self.input_mode = InputMode::CreateTitle; | ||
| 693 | self.input_buf.clear(); | ||
| 694 | self.create_title.clear(); | ||
| 695 | KeyAction::Continue | ||
| 696 | } | ||
| 697 | |||
| 698 | // ── Writing ───────────────────────────────────────────────── | ||
| 699 | Action::CommentOnIssue => { | ||
| 700 | if self.selected_item_id().is_some() { | ||
| 701 | KeyAction::CommentOnIssue | ||
| 702 | } else { | ||
| 703 | self.status_msg = Some("No issue is selected to comment on.".to_string()); | ||
| 704 | KeyAction::Continue | ||
| 705 | } | ||
| 706 | } | ||
| 707 | Action::CloseOrReopenIssue => { | ||
| 708 | if self.selected_item_id().is_some() { | ||
| 709 | KeyAction::CloseOrReopenIssue | ||
| 710 | } else { | ||
| 711 | self.status_msg = Some("No issue is selected.".to_string()); | ||
| 712 | KeyAction::Continue | ||
| 713 | } | ||
| 714 | } | ||
| 715 | Action::CommentOnPatch => KeyAction::Comment, | ||
| 716 | Action::BeginReviewVerdict => { | ||
| 717 | self.input_mode = InputMode::ReviewVerdict; | ||
| 718 | KeyAction::Continue | ||
| 719 | } | ||
| 720 | Action::ToggleResolve => KeyAction::ToggleResolve, | ||
| 721 | Action::ShowAnswers => KeyAction::ShowAnswers, | ||
| 722 | Action::Checkout => KeyAction::Checkout, | ||
| 723 | Action::VerdictApprove => self.verdict(ReviewVerdict::Approve), | ||
| 724 | Action::VerdictRequestChanges => self.verdict(ReviewVerdict::RequestChanges), | ||
| 725 | Action::VerdictComment => self.verdict(ReviewVerdict::Comment), | ||
| 726 | Action::CancelVerdict => { | ||
| 727 | self.input_mode = InputMode::Normal; | ||
| 728 | self.status_msg = Some("Review abandoned.".to_string()); | ||
| 729 | KeyAction::Continue | ||
| 730 | } | ||
| 731 | |||
| 732 | // ── The patch detail pane's own view state ────────────────── | ||
| 733 | Action::NextRevision => { | ||
| 734 | if let Some(ref patch) = self.current_patch { | ||
| 735 | let max = patch.revisions.len().saturating_sub(1); | ||
| 736 | if self.patch_revision_idx < max { | ||
| 737 | self.patch_revision_idx += 1; | ||
| 738 | self.reset_patch_view(); | ||
| 739 | return KeyAction::Reload; // signal to regenerate diff | ||
| 740 | } | ||
| 741 | } | ||
| 742 | KeyAction::Continue | ||
| 743 | } | ||
| 744 | Action::PrevRevision => { | ||
| 745 | if self.patch_revision_idx > 0 { | ||
| 746 | self.patch_revision_idx -= 1; | ||
| 747 | self.reset_patch_view(); | ||
| 748 | return KeyAction::Reload; // signal to regenerate diff | ||
| 749 | } | ||
| 750 | KeyAction::Continue | ||
| 751 | } | ||
| 752 | Action::ToggleInterdiff => { | ||
| 753 | self.patch_interdiff_mode = !self.patch_interdiff_mode; | ||
| 754 | self.reset_patch_view(); | ||
| 755 | KeyAction::Reload // signal to regenerate diff | ||
| 756 | } | ||
| 757 | // The escape hatch for reading `+`/`-` in column. Deliberately | ||
| 758 | // not reset by `reset_patch_view`: a reader who turned wrapping | ||
| 759 | // off did not mean "until the next revision". | ||
| 760 | Action::ToggleWrap => { | ||
| 761 | self.patch_wrap = !self.patch_wrap; | ||
| 762 | self.rewrap(); | ||
| 763 | KeyAction::Continue | ||
| 764 | } | ||
| 765 | |||
| 766 | // ── The overlay ───────────────────────────────────────────── | ||
| 767 | Action::ShowHelp => { | ||
| 768 | self.show_help = true; | ||
| 769 | KeyAction::Continue | ||
| 770 | } | ||
| 771 | Action::CloseHelp => { | ||
| 772 | self.show_help = false; | ||
| 773 | KeyAction::Continue | ||
| 774 | } | ||
| 775 | } | ||
| 776 | } | ||
| 777 | |||
| 778 | fn verdict(&mut self, verdict: ReviewVerdict) -> KeyAction { | ||
| 779 | self.input_mode = InputMode::Normal; | ||
| 780 | KeyAction::Review(verdict) | ||
| 781 | } | ||
| 782 | |||
| 783 | /// Leave the view on screen for the one behind it. | ||
| 784 | fn go_back(&mut self) { | ||
| 785 | match self.mode { | ||
| 786 | ViewMode::PatchDetail => { | ||
| 787 | self.mode = ViewMode::Details; | ||
| 788 | self.pane = Pane::ItemList; | ||
| 789 | self.current_patch = None; | ||
| 790 | self.patch_diff.clear(); | ||
| 791 | self.patch_lines.clear(); | ||
| 792 | self.patch_rows.clear(); | ||
| 793 | self.patch_revision_idx = 0; | ||
| 794 | self.patch_interdiff_mode = false; | ||
| 795 | self.reset_patch_view(); | ||
| 796 | } | ||
| 797 | ViewMode::EventDetail => { | ||
| 798 | self.mode = ViewMode::EventHistory; | ||
| 799 | self.scroll = 0; | ||
| 800 | } | ||
| 801 | ViewMode::EventHistory => { | ||
| 802 | self.event_history.clear(); | ||
| 803 | self.event_list_state = ListState::default(); | ||
| 804 | self.mode = ViewMode::Details; | ||
| 805 | self.scroll = 0; | ||
| 806 | } | ||
| 807 | ViewMode::Details => {} | ||
| 707 | } | 808 | } |
| 708 | } | 809 | } |
| 709 | 810 | ||
| @@ -765,10 +866,16 @@ impl App { | |||
| 765 | 866 | ||
| 766 | pub(crate) fn reload(&mut self, repo: &Repository) { | 867 | pub(crate) fn reload(&mut self, repo: &Repository) { |
| 767 | self.rebaseline(repo); | 868 | self.rebaseline(repo); |
| 768 | if let Ok(issues) = state::list_issues(repo) { | 869 | // Archived objects included, because the status filter offers "closed" |
| 870 | // and "all" and a filter has to be able to reach what it names. | ||
| 871 | // Closing archives the ref, so loading with `list_issues` made those | ||
| 872 | // two settings of `a` structurally empty — the same lie the footer was | ||
| 873 | // telling, in a different place. `issue list -a` and the web UI have | ||
| 874 | // always used these listers; `visible_issues` does the narrowing. | ||
| 875 | if let Ok(issues) = state::list_issues_with_archived(repo) { | ||
| 769 | self.issues = issues; | 876 | self.issues = issues; |
| 770 | } | 877 | } |
| 771 | if let Ok(patches) = state::list_patches(repo) { | 878 | if let Ok(patches) = state::list_patches_with_archived(repo) { |
| 772 | self.patches = patches; | 879 | self.patches = patches; |
| 773 | } | 880 | } |
| 774 | // Refreshing the lists without refreshing the widths would leave the | 881 | // Refreshing the lists without refreshing the widths would leave the |
src/tui/widgets.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,12 +1,12 @@ | |||
| 1 | use git2::{Oid, Repository}; | 1 | use git2::{Oid, Repository}; |
| 2 | use ratatui::prelude::*; | 2 | use ratatui::prelude::*; |
| 3 | use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; | 3 | use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}; |
| 4 | 4 | ||
| 5 | use crate::abbrev::Abbrev; | 5 | use crate::abbrev::Abbrev; |
| 6 | use crate::event::{Action, ReviewVerdict}; | 6 | use crate::event::{Action, ReviewVerdict}; |
| 7 | use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus}; | 7 | use crate::state::{IssueState, IssueStatus, PatchState, PatchStatus}; |
| 8 | 8 | ||
| 9 | use super::state::{App, InputMode, ListMode, Pane, StatusFilter, ViewMode}; | 9 | use super::state::{App, InputMode, ListMode, Pane, ViewMode}; |
| 10 | 10 | ||
| 11 | /// A stored timestamp as the dashboard shows it: `YYYY-MM-DD HH:MM`, the same | 11 | /// A stored timestamp as the dashboard shows it: `YYYY-MM-DD HH:MM`, the same |
| 12 | /// rendering the web settled on in `b2a57996`. | 12 | /// rendering the web settled on in `b2a57996`. |
| @@ -80,7 +80,7 @@ pub(crate) fn action_type_label(action: &Action) -> &str { | |||
| 80 | } | 80 | } |
| 81 | } | 81 | } |
| 82 | 82 | ||
| 83 | /// Render one raw event for the commit browser. | 83 | /// Render one raw event for the Event History pane. |
| 84 | /// | 84 | /// |
| 85 | /// `issue_abbrev` governs the issue ids in the payload. The event's own commit | 85 | /// `issue_abbrev` governs the issue ids in the payload. The event's own commit |
| 86 | /// oid, and the `target` of an edit or a delete, are git object ids naming an | 86 | /// oid, and the `target` of an edit or a delete, are git object ids naming an |
| @@ -454,6 +454,71 @@ pub(crate) fn ui(frame: &mut Frame, app: &mut App, repo: Option<&Repository>) { | |||
| 454 | render_list(frame, app, panes[0]); | 454 | render_list(frame, app, panes[0]); |
| 455 | render_detail(frame, app, panes[1], repo); | 455 | render_detail(frame, app, panes[1], repo); |
| 456 | render_footer(frame, app, footer_area); | 456 | render_footer(frame, app, footer_area); |
| 457 | if app.show_help { | ||
| 458 | render_help(frame, app, main_area); | ||
| 459 | } | ||
| 460 | } | ||
| 461 | |||
| 462 | /// Every key of the pane underneath, generated from the binding table. | ||
| 463 | /// | ||
| 464 | /// This surface grew `c`, `R`, `x`, `a`, `w`, `o`, `n`, `u`, `P`, `Ctrl-E` and | ||
| 465 | /// `Ctrl-Y` without ever telling anyone, because the only place they were | ||
| 466 | /// advertised was a one-line footer that could not hold them. Being generated | ||
| 467 | /// is what makes this list free and what stops it going stale: there is | ||
| 468 | /// nothing here to update when a binding changes. | ||
| 469 | fn render_help(frame: &mut Frame, app: &App, area: Rect) { | ||
| 470 | let context = super::keys::Context::surface(app); | ||
| 471 | let rows = super::keys::overlay(app); | ||
| 472 | // The key column is right-aligned to the widest key, so the box has to be | ||
| 473 | // sized against that width and the widest label, not against the widest | ||
| 474 | // row: the two maxima are rarely on the same line. | ||
| 475 | let keys_width = rows | ||
| 476 | .iter() | ||
| 477 | .map(|(keys, _)| keys.chars().count()) | ||
| 478 | .max() | ||
| 479 | .unwrap_or(0); | ||
| 480 | let label_width = rows | ||
| 481 | .iter() | ||
| 482 | .map(|(_, label)| label.chars().count()) | ||
| 483 | .max() | ||
| 484 | .unwrap_or(0); | ||
| 485 | // ` <keys> <label>` inside a border on each side. | ||
| 486 | let width = (keys_width + label_width + 4) | ||
| 487 | .max(context.title().chars().count() + 9) as u16; | ||
| 488 | let width = width.min(area.width); | ||
| 489 | let height = (rows.len() as u16 + 2).min(area.height); | ||
| 490 | |||
| 491 | // Centred, and clipped by the pane rather than overflowing it. | ||
| 492 | let x = area.x + (area.width.saturating_sub(width)) / 2; | ||
| 493 | let y = area.y + (area.height.saturating_sub(height)) / 2; | ||
| 494 | let popup = Rect { | ||
| 495 | x, | ||
| 496 | y, | ||
| 497 | width, | ||
| 498 | height, | ||
| 499 | }; | ||
| 500 | |||
| 501 | let lines: Vec<Line> = rows | ||
| 502 | .iter() | ||
| 503 | .map(|(keys, label)| { | ||
| 504 | Line::from(vec![ | ||
| 505 | Span::styled( | ||
| 506 | format!(" {:>width$} ", keys, width = keys_width), | ||
| 507 | Style::default() | ||
| 508 | .fg(Color::Yellow) | ||
| 509 | .add_modifier(Modifier::BOLD), | ||
| 510 | ), | ||
| 511 | Span::raw(label.clone()), | ||
| 512 | ]) | ||
| 513 | }) | ||
| 514 | .collect(); | ||
| 515 | |||
| 516 | frame.render_widget(Clear, popup); | ||
| 517 | let block = Block::default() | ||
| 518 | .borders(Borders::ALL) | ||
| 519 | .title(format!("Keys — {}", context.title())) | ||
| 520 | .border_style(Style::default().fg(Color::Yellow)); | ||
| 521 | frame.render_widget(Paragraph::new(Text::from(lines)).block(block), popup); | ||
| 457 | } | 522 | } |
| 458 | 523 | ||
| 459 | /// When the screen was loaded, and whether anything has happened since. | 524 | /// When the screen was loaded, and whether anything has happened since. |
| @@ -653,8 +718,8 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect, repo: Option<&Rep | |||
| 653 | return; | 718 | return; |
| 654 | } | 719 | } |
| 655 | 720 | ||
| 656 | // Handle commit browser modes | 721 | // The Event History panes |
| 657 | if app.mode == ViewMode::CommitList { | 722 | if app.mode == ViewMode::EventHistory { |
| 658 | let items: Vec<ListItem> = app | 723 | let items: Vec<ListItem> = app |
| 659 | .event_history | 724 | .event_history |
| 660 | .iter() | 725 | .iter() |
| @@ -687,7 +752,7 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect, repo: Option<&Rep | |||
| 687 | return; | 752 | return; |
| 688 | } | 753 | } |
| 689 | 754 | ||
| 690 | if app.mode == ViewMode::CommitDetail { | 755 | if app.mode == ViewMode::EventDetail { |
| 691 | let content = if let Some(idx) = app.event_list_state.selected() { | 756 | let content = if let Some(idx) = app.event_list_state.selected() { |
| 692 | if let Some((oid, evt)) = app.event_history.get(idx) { | 757 | if let Some((oid, evt)) = app.event_history.get(idx) { |
| 693 | format_event_detail(oid, evt, &app.issue_abbrev) | 758 | format_event_detail(oid, evt, &app.issue_abbrev) |
| @@ -1344,10 +1409,8 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { | |||
| 1344 | return; | 1409 | return; |
| 1345 | } | 1410 | } |
| 1346 | InputMode::ReviewVerdict => { | 1411 | InputMode::ReviewVerdict => { |
| 1347 | let para = Paragraph::new( | 1412 | let para = Paragraph::new(format!(" Review verdict: {}", super::keys::footer(app))) |
| 1348 | " Review verdict: a:approve r:request-changes c:comment Esc:cancel", | 1413 | .style(Style::default().bg(Color::Blue).fg(Color::White)); |
| 1349 | ) | ||
| 1350 | .style(Style::default().bg(Color::Blue).fg(Color::White)); | ||
| 1351 | frame.render_widget(para, area); | 1414 | frame.render_widget(para, area); |
| 1352 | return; | 1415 | return; |
| 1353 | } | 1416 | } |
| @@ -1362,39 +1425,15 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { | |||
| 1362 | return; | 1425 | return; |
| 1363 | } | 1426 | } |
| 1364 | 1427 | ||
| 1365 | let text = match app.mode { | 1428 | // Generated from the binding table, never written down twice. This line |
| 1366 | ViewMode::CommitList => " j/k:navigate Enter:detail Esc:back q:quit".to_string(), | 1429 | // was a set of string literals kept beside the arms they described, which |
| 1367 | ViewMode::CommitDetail => " j/k:scroll Esc:back q:quit".to_string(), | 1430 | // is how it came to advertise `c:events` for a key that had by then also |
| 1368 | // Ordered by what a reviewer reaches for, because this is one line and | 1431 | // become the comment key one pane over. |
| 1369 | // a narrow terminal clips the tail: the review loop first, then the | 1432 | // |
| 1370 | // way out, then the things you can also get to by other means. | 1433 | // It is one line and a narrow terminal clips the tail, so the table's |
| 1371 | ViewMode::PatchDetail => { | 1434 | // order is its priority order — and `?:keys` leads, because when a line |
| 1372 | " j/k:line c:comment R:review x:resolve a:answers Esc:back q:quit \ | 1435 | // cannot hold the surface its first duty is to name the key that can. |
| 1373 | [/]:revision d:interdiff o:checkout w:wrap" | 1436 | let text = format!(" {}", super::keys::footer(app)); |
| 1374 | .to_string() | ||
| 1375 | } | ||
| 1376 | ViewMode::Details => { | ||
| 1377 | let list_hint = match app.list_mode { | ||
| 1378 | ListMode::Issues => "P:patches", | ||
| 1379 | ListMode::Patches => "P:issues", | ||
| 1380 | }; | ||
| 1381 | let filter_hint = match app.status_filter { | ||
| 1382 | StatusFilter::Open => "a:show all", | ||
| 1383 | StatusFilter::All => "a:closed", | ||
| 1384 | StatusFilter::Closed => "a:open only", | ||
| 1385 | }; | ||
| 1386 | let mode_hint = match app.list_mode { | ||
| 1387 | ListMode::Issues => " c:events p:patch", | ||
| 1388 | // Advertised only where it does something: issues carry no | ||
| 1389 | // unresolved count, so `u` is inert on that pane. | ||
| 1390 | ListMode::Patches => " Enter:view patch u:unresolved", | ||
| 1391 | }; | ||
| 1392 | format!( | ||
| 1393 | " j/k:navigate Tab:pane {} {}{} /:search r:refresh q:quit", | ||
| 1394 | list_hint, filter_hint, mode_hint | ||
| 1395 | ) | ||
| 1396 | } | ||
| 1397 | }; | ||
| 1398 | let para = Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White)); | 1437 | let para = Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White)); |
| 1399 | frame.render_widget(para, area); | 1438 | frame.render_widget(para, area); |
| 1400 | } | 1439 | } |
tests/tui_review_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -538,3 +538,237 @@ fn no_editor_configured_says_so() { | |||
| 538 | "the dashboard hung or died with no editor configured" | 538 | "the dashboard hung or died with no editor configured" |
| 539 | ); | 539 | ); |
| 540 | } | 540 | } |
| 541 | |||
| 542 | /// A captured screen with its spacing removed. | ||
| 543 | /// | ||
| 544 | /// ratatui redraws only the cells that changed and skips the rest with a | ||
| 545 | /// cursor move, which `screen_text` strips — so a run of spaces between two | ||
| 546 | /// words can vanish from the capture while both words are genuinely on screen. | ||
| 547 | /// Comparing without spaces asks what these tests actually mean: is this text | ||
| 548 | /// on the screen, in this order. | ||
| 549 | fn squeezed(bytes: &[u8]) -> String { | ||
| 550 | screen_text(bytes).replace(' ', "") | ||
| 551 | } | ||
| 552 | |||
| 553 | // ── The issue side of the review loop (d7158619 / 02d3eb34) ───────────────── | ||
| 554 | |||
| 555 | /// The report that opened `d7158619`: a reader opened the dashboard on an | ||
| 556 | /// issue and could not answer it. The CLI could — `git-collab issue comment | ||
| 557 | /// <id> -b …` — so this is the binding and the target resolution, not the | ||
| 558 | /// write, and it goes through the same `$EDITOR` suspend the patch side uses. | ||
| 559 | #[test] | ||
| 560 | fn c_comments_on_the_issue_under_the_cursor() { | ||
| 561 | let repo = TestRepo::new("Reader", "reader@example.com"); | ||
| 562 | let id = repo.issue_open("something worth answering"); | ||
| 563 | let editor = editor_writing(&repo, "issue-comment.sh", "answering it from the dashboard"); | ||
| 564 | |||
| 565 | repo.run_dashboard_driven(100, 30, &[("EDITOR", &editor)], |dash| { | ||
| 566 | dash.press("c", "Comment added on issue"); | ||
| 567 | dash.send("q"); | ||
| 568 | }); | ||
| 569 | |||
| 570 | let shown = repo.run_ok(&["issue", "show", &id]); | ||
| 571 | assert!( | ||
| 572 | shown.contains("answering it from the dashboard"), | ||
| 573 | "the editor's body did not reach the issue:\n{}", | ||
| 574 | shown | ||
| 575 | ); | ||
| 576 | } | ||
| 577 | |||
| 578 | /// And from the list pane, where the old `pane == Pane::Detail` guard sent `c` | ||
| 579 | /// into silence. The dashboard opens focused on the list, so this is the press | ||
| 580 | /// the reporter actually made. | ||
| 581 | #[test] | ||
| 582 | fn c_comments_from_the_list_pane_where_it_used_to_do_nothing() { | ||
| 583 | let repo = TestRepo::new("Reader", "reader@example.com"); | ||
| 584 | let id = repo.issue_open("pressed from the list"); | ||
| 585 | let editor = editor_writing(&repo, "list-comment.sh", "no tab press was needed"); | ||
| 586 | |||
| 587 | repo.run_dashboard_driven(100, 30, &[("EDITOR", &editor)], |dash| { | ||
| 588 | // No `Tab`: focus is on the list, which is where `c` used to be inert. | ||
| 589 | dash.press("c", "Comment added on issue"); | ||
| 590 | dash.send("q"); | ||
| 591 | }); | ||
| 592 | |||
| 593 | let shown = repo.run_ok(&["issue", "show", &id]); | ||
| 594 | assert!( | ||
| 595 | shown.contains("no tab press was needed"), | ||
| 596 | "`c` on the list pane recorded nothing:\n{}", | ||
| 597 | shown | ||
| 598 | ); | ||
| 599 | } | ||
| 600 | |||
| 601 | /// The other half of the collision: the Event History is still reachable, on | ||
| 602 | /// `e`. | ||
| 603 | #[test] | ||
| 604 | fn e_opens_the_event_history() { | ||
| 605 | let repo = TestRepo::new("Reader", "reader@example.com"); | ||
| 606 | repo.issue_open("has a history"); | ||
| 607 | |||
| 608 | let out = repo.run_dashboard_driven(100, 30, &[], |dash| { | ||
| 609 | // The pane's rows rather than its title: see `squeezed`. | ||
| 610 | dash.press("e", "Issue Open |"); | ||
| 611 | dash.send("q"); | ||
| 612 | }); | ||
| 613 | assert!( | ||
| 614 | squeezed(&out.stdout).contains("EventHistory"), | ||
| 615 | "`e` did not open the Event History:\n{}", | ||
| 616 | screen_text(&out.stdout) | ||
| 617 | ); | ||
| 618 | } | ||
| 619 | |||
| 620 | /// An empty buffer abandons an issue comment, the way it does everywhere else | ||
| 621 | /// this program asks for prose, and the reader is told. | ||
| 622 | #[test] | ||
| 623 | fn an_empty_issue_comment_aborts_and_records_nothing() { | ||
| 624 | let repo = TestRepo::new("Reader", "reader@example.com"); | ||
| 625 | let id = repo.issue_open("left alone"); | ||
| 626 | let editor = repo.write_script("noop.sh", "#!/bin/sh\nexit 0\n"); | ||
| 627 | |||
| 628 | let out = repo.run_dashboard_driven(100, 30, &[("EDITOR", &editor)], |dash| { | ||
| 629 | dash.press("c", "Aborting"); | ||
| 630 | dash.send("q"); | ||
| 631 | }); | ||
| 632 | |||
| 633 | let shown = repo.run_ok(&["issue", "show", &id]); | ||
| 634 | assert!( | ||
| 635 | !shown.contains("--- Comments ---"), | ||
| 636 | "an aborted comment was recorded anyway:\n{}", | ||
| 637 | shown | ||
| 638 | ); | ||
| 639 | let text = screen_text(&out.stdout); | ||
| 640 | assert!( | ||
| 641 | text.contains("Aborting"), | ||
| 642 | "nothing said the comment was abandoned:\n{}", | ||
| 643 | text | ||
| 644 | ); | ||
| 645 | } | ||
| 646 | |||
| 647 | /// Triage, decided rather than deferred again: `C` closes the issue under the | ||
| 648 | /// cursor with a reason composed in `$EDITOR`. | ||
| 649 | #[test] | ||
| 650 | fn shift_c_closes_the_issue_with_a_reason() { | ||
| 651 | let repo = TestRepo::new("Reader", "reader@example.com"); | ||
| 652 | let id = repo.issue_open("done with this one"); | ||
| 653 | let editor = editor_writing(&repo, "close-reason.sh", "fixed by the patch above"); | ||
| 654 | |||
| 655 | repo.run_dashboard_driven(100, 30, &[("EDITOR", &editor)], |dash| { | ||
| 656 | dash.press("C", "closed"); | ||
| 657 | dash.send("q"); | ||
| 658 | }); | ||
| 659 | |||
| 660 | let shown = repo.run_ok(&["issue", "show", &id]); | ||
| 661 | assert!( | ||
| 662 | shown.contains("[closed]"), | ||
| 663 | "the issue is not closed:\n{}", | ||
| 664 | shown | ||
| 665 | ); | ||
| 666 | assert!( | ||
| 667 | shown.contains("fixed by the patch above"), | ||
| 668 | "the reason did not reach the event:\n{}", | ||
| 669 | shown | ||
| 670 | ); | ||
| 671 | } | ||
| 672 | |||
| 673 | /// The `a` filter offers "closed" and "all", so it has to be able to show a | ||
| 674 | /// closed issue. | ||
| 675 | /// | ||
| 676 | /// It could not: closing archives the ref, and the dashboard loaded issues | ||
| 677 | /// with `state::list_issues`, which excludes archived ones. So `a` cycled | ||
| 678 | /// through two settings that could only ever produce an empty list — a filter | ||
| 679 | /// naming a state it was structurally unable to reach. `issue list -a` and the | ||
| 680 | /// web UI both use the `_with_archived` listers for exactly this reason; the | ||
| 681 | /// dashboard now does too. Found by trying to reopen from the dashboard. | ||
| 682 | #[test] | ||
| 683 | fn the_status_filter_can_reach_a_closed_issue() { | ||
| 684 | let repo = TestRepo::new("Reader", "reader@example.com"); | ||
| 685 | // One word: a title is drawn into cells that were blank, so it survives | ||
| 686 | // the capture whole, while a needle like `(all)` cannot — the `(` of | ||
| 687 | // `Issues (open)` does not change when the word after it does, so it is | ||
| 688 | // never re-emitted and never appears in the stream. | ||
| 689 | let id = repo.issue_open("shut-but-not-gone"); | ||
| 690 | repo.issue_close(&id); | ||
| 691 | |||
| 692 | let out = repo.run_dashboard_driven(100, 30, &[], |dash| { | ||
| 693 | dash.press("a", "shut-but-not-gone"); | ||
| 694 | dash.send("q"); | ||
| 695 | }); | ||
| 696 | assert!( | ||
| 697 | squeezed(&out.stdout).contains("shut-but-not-gone"), | ||
| 698 | "the closed issue is unreachable from the dashboard:\n{}", | ||
| 699 | screen_text(&out.stdout) | ||
| 700 | ); | ||
| 701 | } | ||
| 702 | |||
| 703 | /// The same key the other way. Reopening asks for nothing, because it records | ||
| 704 | /// no reason and because it is itself the undo of a close. | ||
| 705 | #[test] | ||
| 706 | fn shift_c_reopens_a_closed_issue() { | ||
| 707 | let repo = TestRepo::new("Reader", "reader@example.com"); | ||
| 708 | let id = repo.issue_open("closed too early"); | ||
| 709 | repo.issue_close(&id); | ||
| 710 | |||
| 711 | repo.run_dashboard_driven(100, 30, &[], |dash| { | ||
| 712 | // Closed issues are hidden by the default filter, so show them first. | ||
| 713 | // No wait between the two: a pty hands keystrokes to the program in | ||
| 714 | // the order they were written, and the wait that matters is the one | ||
| 715 | // on the write `C` makes. | ||
| 716 | dash.send("a"); | ||
| 717 | dash.press("C", "reopened"); | ||
| 718 | dash.send("q"); | ||
| 719 | }); | ||
| 720 | |||
| 721 | let shown = repo.run_ok(&["issue", "show", &id]); | ||
| 722 | assert!( | ||
| 723 | shown.contains("[open]"), | ||
| 724 | "the issue did not reopen:\n{}", | ||
| 725 | shown | ||
| 726 | ); | ||
| 727 | } | ||
| 728 | |||
| 729 | // ── The bindings, on a real screen (02d3eb34) ─────────────────────────────── | ||
| 730 | |||
| 731 | /// A key this pane does not bind says so. The failure this replaces is not | ||
| 732 | /// that the key did nothing; it is that the reader could not tell "nothing is | ||
| 733 | /// bound here" from "the binding is broken". | ||
| 734 | #[test] | ||
| 735 | fn an_unbound_key_says_so_on_screen() { | ||
| 736 | let repo = TestRepo::new("Reader", "reader@example.com"); | ||
| 737 | repo.issue_open("look at me"); | ||
| 738 | |||
| 739 | let out = repo.run_dashboard_driven(100, 30, &[], |dash| { | ||
| 740 | dash.press("z", "does nothing"); | ||
| 741 | dash.send("q"); | ||
| 742 | }); | ||
| 743 | let text = screen_text(&out.stdout); | ||
| 744 | assert!( | ||
| 745 | text.contains("does nothing") && text.contains('?'), | ||
| 746 | "an unbound key left no trace on screen:\n{}", | ||
| 747 | text | ||
| 748 | ); | ||
| 749 | } | ||
| 750 | |||
| 751 | /// `?` lists this pane's keys. The footer is one line and this surface has | ||
| 752 | /// long since outgrown it — `o`, `w`, `Ctrl-E` and the rest have never been | ||
| 753 | /// advertised anywhere. | ||
| 754 | #[test] | ||
| 755 | fn question_mark_lists_this_panes_keys() { | ||
| 756 | let repo = TestRepo::new("Reader", "reader@example.com"); | ||
| 757 | repo.issue_open("look at me"); | ||
| 758 | |||
| 759 | let out = repo.run_dashboard_driven(100, 30, &[], |dash| { | ||
| 760 | dash.press("?", "Keys"); | ||
| 761 | dash.send("q"); | ||
| 762 | }); | ||
| 763 | let text = squeezed(&out.stdout); | ||
| 764 | assert!( | ||
| 765 | text.contains("Keys"), | ||
| 766 | "no key overlay appeared:\n{}", | ||
| 767 | screen_text(&out.stdout) | ||
| 768 | ); | ||
| 769 | assert!( | ||
| 770 | text.contains("checkoutthelinkedpatch"), | ||
| 771 | "the overlay does not list the keys the footer cannot fit:\n{}", | ||
| 772 | screen_text(&out.stdout) | ||
| 773 | ); | ||
| 774 | } | ||