History

feat(gix-tix): replay diagnostic navigation inputs

Accept an optional keyboard-input string on --quit-on-finish and replay read-only actions after completed lanes have established the final layout. Keep the retained frame diagnostic safe by ignoring repository-changing, external-program, copy, and quit actions.

Codex committed 2 months ago

Commit b6096cf1 parent 980ce793 files · +138 −27Browse files

Unified · Split

3 files
  1. gix-tix/spec.md +6 −3
  2. gix-tix/src/command.rs +15 −6
  3. gix-tix/src/lib.rs +117 −18
gix-tix/spec.md+6−3
@@ -100,9 +100,12 @@ without trading responsiveness for metadata that is not visible. · 99 unmodified lines
100100 - `-x/--hide REVSPEC` excludes the revision and its reachable ancestry. The
101101 option may be repeated.
102102 - `-h/--help` prints Clap's standard help for `tix` and every subcommand.
103−- `--quit-on-finish` exits after traversal, lane computation, and one completed
104− frame, for measurement and non-interactive inspection. Its frame is drawn on
105− the normal screen and remains visible after exit.
103+- `--quit-on-finish[=INPUTS]` exits after traversal, lane computation, and one
104+ completed frame, for measurement and non-interactive inspection. Optional
105+ characters are replayed as read-only keyboard input before the retained final
106+ frame, allowing navigation such as `--quit-on-finish=jjjl`. Inputs that would
107+ mutate the repository, launch another program, or copy data are ignored. The
108+ frame is drawn on the normal screen and remains visible after exit.
106109 - `tix rebase todo [-x HIDDEN...] [--no-auto-hide]
107110 [--onto REV | --update-base] [TIP...]`
108111 writes a self-contained Markdown history-rebase plan to stdout. Visible tips
gix-tix/src/command.rs+15−6
@@ -20,9 +20,15 @@ mod travel; · 19 unmodified lines
2020 #[derive(Debug, clap::Args)]
2121 #[command(args_conflicts_with_subcommands = true)]
2222 pub struct Platform {
23− /// Exit once all commits and graph lanes have been computed.
24− #[arg(long)]
25− quit_on_finish: bool,
23+ /// Exit after the final frame, optionally replaying read-only INPUTS first.
24+ #[arg(
25+ long,
26+ value_name = "INPUTS",
27+ num_args = 0..=1,
28+ default_missing_value = "",
29+ require_equals = true
30+ )]
31+ quit_on_finish: Option<String>,
2632 /// Hide this revision and every commit reachable from it.
2733 #[arg(short = 'x', long, value_name = "REVSPEC")]
2834 hide: Vec<OsString>,
@@ -728,7 +734,7 @@ mod tests { · 699 unmodified lines
728734 fn parses_tui_options_and_top_level_commands() {
729735 let cli = Cli::try_parse_from(["tix", "--quit-on-finish", "-x", "main", "--hide", "tag", "topic"])
730736 .expect("TUI arguments parse");
731− assert!(cli.platform.quit_on_finish);
737+ assert_eq!(cli.platform.quit_on_finish, Some(String::new()));
732738 assert_eq!(cli.platform.hide, ["main", "tag"], "hide options append");
733739 assert_eq!(
734740 cli.platform.revisions,
@@ -737,6 +743,9 @@ mod tests { · 2 unmodified lines
737743 );
738744 assert!(cli.platform.command.is_none(), "omitting a command launches the TUI");
739745
746+ let cli = Cli::try_parse_from(["tix", "--quit-on-finish=jjjl"]).expect("diagnostic inputs parse");
747+ assert_eq!(cli.platform.quit_on_finish.as_deref(), Some("jjjl"));
748+
740749 let ref_tree = Cli::try_parse_from([
741750 "tix",
742751 "ref-tree",
@@ -1536,7 +1545,7 @@ mod tests { · 793 unmodified lines
15361545 let linked_target = linked.find_reference(retained_ref.as_ref())?.id().detach();
15371546
15381547 Platform {
1539− quit_on_finish: false,
1548+ quit_on_finish: None,
15401549 hide: Vec::new(),
15411550 command: Some(Command::Admin(Admin::ClearUndo)),
15421551 revisions: Vec::new(),
@@ -1558,7 +1567,7 @@ mod tests { · 15 unmodified lines
15581567 );
15591568
15601569 Platform {
1561− quit_on_finish: false,
1570+ quit_on_finish: None,
15621571 hide: Vec::new(),
15631572 command: Some(Command::Admin(Admin::ClearUndo)),
15641573 revisions: Vec::new(),
gix-tix/src/lib.rs+117−18
@@ -648,8 +648,8 @@ impl BuiltInDiff { · 647 unmodified lines
648648 /// Options for [`run()`].
649649 #[derive(Clone, Debug, Default)]
650650 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>,
653653 /// Revisions whose reachable commits should initially be hidden.
654654 pub hide: Vec<OsString>,
655655 }
@@ -707,7 +707,7 @@ pub fn run(repository: gix::ThreadSafeRepository, revisions: Vec<OsString>, mut · 51 unmodified lines
707707 "starting tix"
708708 );
709709 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();
711711 let mut terminal = if quit_on_finish {
712712 let (_, height) = terminal::size().context("could not determine terminal size")?;
713713 ratatui::try_init_with_options(TerminalOptions {
@@ -806,6 +806,13 @@ fn event_loop( · 92 unmodified lines
806806 commit_pane_background: Option<(u8, u8, u8)>,
807807 ) -> Result<Option<Duration>> {
808808 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();
809816 let mut repository_path = repository.git_dir().to_owned();
810817 let common_dir = normalize_common_dir(repository.common_dir.clone().unwrap_or_else(|| repository_path.clone()))?;
811818 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
13951402 fill_repository.retain = false;
13961403 fill_repository.retained = None;
13971404 }
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+ {
13991410 return Ok(app.lane_time);
14001411 }
14011412 continue;
@@ -1484,12 +1495,18 @@ fn event_loop( · 82 unmodified lines
14841495 .into_iter()
14851496 .flatten()
14861497 .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+ ),
14931510 },
14941511 };
14951512 let Some(terminal_event) = terminal_event else {
@@ -1670,14 +1687,18 @@ fn event_loop( · 174 unmodified lines
16701687 let key_pressed = is_key_press(&terminal_event);
16711688 let (mut action, repeats_history, is_repeat, throttles_draw) = match terminal_event {
16721689 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+ };
16811702 let repeats_history = retains_fill_repository(key.kind, action.as_ref(), app.changes_focus.is_some());
16821703 (action, repeats_history, key.kind == KeyEventKind::Repeat, false)
16831704 }
@@ -1728,6 +1749,11 @@ fn event_loop( · 44 unmodified lines
17281749 if !focused {
17291750 continue;
17301751 }
1752+ if diagnostic_input && action.is_none() {
1753+ dirty = true;
1754+ urgent = true;
1755+ continue;
1756+ }
17311757 if repeats_history || throttles_draw {
17321758 repeat_deadline = Some(Instant::now() + REPEAT_IDLE);
17331759 }
@@ -5857,6 +5883,49 @@ fn action(key: KeyEvent) -> Option<Action> { · 4123 unmodified lines
58575883 action_with_shortcut_groups(key, false, false, false, false, false)
58585884 }
58595885
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+
58605929 fn action_allowed_during_rebase_continuation(action: Option<&Action>, changes_focused: bool) -> bool {
58615930 matches!(
58625931 action,
@@ -7582,6 +7651,36 @@ mod tests { · 1719 unmodified lines
75827651 );
75837652 }
75847653
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+
75857684 #[test]
75867685 fn tree_is_direct_while_trailers_remain_scoped_to_the_view_prefix() {
75877686 let key = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE);

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t