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
100- `-x/--hide REVSPEC` excludes the revision and its reachable ancestry. The100- `-x/--hide REVSPEC` excludes the revision and its reachable ancestry. The
101 option may be repeated.101 option may be repeated.
102- `-h/--help` prints Clap's standard help for `tix` and every subcommand.102- `-h/--help` prints Clap's standard help for `tix` and every subcommand.
103- `--quit-on-finish` exits after traversal, lane computation, and one completed103- `--quit-on-finish[=INPUTS]` exits after traversal, lane computation, and one
104 frame, for measurement and non-interactive inspection. Its frame is drawn on104 completed frame, for measurement and non-interactive inspection. Optional
105 the normal screen and remains visible after exit.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.
106- `tix rebase todo [-x HIDDEN...] [--no-auto-hide]109- `tix rebase todo [-x HIDDEN...] [--no-auto-hide]
107 [--onto REV | --update-base] [TIP...]`110 [--onto REV | --update-base] [TIP...]`
108 writes a self-contained Markdown history-rebase plan to stdout. Visible tips111 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
20#[derive(Debug, clap::Args)]20#[derive(Debug, clap::Args)]
21#[command(args_conflicts_with_subcommands = true)]21#[command(args_conflicts_with_subcommands = true)]
22pub struct Platform {22pub struct Platform {
23 /// Exit once all commits and graph lanes have been computed.23 /// Exit after the final frame, optionally replaying read-only INPUTS first.
24 #[arg(long)]24 #[arg(
25 quit_on_finish: bool,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>,
26 /// Hide this revision and every commit reachable from it.32 /// Hide this revision and every commit reachable from it.
27 #[arg(short = 'x', long, value_name = "REVSPEC")]33 #[arg(short = 'x', long, value_name = "REVSPEC")]
28 hide: Vec<OsString>,34 hide: Vec<OsString>,
@@ -728,7 +734,7 @@ mod tests { · 699 unmodified lines
728 fn parses_tui_options_and_top_level_commands() {734 fn parses_tui_options_and_top_level_commands() {
729 let cli = Cli::try_parse_from(["tix", "--quit-on-finish", "-x", "main", "--hide", "tag", "topic"])735 let cli = Cli::try_parse_from(["tix", "--quit-on-finish", "-x", "main", "--hide", "tag", "topic"])
730 .expect("TUI arguments parse");736 .expect("TUI arguments parse");
731 assert!(cli.platform.quit_on_finish);737 assert_eq!(cli.platform.quit_on_finish, Some(String::new()));
732 assert_eq!(cli.platform.hide, ["main", "tag"], "hide options append");738 assert_eq!(cli.platform.hide, ["main", "tag"], "hide options append");
733 assert_eq!(739 assert_eq!(
734 cli.platform.revisions,740 cli.platform.revisions,
@@ -737,6 +743,9 @@ mod tests { · 2 unmodified lines
737 );743 );
738 assert!(cli.platform.command.is_none(), "omitting a command launches the TUI");744 assert!(cli.platform.command.is_none(), "omitting a command launches the TUI");
739 745
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
740 let ref_tree = Cli::try_parse_from([749 let ref_tree = Cli::try_parse_from([
741 "tix",750 "tix",
742 "ref-tree",751 "ref-tree",
@@ -1536,7 +1545,7 @@ mod tests { · 793 unmodified lines
1536 let linked_target = linked.find_reference(retained_ref.as_ref())?.id().detach();1545 let linked_target = linked.find_reference(retained_ref.as_ref())?.id().detach();
1537 1546
1538 Platform {1547 Platform {
1539 quit_on_finish: false,1548 quit_on_finish: None,
1540 hide: Vec::new(),1549 hide: Vec::new(),
1541 command: Some(Command::Admin(Admin::ClearUndo)),1550 command: Some(Command::Admin(Admin::ClearUndo)),
1542 revisions: Vec::new(),1551 revisions: Vec::new(),
@@ -1558,7 +1567,7 @@ mod tests { · 15 unmodified lines
1558 );1567 );
1559 1568
1560 Platform {1569 Platform {
1561 quit_on_finish: false,1570 quit_on_finish: None,
1562 hide: Vec::new(),1571 hide: Vec::new(),
1563 command: Some(Command::Admin(Admin::ClearUndo)),1572 command: Some(Command::Admin(Admin::ClearUndo)),
1564 revisions: Vec::new(),1573 revisions: Vec::new(),
gix-tix/src/lib.rs+117−18
@@ -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)]
650pub struct Options {650pub 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
5886fn 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
5901fn 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
5907fn 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
5860fn action_allowed_during_rebase_continuation(action: Option<&Action>, changes_focused: bool) -> bool {5929fn 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);

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