| @@ -648,8 +648,8 @@ impl BuiltInDiff { · 647 unmodified lines |
| 648 | 648 | /// Options for [`run()`]. |
| 649 | 649 | #[derive(Clone, Debug, Default)] |
| 650 | 650 | pub struct Options { |
| 651 | | − /// Exit once all commits and graph lanes have been computed. |
| 652 | | − pub quit_on_finish: bool, |
| 651 | + /// Exit after the final frame, optionally replaying read-only keyboard input first. |
| 652 | + pub quit_on_finish: Option<String>, |
| 653 | 653 | /// Revisions whose reachable commits should initially be hidden. |
| 654 | 654 | pub hide: Vec<OsString>, |
| 655 | 655 | } |
| @@ -707,7 +707,7 @@ pub fn run(repository: gix::ThreadSafeRepository, revisions: Vec<OsString>, mut · 51 unmodified lines |
| 707 | 707 | "starting tix" |
| 708 | 708 | ); |
| 709 | 709 | let commit_pane_background = detect_commit_pane_background(); |
| 710 | | − let quit_on_finish = options.quit_on_finish; |
| 710 | + let quit_on_finish = options.quit_on_finish.is_some(); |
| 711 | 711 | let mut terminal = if quit_on_finish { |
| 712 | 712 | let (_, height) = terminal::size().context("could not determine terminal size")?; |
| 713 | 713 | ratatui::try_init_with_options(TerminalOptions { |
| @@ -806,6 +806,13 @@ fn event_loop( · 92 unmodified lines |
| 806 | 806 | commit_pane_background: Option<(u8, u8, u8)>, |
| 807 | 807 | ) -> Result<Option<Duration>> { |
| 808 | 808 | let Options { quit_on_finish, hide } = options; |
| 809 | + let mut quit_inputs: VecDeque<_> = quit_on_finish |
| 810 | + .as_deref() |
| 811 | + .unwrap_or_default() |
| 812 | + .chars() |
| 813 | + .map(diagnostic_key) |
| 814 | + .collect(); |
| 815 | + let quit_on_finish = quit_on_finish.is_some(); |
| 809 | 816 | let mut repository_path = repository.git_dir().to_owned(); |
| 810 | 817 | let common_dir = normalize_common_dir(repository.common_dir.clone().unwrap_or_else(|| repository_path.clone()))?; |
| 811 | 818 | let (mut view_repository, recovered_at_startup) = open_history_repository(&mut repository_path, &common_dir)?; |
| @@ -1395,7 +1402,11 @@ fn event_loop( · 583 unmodified lines |
| 1395 | 1402 | fill_repository.retain = false; |
| 1396 | 1403 | fill_repository.retained = None; |
| 1397 | 1404 | } |
| 1398 | | − if quit_on_finish && matches!(app.state, State::Complete) && lane_receiver.is_none() { |
| 1405 | + if quit_on_finish |
| 1406 | + && quit_inputs.is_empty() |
| 1407 | + && matches!(app.state, State::Complete) |
| 1408 | + && lane_receiver.is_none() |
| 1409 | + { |
| 1399 | 1410 | return Ok(app.lane_time); |
| 1400 | 1411 | } |
| 1401 | 1412 | continue; |
| @@ -1484,12 +1495,18 @@ fn event_loop( · 82 unmodified lines |
| 1484 | 1495 | .into_iter() |
| 1485 | 1496 | .flatten() |
| 1486 | 1497 | .min(); |
| 1487 | | − let terminal_event = match pending_terminal_event.take() { |
| 1488 | | − Some(event) => Some(event), |
| 1489 | | − None => match poll_timeout(streaming, events, dirty, last_draw.elapsed(), wake_after) { |
| 1490 | | − Some(timeout) if event::poll(timeout)? => Some(event::read()?), |
| 1491 | | − Some(_) => None, |
| 1492 | | − None => Some(event::read()?), |
| 1498 | + let (terminal_event, diagnostic_input) = match pending_terminal_event.take() { |
| 1499 | + Some(event) => (Some(event), false), |
| 1500 | + None => match next_diagnostic_input(&mut quit_inputs, app.state, lane_receiver.is_some()) { |
| 1501 | + Some(key) => (Some(TerminalEvent::Key(key)), true), |
| 1502 | + None => ( |
| 1503 | + match poll_timeout(streaming, events, dirty, last_draw.elapsed(), wake_after) { |
| 1504 | + Some(timeout) if event::poll(timeout)? => Some(event::read()?), |
| 1505 | + Some(_) => None, |
| 1506 | + None => Some(event::read()?), |
| 1507 | + }, |
| 1508 | + false, |
| 1509 | + ), |
| 1493 | 1510 | }, |
| 1494 | 1511 | }; |
| 1495 | 1512 | let Some(terminal_event) = terminal_event else { |
| @@ -1670,14 +1687,18 @@ fn event_loop( · 174 unmodified lines |
| 1670 | 1687 | let key_pressed = is_key_press(&terminal_event); |
| 1671 | 1688 | let (mut action, repeats_history, is_repeat, throttles_draw) = match terminal_event { |
| 1672 | 1689 | TerminalEvent::Key(key) => { |
| 1673 | | − let action = action_with_shortcut_groups( |
| 1674 | | − key, |
| 1675 | | − app.history_display_expanded, |
| 1676 | | − app.commit_expanded, |
| 1677 | | − app.actions_expanded, |
| 1678 | | − app.enrich_expanded, |
| 1679 | | − app.information_expanded || app.changes_focus.is_some(), |
| 1680 | | − ); |
| 1690 | + let action = if diagnostic_input { |
| 1691 | + diagnostic_action(key, &app) |
| 1692 | + } else { |
| 1693 | + action_with_shortcut_groups( |
| 1694 | + key, |
| 1695 | + app.history_display_expanded, |
| 1696 | + app.commit_expanded, |
| 1697 | + app.actions_expanded, |
| 1698 | + app.enrich_expanded, |
| 1699 | + app.information_expanded || app.changes_focus.is_some(), |
| 1700 | + ) |
| 1701 | + }; |
| 1681 | 1702 | let repeats_history = retains_fill_repository(key.kind, action.as_ref(), app.changes_focus.is_some()); |
| 1682 | 1703 | (action, repeats_history, key.kind == KeyEventKind::Repeat, false) |
| 1683 | 1704 | } |
| @@ -1728,6 +1749,11 @@ fn event_loop( · 44 unmodified lines |
| 1728 | 1749 | if !focused { |
| 1729 | 1750 | continue; |
| 1730 | 1751 | } |
| 1752 | + if diagnostic_input && action.is_none() { |
| 1753 | + dirty = true; |
| 1754 | + urgent = true; |
| 1755 | + continue; |
| 1756 | + } |
| 1731 | 1757 | if repeats_history || throttles_draw { |
| 1732 | 1758 | repeat_deadline = Some(Instant::now() + REPEAT_IDLE); |
| 1733 | 1759 | } |
| @@ -5857,6 +5883,49 @@ fn action(key: KeyEvent) -> Option<Action> { · 4123 unmodified lines |
| 5857 | 5883 | action_with_shortcut_groups(key, false, false, false, false, false) |
| 5858 | 5884 | } |
| 5859 | 5885 | |
| 5886 | +fn diagnostic_key(character: char) -> KeyEvent { |
| 5887 | + let code = match character { |
| 5888 | + '\t' => KeyCode::Tab, |
| 5889 | + '\n' | '\r' => KeyCode::Enter, |
| 5890 | + '\u{1b}' => KeyCode::Esc, |
| 5891 | + character => KeyCode::Char(character), |
| 5892 | + }; |
| 5893 | + let modifiers = if character.is_uppercase() { |
| 5894 | + KeyModifiers::SHIFT |
| 5895 | + } else { |
| 5896 | + KeyModifiers::NONE |
| 5897 | + }; |
| 5898 | + KeyEvent::new(code, modifiers) |
| 5899 | +} |
| 5900 | + |
| 5901 | +fn next_diagnostic_input(inputs: &mut VecDeque<KeyEvent>, state: State, lane_computing: bool) -> Option<KeyEvent> { |
| 5902 | + (state == State::Complete && !lane_computing) |
| 5903 | + .then(|| inputs.pop_front()) |
| 5904 | + .flatten() |
| 5905 | +} |
| 5906 | + |
| 5907 | +fn diagnostic_action(key: KeyEvent, app: &App) -> Option<Action> { |
| 5908 | + action_with_shortcut_groups( |
| 5909 | + key, |
| 5910 | + app.history_display_expanded, |
| 5911 | + app.commit_expanded, |
| 5912 | + app.actions_expanded, |
| 5913 | + app.enrich_expanded, |
| 5914 | + app.information_expanded || app.changes_focus.is_some(), |
| 5915 | + ) |
| 5916 | + .filter(|action| { |
| 5917 | + (action_allowed_during_rebase_continuation(Some(action), app.changes_focus.is_some()) |
| 5918 | + || matches!( |
| 5919 | + action, |
| 5920 | + Action::ToggleCommitGroup | Action::ToggleActions | Action::ToggleEnrich |
| 5921 | + )) |
| 5922 | + && !matches!( |
| 5923 | + action, |
| 5924 | + Action::Copy | Action::CopyPath(_) | Action::CopyAuthor | Action::ForceQuit | Action::Quit |
| 5925 | + ) |
| 5926 | + }) |
| 5927 | +} |
| 5928 | + |
| 5860 | 5929 | fn action_allowed_during_rebase_continuation(action: Option<&Action>, changes_focused: bool) -> bool { |
| 5861 | 5930 | matches!( |
| 5862 | 5931 | action, |
| @@ -7582,6 +7651,36 @@ mod tests { · 1719 unmodified lines |
| 7582 | 7651 | ); |
| 7583 | 7652 | } |
| 7584 | 7653 | |
| 7654 | + #[test] |
| 7655 | + fn diagnostic_inputs_replay_only_read_only_actions() { |
| 7656 | + let mut app = App::new(1); |
| 7657 | + assert_eq!(diagnostic_action(diagnostic_key('j'), &app), Some(Action::MoveDown)); |
| 7658 | + assert_eq!(diagnostic_action(diagnostic_key('l'), &app), Some(Action::ScrollRight)); |
| 7659 | + assert_eq!(diagnostic_action(diagnostic_key('G'), &app), Some(Action::Last)); |
| 7660 | + assert_eq!( |
| 7661 | + diagnostic_action(diagnostic_key('u'), &app), |
| 7662 | + None, |
| 7663 | + "undo is not replayed" |
| 7664 | + ); |
| 7665 | + |
| 7666 | + app.actions_expanded = true; |
| 7667 | + assert_eq!( |
| 7668 | + diagnostic_action(diagnostic_key('b'), &app), |
| 7669 | + None, |
| 7670 | + "repository-changing submenu actions are not replayed" |
| 7671 | + ); |
| 7672 | + } |
| 7673 | + |
| 7674 | + #[test] |
| 7675 | + fn diagnostic_inputs_wait_for_completed_lanes() { |
| 7676 | + let key = diagnostic_key('j'); |
| 7677 | + let mut inputs = VecDeque::from([key]); |
| 7678 | + assert!(next_diagnostic_input(&mut inputs, State::Loading, false).is_none()); |
| 7679 | + assert!(next_diagnostic_input(&mut inputs, State::Complete, true).is_none()); |
| 7680 | + assert_eq!(inputs.len(), 1, "premature checks do not consume input"); |
| 7681 | + assert_eq!(next_diagnostic_input(&mut inputs, State::Complete, false), Some(key)); |
| 7682 | + } |
| 7683 | + |
| 7585 | 7684 | #[test] |
| 7586 | 7685 | fn tree_is_direct_while_trailers_remain_scoped_to_the_view_prefix() { |
| 7587 | 7686 | let key = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE); |