History

feat(gix-tix): restore paged time-travel animation

Animate completed pending rebases through fixed history viewports, jumping by a full page when the selection reaches the top. Temporarily expand compressed history, keep cached panes tied to the animation origin, and preserve completed mutations if frame rendering fails.

Codex committed 2 months ago

Commit 2b338881 parent 0e8c2494 files · +372 −26Browse files

Unified · Split

4 files
  1. gix-tix/spec.md +7 −2
  2. gix-tix/src/app.rs +115 −1
  3. gix-tix/src/lib.rs +207 −18
  4. gix-tix/src/ui.rs +43 −5
gix-tix/spec.md+7−2
@@ -1068,8 +1068,13 @@ space first; changes blocks adapt within the remaining history width. · 1067 unmodified lines
10681068 the index tree, including additional staged changes, becomes the resolved tree.
10691069 There is no hidden sequencer state or separate continue/abort command.
10701070 - Interactive todo application runs on a scoped worker and shows its modal gauge
1071− after 300 ms. TUI and command-line time travel complete without intermediate
1072− history-selection frames.
1071+ after 300 ms. TUI time travel also runs on a scoped worker and follows completed
1072+ pending rebases with the history selection. The selection traverses each fixed
1073+ viewport from bottom to top; crossing its top jumps the viewport by one page and
1074+ places the selection at the bottom again. Compressed history temporarily uses
1075+ its canonical rows for these frames and is restored afterward. The first and
1076+ latest rows are drawn even for fast operations, with intermediate rows
1077+ coalesced to at most 60 fps. Command-line time travel does not animate.
10731078 - Displayed mutable refs follow their explicit locations in the edited todo;
10741079 omission deletes them and newly named refs require nonexistence. Refs checked
10751080 out by linked worktrees are displayed normally and may move, with their index
gix-tix/src/app.rs+115−1
@@ -518,6 +518,7 @@ pub(crate) struct App { · 517 unmodified lines
518518 compressed_anchor: Option<ObjectId>,
519519 compressed_segment: Option<ObjectId>,
520520 compressed_expanded: HashSet<ObjectId>,
521+ time_travel_animation: Option<ObjectId>,
521522 attributions: Vec<Attribution>,
522523 #[cfg(test)]
523524 test_lanes: Vec<String>,
@@ -625,6 +626,7 @@ impl App { · 101 unmodified lines
625626 compressed_anchor: None,
626627 compressed_segment: None,
627628 compressed_expanded: HashSet::new(),
629+ time_travel_animation: None,
628630 attributions: Vec::new(),
629631 #[cfg(test)]
630632 test_lanes: Vec::new(),
@@ -1182,7 +1184,10 @@ impl App { · 551 unmodified lines
11821184 }
11831185
11841186 fn compressed_history_suspended(&self) -> bool {
1185− self.pending_rebase_conflict.is_some() || self.rebase_continuation_pending || self.worktree_conflicted
1187+ self.time_travel_animation.is_some()
1188+ || self.pending_rebase_conflict.is_some()
1189+ || self.rebase_continuation_pending
1190+ || self.worktree_conflicted
11861191 }
11871192
11881193 fn rebuild_compressed_history(&mut self) {
@@ -2906,6 +2911,56 @@ impl App { · 1717 unmodified lines
29062911 }
29072912 }
29082913
2914+ pub(crate) fn select_commit_for_time_travel(&mut self, id: ObjectId) {
2915+ let Some(index) = self.rows.iter().position(|row| row.id == id) else {
2916+ return;
2917+ };
2918+ let previous_offset = self.offset;
2919+ self.select(index);
2920+ if let Some(selected) = self
2921+ .selected_history_index()
2922+ .filter(|selected| *selected < previous_offset)
2923+ {
2924+ self.offset = selected.saturating_sub(self.viewport_rows.max(1) - 1);
2925+ }
2926+ }
2927+
2928+ pub(crate) fn begin_time_travel_animation(&mut self) {
2929+ let viewport_row = if self.alignment == Alignment::Compressed {
2930+ self.selected_history_index()
2931+ .map(|selected| selected.saturating_sub(self.offset))
2932+ } else {
2933+ None
2934+ };
2935+ self.time_travel_animation = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id);
2936+ if let (Some(selected), Some(viewport_row)) = (self.selected_history_index(), viewport_row) {
2937+ self.offset = selected.saturating_sub(viewport_row);
2938+ }
2939+ self.ensure_visible();
2940+ }
2941+
2942+ pub(crate) fn finish_time_travel_animation(&mut self) {
2943+ let viewport_row = if self.alignment == Alignment::Compressed {
2944+ self.selected_history_index()
2945+ .map(|selected| selected.saturating_sub(self.offset))
2946+ } else {
2947+ None
2948+ };
2949+ if self.time_travel_animation.take().is_none() || self.alignment != Alignment::Compressed {
2950+ return;
2951+ }
2952+ self.compressed_anchor = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id);
2953+ self.rebuild_compressed_history();
2954+ if let (Some(selected), Some(viewport_row)) = (self.selected_history_index(), viewport_row) {
2955+ self.offset = selected.saturating_sub(viewport_row);
2956+ }
2957+ self.ensure_visible();
2958+ }
2959+
2960+ pub(crate) fn time_travel_animation_origin(&self) -> Option<ObjectId> {
2961+ self.time_travel_animation
2962+ }
2963+
29092964 pub(crate) fn select_commit_after_refresh(&mut self, id: ObjectId) {
29102965 self.selection_after_refresh = Some(id);
29112966 }
@@ -4957,6 +5012,65 @@ mod tests { · 2045 unmodified lines
49575012 assert_eq!(app.selected, Some(1));
49585013 }
49595014
5015+ #[test]
5016+ fn time_travel_selection_crosses_each_viewport_before_paging() {
5017+ let mut app = App::new(4);
5018+ app.extend_commits((1..=10).map(row).collect::<Vec<_>>());
5019+ complete(&mut app);
5020+
5021+ let mut positions = Vec::new();
5022+ for commit in (1u16..=10).rev() {
5023+ app.select_commit_for_time_travel(id(commit));
5024+ positions.push((app.selected, app.offset));
5025+ }
5026+
5027+ assert_eq!(
5028+ positions,
5029+ [
5030+ (Some(9), 6),
5031+ (Some(8), 6),
5032+ (Some(7), 6),
5033+ (Some(6), 6),
5034+ (Some(5), 2),
5035+ (Some(4), 2),
5036+ (Some(3), 2),
5037+ (Some(2), 2),
5038+ (Some(1), 0),
5039+ (Some(0), 0),
5040+ ],
5041+ "selection crosses a full viewport before the next page is bottom-aligned"
5042+ );
5043+ }
5044+
5045+ #[test]
5046+ fn time_travel_animation_temporarily_expands_compressed_history() {
5047+ let mut app = App::new(4);
5048+ app.extend_commits(
5049+ (1u16..=10)
5050+ .rev()
5051+ .map(|commit| numbered_row(commit, (commit > 1).then_some(commit - 1)))
5052+ .collect::<Vec<_>>(),
5053+ );
5054+ complete(&mut app);
5055+ app.set_view_tips(&[id(10)]);
5056+ app.alignment = Alignment::None;
5057+ app.update(Action::ToggleAlign);
5058+ assert!(app.history_len() < app.rows.len(), "the linear history is compressed");
5059+
5060+ app.begin_time_travel_animation();
5061+ assert_eq!(app.history_len(), app.rows.len(), "animation uses canonical rows");
5062+ let mut offsets = Vec::new();
5063+ for commit in 1..=10 {
5064+ app.select_commit_for_time_travel(id(commit));
5065+ offsets.push(app.offset);
5066+ }
5067+ assert_eq!(offsets, [6, 6, 6, 6, 2, 2, 2, 2, 0, 0]);
5068+ app.finish_time_travel_animation();
5069+
5070+ assert_eq!(app.alignment, Alignment::Compressed);
5071+ assert!(app.history_len() < app.rows.len(), "compression is restored");
5072+ }
5073+
49605074 #[test]
49615075 fn compressed_history_retains_tips_and_selection_and_selects_segments() {
49625076 let mut app = App::new(2);
gix-tix/src/lib.rs+207−18
@@ -3137,20 +3137,54 @@ fn event_loop( · 3136 unmodified lines
31373137 fill_repository.retain = false;
31383138 fill_repository.retained = None;
31393139 let review_roots: Vec<_> = app.rows.iter().filter(|row| row.is_review).map(|row| row.id).collect();
3140+ app.begin_time_travel_animation();
31403141 let result = history_graph
31413142 .as_ref()
31423143 .context("time-travel requires a completed history graph")
31433144 .and_then(|graph| {
3144− edit::time_travel::perform(
3145− &repository_path,
3146− repository_is_bare,
3147− id,
3148− graph,
3149− &review_roots,
3150− &revisions,
3151− false,
3145+ let repository = open_fill_repository(&repository_path, repository_is_bare)
3146+ .context("could not open repository for time-travel animation")?;
3147+ run_with_rebase_selection(
3148+ |report| {
3149+ edit::time_travel::perform_reporting_rebased(
3150+ &repository_path,
3151+ repository_is_bare,
3152+ id,
3153+ graph,
3154+ &review_roots,
3155+ &revisions,
3156+ false,
3157+ report,
3158+ )
3159+ },
3160+ |id| {
3161+ app.select_commit_for_time_travel(id);
3162+ let render_rows = terminal.get_frame().area().height.saturating_sub(1) as usize;
3163+ load_visible_history_metadata(&repository, &mut app, &authors, render_rows)?;
3164+ let message = commit_message.as_ref().map(|(_, message)| message.as_bstr());
3165+ let tree = tree_changes.as_ref().map(|(_, changes)| changes);
3166+ let worktree = worktree_changes
3167+ .as_ref()
3168+ .filter(|(marker, _)| *marker != usize::MAX)
3169+ .map(|(_, changes)| changes);
3170+ terminal
3171+ .draw(|frame| {
3172+ ui::draw_with_worktree(
3173+ frame,
3174+ &mut app,
3175+ &decorations,
3176+ &mailmap,
3177+ message,
3178+ tree,
3179+ worktree,
3180+ );
3181+ })
3182+ .context("could not draw time-travel animation")?;
3183+ Ok(())
3184+ },
31523185 )
31533186 });
3187+ app.finish_time_travel_animation();
31543188 match result {
31553189 Ok(edit::time_travel::Perform::Complete {
31563190 notice: Some(notice),
@@ -3938,6 +3972,25 @@ fn restore_change_selection(view: &mut app::ChangesView, changes: &Changes, reme · 781 unmodified lines
39383972 }
39393973 }
39403974
3975+fn load_visible_history_metadata(
3976+ repository: &gix::Repository,
3977+ app: &mut App,
3978+ authors: &SharedAuthors,
3979+ render_rows: usize,
3980+) -> Result<()> {
3981+ let start = app.offset.min(app.history_len());
3982+ let end = start.saturating_add(render_rows).min(app.history_len());
3983+ for index in app.visible_history_indices(start..end) {
3984+ if app.rows[index].metadata_loaded {
3985+ continue;
3986+ }
3987+ let (metadata, attributions) =
3988+ history::load_metadata(repository, app.rows[index].id, authors).context("could not load visible commit")?;
3989+ app.set_metadata(index, metadata, attributions);
3990+ }
3991+ Ok(())
3992+}
3993+
39413994 #[expect(clippy::too_many_arguments, reason = "drawing needs the complete view state")]
39423995 fn draw(
39433996 terminal: &mut ratatui::DefaultTerminal,
@@ -4192,16 +4245,7 @@ fn draw( · 248 unmodified lines
41924245 }
41934246 }
41944247 }
4195− let start = app.offset.min(app.history_len());
4196− let end = start.saturating_add(render_rows).min(app.history_len());
4197− for index in app.visible_history_indices(start..end) {
4198− if app.rows[index].metadata_loaded {
4199− continue;
4200− }
4201− let (metadata, attributions) = history::load_metadata(repository, app.rows[index].id, authors)
4202− .context("could not load visible commit")?;
4203− app.set_metadata(index, metadata, attributions);
4204− }
4248+ load_visible_history_metadata(repository, app, authors, render_rows)?;
42054249 }
42064250 let message = commit_message.as_ref().map(|(_, message)| message.as_bstr());
42074251 let tree_changes = tree_changes.as_ref().map(|(_, changes)| changes);
@@ -4976,6 +5020,87 @@ enum RebaseWorkerEvent<T> { · 768 unmodified lines
49765020 Complete(Result<T>),
49775021 }
49785022
5023+enum TravelWorkerEvent<T> {
5024+ Rebased(gix::ObjectId),
5025+ Complete(Result<T>),
5026+}
5027+
5028+fn run_with_rebase_selection<T: Send>(
5029+ operation: impl FnOnce(&mut dyn FnMut(gix::ObjectId)) -> Result<T> + Send,
5030+ mut render: impl FnMut(gix::ObjectId) -> Result<()>,
5031+) -> Result<T> {
5032+ std::thread::scope(|scope| {
5033+ let (sender, receiver) = mpsc::channel();
5034+ let worker = scope.spawn(move || {
5035+ let mut report = |id| {
5036+ let _ = sender.send(TravelWorkerEvent::Rebased(id));
5037+ };
5038+ let result = operation(&mut report);
5039+ let _ = sender.send(TravelWorkerEvent::Complete(result));
5040+ });
5041+ let mut last_draw: Option<Instant> = None;
5042+ let mut latest = None;
5043+ let mut rendered = None;
5044+ let mut complete = None;
5045+ let mut render_error = None;
5046+ let result = loop {
5047+ let event = if complete.is_some() {
5048+ None
5049+ } else if render_error.is_none()
5050+ && latest != rendered
5051+ && let Some(last_draw) = last_draw
5052+ {
5053+ match receiver.recv_timeout(FRAME_INTERVAL.saturating_sub(last_draw.elapsed())) {
5054+ Ok(event) => Some(event),
5055+ Err(mpsc::RecvTimeoutError::Timeout) => None,
5056+ Err(mpsc::RecvTimeoutError::Disconnected) => {
5057+ break Err(anyhow::anyhow!("time-travel worker stopped unexpectedly"));
5058+ }
5059+ }
5060+ } else {
5061+ match receiver.recv() {
5062+ Ok(event) => Some(event),
5063+ Err(_) => break Err(anyhow::anyhow!("time-travel worker stopped unexpectedly")),
5064+ }
5065+ };
5066+ match event {
5067+ Some(TravelWorkerEvent::Rebased(id)) => latest = Some(id),
5068+ Some(TravelWorkerEvent::Complete(result)) => complete = Some(result),
5069+ None => {}
5070+ }
5071+ if render_error.is_none()
5072+ && latest != rendered
5073+ && (last_draw.is_none()
5074+ || last_draw.is_some_and(|last_draw| last_draw.elapsed() >= FRAME_INTERVAL)
5075+ || complete.is_some())
5076+ {
5077+ if complete.is_some()
5078+ && let Some(last_draw) = last_draw
5079+ {
5080+ std::thread::sleep(FRAME_INTERVAL.saturating_sub(last_draw.elapsed()));
5081+ }
5082+ let id = latest.expect("a changed rebased commit is available");
5083+ if let Err(err) = render(id) {
5084+ render_error = Some(err);
5085+ continue;
5086+ }
5087+ rendered = latest;
5088+ last_draw = Some(Instant::now());
5089+ }
5090+ if let Some(result) = complete.take() {
5091+ break result;
5092+ }
5093+ };
5094+ if let Some(err) = render_error {
5095+ tracing::warn!(error = %err, "time-travel animation stopped");
5096+ }
5097+ if worker.join().is_err() {
5098+ return Err(anyhow::anyhow!("time-travel worker panicked"));
5099+ }
5100+ result
5101+ })
5102+}
5103+
49795104 fn run_rebase_plan(
49805105 terminal: &mut ratatui::DefaultTerminal,
49815106 repository: gix::ThreadSafeRepository,
@@ -8138,6 +8263,70 @@ mod tests { · 3156 unmodified lines
81388263 assert!(todo_progress_visible(TODO_PROGRESS_DELAY));
81398264 }
81408265
8266+ #[test]
8267+ fn fast_time_travel_draws_the_first_and_latest_rebased_commits() -> Result<()> {
8268+ let ids = [
8269+ gix::ObjectId::Sha1([1; 20]),
8270+ gix::ObjectId::Sha1([2; 20]),
8271+ gix::ObjectId::Sha1([3; 20]),
8272+ ];
8273+ let mut rendered = Vec::new();
8274+
8275+ run_with_rebase_selection(
8276+ |report| {
8277+ for id in ids {
8278+ report(id);
8279+ }
8280+ Ok(())
8281+ },
8282+ |id| {
8283+ rendered.push(id);
8284+ Ok(())
8285+ },
8286+ )?;
8287+
8288+ assert_eq!(rendered.first(), Some(&ids[0]), "the first completed rebase is drawn");
8289+ assert_eq!(rendered.last(), Some(&ids[2]), "the latest completed rebase is drawn");
8290+ Ok(())
8291+ }
8292+
8293+ #[test]
8294+ fn slow_time_travel_draws_every_rebased_selection() -> Result<()> {
8295+ let ids = [gix::ObjectId::Sha1([1; 20]), gix::ObjectId::Sha1([2; 20])];
8296+ let mut rendered = Vec::new();
8297+
8298+ run_with_rebase_selection(
8299+ |report| {
8300+ for id in ids {
8301+ report(id);
8302+ std::thread::sleep(FRAME_INTERVAL * 2);
8303+ }
8304+ Ok(())
8305+ },
8306+ |id| {
8307+ rendered.push(id);
8308+ Ok(())
8309+ },
8310+ )?;
8311+
8312+ assert_eq!(rendered, ids, "slow travel renders each completed rebase");
8313+ Ok(())
8314+ }
8315+
8316+ #[test]
8317+ fn failed_animation_frame_preserves_completed_time_travel() -> Result<()> {
8318+ let completed = run_with_rebase_selection(
8319+ |report| {
8320+ report(gix::ObjectId::Sha1([1; 20]));
8321+ Ok(42)
8322+ },
8323+ |_| Err(anyhow::anyhow!("frame failed")),
8324+ )?;
8325+
8326+ assert_eq!(completed, 42, "animation failure cannot hide a completed mutation");
8327+ Ok(())
8328+ }
8329+
81418330 #[test]
81428331 fn continuing_a_conflict_commits_the_complete_resolved_index() -> gix_testtools::Result {
81438332 let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?;
gix-tix/src/ui.rs+43−5
@@ -313,6 +313,7 @@ pub(crate) fn draw_with_worktree( · 312 unmodified lines
313313 let [mut body, footer] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(frame.area());
314314 let full_body = body;
315315 let selected_segment = app.selected_is_segment();
316+ let time_travel_animation = app.time_travel_animation_origin().is_some();
316317 let compared_parent = if app.changes_visible() && !selected_segment {
317318 tree_changes.and_then(|changes| changes.parent.map(|parent| parent.id))
318319 } else {
@@ -680,11 +681,13 @@ pub(crate) fn draw_with_worktree( · 361 unmodified lines
680681 .min(u16::MAX as usize);
681682 let horizontal_offset = app.horizontal_offset.min(max_offset);
682683 let selection_info = selection_info_line(
683− (!selected_segment && app.changes_visible())
684+ (!time_travel_animation && !selected_segment && app.changes_visible())
684685 .then_some(tree_changes)
685686 .flatten()
686687 .filter(|changes| changes.is_visible()),
687− (!selected_segment).then_some(app.selection_relation).flatten(),
688+ (!time_travel_animation && !selected_segment)
689+ .then_some(app.selection_relation)
690+ .flatten(),
688691 );
689692 let selection_info_width = selection_info.width();
690693 let mut selection_info_area = None;
@@ -1878,9 +1881,9 @@ fn changes_summary(pane: ChangePane, app: &App, changes: &Changes) -> Line<'stat · 1187 unmodified lines
18781881 ChangePane::Tree => {
18791882 let label = changes.range.map_or_else(
18801883 || {
1881− app.selected
1882− .and_then(|index| app.rows.get(index))
1883− .map_or_else(|| "-------".into(), |row| row.id.to_hex_with_len(7).to_string())
1884+ app.time_travel_animation_origin()
1885+ .or_else(|| app.selected.and_then(|index| app.rows.get(index)).map(|row| row.id))
1886+ .map_or_else(|| "-------".into(), |id| id.to_hex_with_len(7).to_string())
18841887 },
18851888 |range| format!("{}..{}", range.base.to_hex_with_len(7), range.tip.to_hex_with_len(7)),
18861889 );
@@ -2809,6 +2812,41 @@ mod tests { · 922 unmodified lines
28092812 assert!(spans[1].style.add_modifier.contains(Modifier::DIM));
28102813 }
28112814
2815+ #[test]
2816+ fn time_travel_animation_keeps_the_tree_summary_on_its_origin() {
2817+ let ids = [1, 2].map(|byte| gix::ObjectId::Sha1([byte; 20]));
2818+ let mut app = App::new(ids.len());
2819+ app.extend_commits(
2820+ ids.into_iter()
2821+ .map(|id| Commit {
2822+ id,
2823+ parent_ids: Default::default(),
2824+ author_time: gix::date::Time::default(),
2825+ committer_time: gix::date::Time::default(),
2826+ author: author(b"author", b"author@example.com"),
2827+ attributions: 0..0,
2828+ title: "subject".into(),
2829+ metadata_loaded: true,
2830+ has_agent_marker: false,
2831+ is_review: false,
2832+ signature: SignatureState::Unsigned,
2833+ })
2834+ .collect::<Vec<_>>(),
2835+ );
2836+ complete(&mut app);
2837+ app.select_commit(ids[1]);
2838+ app.begin_time_travel_animation();
2839+ app.select_commit_for_time_travel(ids[0]);
2840+
2841+ assert!(
2842+ changes_summary(ChangePane::Tree, &app, &Changes::default())
2843+ .to_string()
2844+ .contains("0202020"),
2845+ "the cached tree diff remains labelled with its origin while selection moves"
2846+ );
2847+ app.finish_time_travel_animation();
2848+ }
2849+
28122850 #[test]
28132851 fn prefix_popout_clamps_and_clears_the_row_beneath_it() -> Result<(), Box<dyn std::error::Error>> {
28142852 let mut terminal = Terminal::new(TestBackend::new(20, 2))?;

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