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 | the index tree, including additional staged changes, becomes the resolved tree. | 1068 | the index tree, including additional staged changes, becomes the resolved tree. |
| 1069 | There is no hidden sequencer state or separate continue/abort command. | 1069 | There is no hidden sequencer state or separate continue/abort command. |
| 1070 | - Interactive todo application runs on a scoped worker and shows its modal gauge | 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 | 1071 | after 300 ms. TUI time travel also runs on a scoped worker and follows completed |
| 1072 | history-selection frames. | 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 | - Displayed mutable refs follow their explicit locations in the edited todo; | 1078 | - Displayed mutable refs follow their explicit locations in the edited todo; |
| 1074 | omission deletes them and newly named refs require nonexistence. Refs checked | 1079 | omission deletes them and newly named refs require nonexistence. Refs checked |
| 1075 | out by linked worktrees are displayed normally and may move, with their index | 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 | compressed_anchor: Option<ObjectId>, | 518 | compressed_anchor: Option<ObjectId>, |
| 519 | compressed_segment: Option<ObjectId>, | 519 | compressed_segment: Option<ObjectId>, |
| 520 | compressed_expanded: HashSet<ObjectId>, | 520 | compressed_expanded: HashSet<ObjectId>, |
| 521 | time_travel_animation: Option<ObjectId>, | ||
| 521 | attributions: Vec<Attribution>, | 522 | attributions: Vec<Attribution>, |
| 522 | #[cfg(test)] | 523 | #[cfg(test)] |
| 523 | test_lanes: Vec<String>, | 524 | test_lanes: Vec<String>, |
| @@ -625,6 +626,7 @@ impl App { · 101 unmodified lines | |||
| 625 | compressed_anchor: None, | 626 | compressed_anchor: None, |
| 626 | compressed_segment: None, | 627 | compressed_segment: None, |
| 627 | compressed_expanded: HashSet::new(), | 628 | compressed_expanded: HashSet::new(), |
| 629 | time_travel_animation: None, | ||
| 628 | attributions: Vec::new(), | 630 | attributions: Vec::new(), |
| 629 | #[cfg(test)] | 631 | #[cfg(test)] |
| 630 | test_lanes: Vec::new(), | 632 | test_lanes: Vec::new(), |
| @@ -1182,7 +1184,10 @@ impl App { · 551 unmodified lines | |||
| 1182 | } | 1184 | } |
| 1183 | 1185 | ||
| 1184 | fn compressed_history_suspended(&self) -> bool { | 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 | fn rebuild_compressed_history(&mut self) { | 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 | pub(crate) fn select_commit_after_refresh(&mut self, id: ObjectId) { | 2964 | pub(crate) fn select_commit_after_refresh(&mut self, id: ObjectId) { |
| 2910 | self.selection_after_refresh = Some(id); | 2965 | self.selection_after_refresh = Some(id); |
| 2911 | } | 2966 | } |
| @@ -4957,6 +5012,65 @@ mod tests { · 2045 unmodified lines | |||
| 4957 | assert_eq!(app.selected, Some(1)); | 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 | #[test] | 5074 | #[test] |
| 4961 | fn compressed_history_retains_tips_and_selection_and_selects_segments() { | 5075 | fn compressed_history_retains_tips_and_selection_and_selects_segments() { |
| 4962 | let mut app = App::new(2); | 5076 | let mut app = App::new(2); |
| @@ -3137,20 +3137,54 @@ fn event_loop( · 3136 unmodified lines | |||
| 3137 | fill_repository.retain = false; | 3137 | fill_repository.retain = false; |
| 3138 | fill_repository.retained = None; | 3138 | fill_repository.retained = None; |
| 3139 | let review_roots: Vec<_> = app.rows.iter().filter(|row| row.is_review).map(|row| row.id).collect(); | 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 | let result = history_graph | 3141 | let result = history_graph |
| 3141 | .as_ref() | 3142 | .as_ref() |
| 3142 | .context("time-travel requires a completed history graph") | 3143 | .context("time-travel requires a completed history graph") |
| 3143 | .and_then(|graph| { | 3144 | .and_then(|graph| { |
| 3144 | edit::time_travel::perform( | 3145 | let repository = open_fill_repository(&repository_path, repository_is_bare) |
| 3145 | &repository_path, | 3146 | .context("could not open repository for time-travel animation")?; |
| 3146 | repository_is_bare, | 3147 | run_with_rebase_selection( |
| 3147 | id, | 3148 | |report| { |
| 3148 | graph, | 3149 | edit::time_travel::perform_reporting_rebased( |
| 3149 | &review_roots, | 3150 | &repository_path, |
| 3150 | &revisions, | 3151 | repository_is_bare, |
| 3151 | false, | 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 | match result { | 3188 | match result { |
| 3155 | Ok(edit::time_travel::Perform::Complete { | 3189 | Ok(edit::time_travel::Perform::Complete { |
| 3156 | notice: Some(notice), | 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 | #[expect(clippy::too_many_arguments, reason = "drawing needs the complete view state")] | 3994 | #[expect(clippy::too_many_arguments, reason = "drawing needs the complete view state")] |
| 3942 | fn draw( | 3995 | fn draw( |
| 3943 | terminal: &mut ratatui::DefaultTerminal, | 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()); | 4248 | load_visible_history_metadata(repository, app, authors, render_rows)?; |
| 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 | } | ||
| 4205 | } | 4249 | } |
| 4206 | let message = commit_message.as_ref().map(|(_, message)| message.as_bstr()); | 4250 | let message = commit_message.as_ref().map(|(_, message)| message.as_bstr()); |
| 4207 | let tree_changes = tree_changes.as_ref().map(|(_, changes)| changes); | 4251 | let tree_changes = tree_changes.as_ref().map(|(_, changes)| changes); |
| @@ -4976,6 +5020,87 @@ enum RebaseWorkerEvent<T> { · 768 unmodified lines | |||
| 4976 | Complete(Result<T>), | 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 | fn run_rebase_plan( | 5104 | fn run_rebase_plan( |
| 4980 | terminal: &mut ratatui::DefaultTerminal, | 5105 | terminal: &mut ratatui::DefaultTerminal, |
| 4981 | repository: gix::ThreadSafeRepository, | 5106 | repository: gix::ThreadSafeRepository, |
| @@ -8138,6 +8263,70 @@ mod tests { · 3156 unmodified lines | |||
| 8138 | assert!(todo_progress_visible(TODO_PROGRESS_DELAY)); | 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 | #[test] | 8330 | #[test] |
| 8142 | fn continuing_a_conflict_commits_the_complete_resolved_index() -> gix_testtools::Result { | 8331 | fn continuing_a_conflict_commits_the_complete_resolved_index() -> gix_testtools::Result { |
| 8143 | let fixture = gix_testtools::scripted_fixture_writable("rebase_conflict.sh")?; | 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 | let [mut body, footer] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(frame.area()); | 313 | let [mut body, footer] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(frame.area()); |
| 314 | let full_body = body; | 314 | let full_body = body; |
| 315 | let selected_segment = app.selected_is_segment(); | 315 | let selected_segment = app.selected_is_segment(); |
| 316 | let time_travel_animation = app.time_travel_animation_origin().is_some(); | ||
| 316 | let compared_parent = if app.changes_visible() && !selected_segment { | 317 | let compared_parent = if app.changes_visible() && !selected_segment { |
| 317 | tree_changes.and_then(|changes| changes.parent.map(|parent| parent.id)) | 318 | tree_changes.and_then(|changes| changes.parent.map(|parent| parent.id)) |
| 318 | } else { | 319 | } else { |
| @@ -680,11 +681,13 @@ pub(crate) fn draw_with_worktree( · 361 unmodified lines | |||
| 680 | .min(u16::MAX as usize); | 681 | .min(u16::MAX as usize); |
| 681 | let horizontal_offset = app.horizontal_offset.min(max_offset); | 682 | let horizontal_offset = app.horizontal_offset.min(max_offset); |
| 682 | let selection_info = selection_info_line( | 683 | let selection_info = selection_info_line( |
| 683 | (!selected_segment && app.changes_visible()) | 684 | (!time_travel_animation && !selected_segment && app.changes_visible()) |
| 684 | .then_some(tree_changes) | 685 | .then_some(tree_changes) |
| 685 | .flatten() | 686 | .flatten() |
| 686 | .filter(|changes| changes.is_visible()), | 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 | let selection_info_width = selection_info.width(); | 692 | let selection_info_width = selection_info.width(); |
| 690 | let mut selection_info_area = None; | 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 | ChangePane::Tree => { | 1881 | ChangePane::Tree => { |
| 1879 | let label = changes.range.map_or_else( | 1882 | let label = changes.range.map_or_else( |
| 1880 | || { | 1883 | || { |
| 1881 | app.selected | 1884 | app.time_travel_animation_origin() |
| 1882 | .and_then(|index| app.rows.get(index)) | 1885 | .or_else(|| app.selected.and_then(|index| app.rows.get(index)).map(|row| row.id)) |
| 1883 | .map_or_else(|| "-------".into(), |row| row.id.to_hex_with_len(7).to_string()) | 1886 | .map_or_else(|| "-------".into(), |id| id.to_hex_with_len(7).to_string()) |
| 1884 | }, | 1887 | }, |
| 1885 | |range| format!("{}..{}", range.base.to_hex_with_len(7), range.tip.to_hex_with_len(7)), | 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 | assert!(spans[1].style.add_modifier.contains(Modifier::DIM)); | 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 | #[test] | 2850 | #[test] |
| 2813 | fn prefix_popout_clamps_and_clears_the_row_beneath_it() -> Result<(), Box<dyn std::error::Error>> { | 2851 | fn prefix_popout_clamps_and_clears_the_row_beneath_it() -> Result<(), Box<dyn std::error::Error>> { |
| 2814 | let mut terminal = Terminal::new(TestBackend::new(20, 2))?; | 2852 | let mut terminal = Terminal::new(TestBackend::new(20, 2))?; |