| @@ -648,8 +648,8 @@ impl BuiltInDiff { · 647 unmodified lines |
| 648 | /// Options for [`run()`]. | 648 | /// Options for [`run()`]. |
| 649 | #[derive(Clone, Debug, Default)] | 649 | #[derive(Clone, Debug, Default)] |
| 650 | pub struct Options { | 650 | pub struct Options { |
| 651 | /// Exit once all commits and graph lanes have been computed. | 651 | /// Exit after the final frame, optionally replaying read-only keyboard input first. |
| 652 | pub quit_on_finish: bool, | 652 | pub quit_on_finish: Option<String>, |
| 653 | /// Revisions whose reachable commits should initially be hidden. | 653 | /// Revisions whose reachable commits should initially be hidden. |
| 654 | pub hide: Vec<OsString>, | 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 | "starting tix" | 707 | "starting tix" |
| 708 | ); | 708 | ); |
| 709 | let commit_pane_background = detect_commit_pane_background(); | 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 | let mut terminal = if quit_on_finish { | 711 | let mut terminal = if quit_on_finish { |
| 712 | let (_, height) = terminal::size().context("could not determine terminal size")?; | 712 | let (_, height) = terminal::size().context("could not determine terminal size")?; |
| 713 | ratatui::try_init_with_options(TerminalOptions { | 713 | ratatui::try_init_with_options(TerminalOptions { |
| @@ -806,6 +806,13 @@ fn event_loop( · 92 unmodified lines |
| 806 | commit_pane_background: Option<(u8, u8, u8)>, | 806 | commit_pane_background: Option<(u8, u8, u8)>, |
| 807 | ) -> Result<Option<Duration>> { | 807 | ) -> Result<Option<Duration>> { |
| 808 | let Options { quit_on_finish, hide } = options; | 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 | let mut repository_path = repository.git_dir().to_owned(); | 816 | let mut repository_path = repository.git_dir().to_owned(); |
| 810 | let common_dir = normalize_common_dir(repository.common_dir.clone().unwrap_or_else(|| repository_path.clone()))?; | 817 | let common_dir = normalize_common_dir(repository.common_dir.clone().unwrap_or_else(|| repository_path.clone()))?; |
| 811 | let (mut view_repository, recovered_at_startup) = open_history_repository(&mut repository_path, &common_dir)?; | 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 | fill_repository.retain = false; | 1402 | fill_repository.retain = false; |
| 1396 | fill_repository.retained = None; | 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 | return Ok(app.lane_time); | 1410 | return Ok(app.lane_time); |
| 1400 | } | 1411 | } |
| 1401 | continue; | 1412 | continue; |
| @@ -1484,12 +1495,18 @@ fn event_loop( · 82 unmodified lines |
| 1484 | .into_iter() | 1495 | .into_iter() |
| 1485 | .flatten() | 1496 | .flatten() |
| 1486 | .min(); | 1497 | .min(); |
| 1487 | let terminal_event = match pending_terminal_event.take() { | 1498 | let (terminal_event, diagnostic_input) = match pending_terminal_event.take() { |
| 1488 | Some(event) => Some(event), | 1499 | Some(event) => (Some(event), false), |
| 1489 | None => match poll_timeout(streaming, events, dirty, last_draw.elapsed(), wake_after) { | 1500 | None => match next_diagnostic_input(&mut quit_inputs, app.state, lane_receiver.is_some()) { |
| 1490 | Some(timeout) if event::poll(timeout)? => Some(event::read()?), | 1501 | Some(key) => (Some(TerminalEvent::Key(key)), true), |
| 1491 | Some(_) => None, | 1502 | None => ( |
| 1492 | None => Some(event::read()?), | 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 | let Some(terminal_event) = terminal_event else { | 1512 | let Some(terminal_event) = terminal_event else { |
| @@ -1670,14 +1687,18 @@ fn event_loop( · 174 unmodified lines |
| 1670 | let key_pressed = is_key_press(&terminal_event); | 1687 | let key_pressed = is_key_press(&terminal_event); |
| 1671 | let (mut action, repeats_history, is_repeat, throttles_draw) = match terminal_event { | 1688 | let (mut action, repeats_history, is_repeat, throttles_draw) = match terminal_event { |
| 1672 | TerminalEvent::Key(key) => { | 1689 | TerminalEvent::Key(key) => { |
| 1673 | let action = action_with_shortcut_groups( | 1690 | let action = if diagnostic_input { |
| 1674 | key, | 1691 | diagnostic_action(key, &app) |
| 1675 | app.history_display_expanded, | 1692 | } else { |
| 1676 | app.commit_expanded, | 1693 | action_with_shortcut_groups( |
| 1677 | app.actions_expanded, | 1694 | key, |
| 1678 | app.enrich_expanded, | 1695 | app.history_display_expanded, |
| 1679 | app.information_expanded || app.changes_focus.is_some(), | 1696 | app.commit_expanded, |
| 1680 | ); | 1697 | app.actions_expanded, |
| | 1698 | app.enrich_expanded, |
| | 1699 | app.information_expanded || app.changes_focus.is_some(), |
| | 1700 | ) |
| | 1701 | }; |
| 1681 | let repeats_history = retains_fill_repository(key.kind, action.as_ref(), app.changes_focus.is_some()); | 1702 | let repeats_history = retains_fill_repository(key.kind, action.as_ref(), app.changes_focus.is_some()); |
| 1682 | (action, repeats_history, key.kind == KeyEventKind::Repeat, false) | 1703 | (action, repeats_history, key.kind == KeyEventKind::Repeat, false) |
| 1683 | } | 1704 | } |
| @@ -1728,6 +1749,11 @@ fn event_loop( · 44 unmodified lines |
| 1728 | if !focused { | 1749 | if !focused { |
| 1729 | continue; | 1750 | continue; |
| 1730 | } | 1751 | } |
| | 1752 | if diagnostic_input && action.is_none() { |
| | 1753 | dirty = true; |
| | 1754 | urgent = true; |
| | 1755 | continue; |
| | 1756 | } |
| 1731 | if repeats_history || throttles_draw { | 1757 | if repeats_history || throttles_draw { |
| 1732 | repeat_deadline = Some(Instant::now() + REPEAT_IDLE); | 1758 | repeat_deadline = Some(Instant::now() + REPEAT_IDLE); |
| 1733 | } | 1759 | } |
| @@ -5857,6 +5883,49 @@ fn action(key: KeyEvent) -> Option<Action> { · 4123 unmodified lines |
| 5857 | action_with_shortcut_groups(key, false, false, false, false, false) | 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 | fn action_allowed_during_rebase_continuation(action: Option<&Action>, changes_focused: bool) -> bool { | 5929 | fn action_allowed_during_rebase_continuation(action: Option<&Action>, changes_focused: bool) -> bool { |
| 5861 | matches!( | 5930 | matches!( |
| 5862 | action, | 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 | #[test] | 7684 | #[test] |
| 7586 | fn tree_is_direct_while_trailers_remain_scoped_to_the_view_prefix() { | 7685 | fn tree_is_direct_while_trailers_remain_scoped_to_the_view_prefix() { |
| 7587 | let key = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE); | 7686 | let key = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE); |