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.
Unified · Split
| @@ -1068,8 +1068,13 @@ space first; changes blocks adapt within the remaining history width. · 1067 unmodified lines | ||
| 1068 | 1068 | the index tree, including additional staged changes, becomes the resolved tree. |
| 1069 | 1069 | There is no hidden sequencer state or separate continue/abort command. |
| 1070 | 1070 | - 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. | |
| 1073 | 1078 | - Displayed mutable refs follow their explicit locations in the edited todo; |
| 1074 | 1079 | omission deletes them and newly named refs require nonexistence. Refs checked |
| 1075 | 1080 | out by linked worktrees are displayed normally and may move, with their index |
| @@ -518,6 +518,7 @@ pub(crate) struct App { · 517 unmodified lines | ||
| 518 | 518 | compressed_anchor: Option<ObjectId>, |
| 519 | 519 | compressed_segment: Option<ObjectId>, |
| 520 | 520 | compressed_expanded: HashSet<ObjectId>, |
| 521 | + time_travel_animation: Option<ObjectId>, | |
| 521 | 522 | attributions: Vec<Attribution>, |
| 522 | 523 | #[cfg(test)] |
| 523 | 524 | test_lanes: Vec<String>, |
| @@ -625,6 +626,7 @@ impl App { · 101 unmodified lines | ||
| 625 | 626 | compressed_anchor: None, |
| 626 | 627 | compressed_segment: None, |
| 627 | 628 | compressed_expanded: HashSet::new(), |
| 629 | + time_travel_animation: None, | |
| 628 | 630 | attributions: Vec::new(), |
| 629 | 631 | #[cfg(test)] |
| 630 | 632 | test_lanes: Vec::new(), |
| @@ -1182,7 +1184,10 @@ impl App { · 551 unmodified lines | ||
| 1182 | 1184 | } |
| 1183 | 1185 | |
| 1184 | 1186 | 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 | |
| 1186 | 1191 | } |
| 1187 | 1192 | |
| 1188 | 1193 | fn rebuild_compressed_history(&mut self) { |
| @@ -2906,6 +2911,56 @@ impl App { · 1717 unmodified lines | ||
| 2906 | 2911 | } |
| 2907 | 2912 | } |
| 2908 | 2913 | |
| 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 | + | |
| 2909 | 2964 | pub(crate) fn select_commit_after_refresh(&mut self, id: ObjectId) { |
| 2910 | 2965 | self.selection_after_refresh = Some(id); |
| 2911 | 2966 | } |
| @@ -4957,6 +5012,65 @@ mod tests { · 2045 unmodified lines | ||
| 4957 | 5012 | assert_eq!(app.selected, Some(1)); |
| 4958 | 5013 | } |
| 4959 | 5014 | |
| 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 | + | |
| 4960 | 5074 | #[test] |
| 4961 | 5075 | fn compressed_history_retains_tips_and_selection_and_selects_segments() { |
| 4962 | 5076 | let mut app = App::new(2); |
| @@ -3137,20 +3137,54 @@ fn event_loop( · 3136 unmodified lines | ||
| 3137 | 3137 | fill_repository.retain = false; |
| 3138 | 3138 | fill_repository.retained = None; |
| 3139 | 3139 | let review_roots: Vec<_> = app.rows.iter().filter(|row| row.is_review).map(|row| row.id).collect(); |
| 3140 | + app.begin_time_travel_animation(); | |
| 3140 | 3141 | let result = history_graph |
| 3141 | 3142 | .as_ref() |
| 3142 | 3143 | .context("time-travel requires a completed history graph") |
| 3143 | 3144 | .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 | + }, | |
| 3152 | 3185 | ) |
| 3153 | 3186 | }); |
| 3187 | + app.finish_time_travel_animation(); | |
| 3154 | 3188 | match result { |
| 3155 | 3189 | Ok(edit::time_travel::Perform::Complete { |
| 3156 | 3190 | notice: Some(notice), |
| @@ -3938,6 +3972,25 @@ fn restore_change_selection(view: &mut app::ChangesView, changes: &Changes, reme · 781 unmodified lines | ||
| 3938 | 3972 | } |
| 3939 | 3973 | } |
| 3940 | 3974 | |
| 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 | + | |
| 3941 | 3994 | #[expect(clippy::too_many_arguments, reason = "drawing needs the complete view state")] |
| 3942 | 3995 | fn draw( |
| 3943 | 3996 | terminal: &mut ratatui::DefaultTerminal, |
| @@ -4192,16 +4245,7 @@ fn draw( · 248 unmodified lines | ||
| 4192 | 4245 | } |
| 4193 | 4246 | } |
| 4194 | 4247 | } |
| 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)?; | |
| 4205 | 4249 | } |
| 4206 | 4250 | let message = commit_message.as_ref().map(|(_, message)| message.as_bstr()); |
| 4207 | 4251 | let tree_changes = tree_changes.as_ref().map(|(_, changes)| changes); |
| @@ -4976,6 +5020,87 @@ enum RebaseWorkerEvent<T> { · 768 unmodified lines | ||
| 4976 | 5020 | Complete(Result<T>), |
| 4977 | 5021 | } |
| 4978 | 5022 | |
| 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 | + | |
| 4979 | 5104 | fn run_rebase_plan( |
| 4980 | 5105 | terminal: &mut ratatui::DefaultTerminal, |
| 4981 | 5106 | repository: gix::ThreadSafeRepository, |
| @@ -8138,6 +8263,70 @@ mod tests { · 3156 unmodified lines | ||
| 8138 | 8263 | assert!(todo_progress_visible(TODO_PROGRESS_DELAY)); |
| 8139 | 8264 | } |
| 8140 | 8265 | |
| 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 | + | |
| 8141 | 8330 | #[test] |
| 8142 | 8331 | fn continuing_a_conflict_commits_the_complete_resolved_index() -> gix_testtools::Result { |
| 8143 | 8332 | let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; |
| @@ -313,6 +313,7 @@ pub(crate) fn draw_with_worktree( · 312 unmodified lines | ||
| 313 | 313 | let [mut body, footer] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(frame.area()); |
| 314 | 314 | let full_body = body; |
| 315 | 315 | let selected_segment = app.selected_is_segment(); |
| 316 | + let time_travel_animation = app.time_travel_animation_origin().is_some(); | |
| 316 | 317 | let compared_parent = if app.changes_visible() && !selected_segment { |
| 317 | 318 | tree_changes.and_then(|changes| changes.parent.map(|parent| parent.id)) |
| 318 | 319 | } else { |
| @@ -680,11 +681,13 @@ pub(crate) fn draw_with_worktree( · 361 unmodified lines | ||
| 680 | 681 | .min(u16::MAX as usize); |
| 681 | 682 | let horizontal_offset = app.horizontal_offset.min(max_offset); |
| 682 | 683 | let selection_info = selection_info_line( |
| 683 | − (!selected_segment && app.changes_visible()) | |
| 684 | + (!time_travel_animation && !selected_segment && app.changes_visible()) | |
| 684 | 685 | .then_some(tree_changes) |
| 685 | 686 | .flatten() |
| 686 | 687 | .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(), | |
| 688 | 691 | ); |
| 689 | 692 | let selection_info_width = selection_info.width(); |
| 690 | 693 | let mut selection_info_area = None; |
| @@ -1878,9 +1881,9 @@ fn changes_summary(pane: ChangePane, app: &App, changes: &Changes) -> Line<'stat · 1187 unmodified lines | ||
| 1878 | 1881 | ChangePane::Tree => { |
| 1879 | 1882 | let label = changes.range.map_or_else( |
| 1880 | 1883 | || { |
| 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()) | |
| 1884 | 1887 | }, |
| 1885 | 1888 | |range| format!("{}..{}", range.base.to_hex_with_len(7), range.tip.to_hex_with_len(7)), |
| 1886 | 1889 | ); |
| @@ -2809,6 +2812,41 @@ mod tests { · 922 unmodified lines | ||
| 2809 | 2812 | assert!(spans[1].style.add_modifier.contains(Modifier::DIM)); |
| 2810 | 2813 | } |
| 2811 | 2814 | |
| 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 | + | |
| 2812 | 2850 | #[test] |
| 2813 | 2851 | fn prefix_popout_clamps_and_clears_the_row_beneath_it() -> Result<(), Box<dyn std::error::Error>> { |
| 2814 | 2852 | let mut terminal = Terminal::new(TestBackend::new(20, 2))?; |