History

fix(gix-tix): retain history selection after time travel

Restore the animation origin and its viewport row, and retain the mapped time-travel destination through refreshes. When a refresh no longer contains the selected object ID, match the displayed entry number within its visual base, then keep the nearest prior row before falling back to the top. Schedule refreshes when lazy finalization changes refs without producing a checkout notice.

Codex committed 2 months ago

Commit 68abfcd1 parent f49faf12 files · +151 −24Browse files

Unified · Split

2 files
  1. gix-tix/src/app.rs +145 −19
  2. gix-tix/src/lib.rs +6 −5
gix-tix/src/app.rs+145−19
@@ -524,7 +524,7 @@ pub(crate) struct App { · 523 unmodified lines
524 compressed_anchor: Option<ObjectId>,524 compressed_anchor: Option<ObjectId>,
525 compressed_segment: Option<ObjectId>,525 compressed_segment: Option<ObjectId>,
526 compressed_expanded: HashSet<ObjectId>,526 compressed_expanded: HashSet<ObjectId>,
527 time_travel_animation: Option<ObjectId>,527 time_travel_animation: Option<(ObjectId, usize)>,
528 attributions: Vec<Attribution>,528 attributions: Vec<Attribution>,
529 #[cfg(test)]529 #[cfg(test)]
530 test_lanes: Vec<String>,530 test_lanes: Vec<String>,
@@ -2339,6 +2339,12 @@ impl App { · 1808 unmodified lines
2339 return;2339 return;
2340 }2340 }
2341 let select_top = std::mem::take(&mut self.select_top_after_refresh);2341 let select_top = std::mem::take(&mut self.select_top_after_refresh);
2342 let previous_selection = if select_top { None } else { self.selected };
2343 let previous_visual_position = previous_selection.and_then(|index| {
2344 let count = self.visual_count(index)?;
2345 let base = self.rows.get(index.checked_add(count)?)?.id;
2346 Some((base, count, index))
2347 });
2342 let segment = (!select_top && self.selection_after_refresh.is_none())2348 let segment = (!select_top && self.selection_after_refresh.is_none())
2343 .then_some(self.compressed_segment)2349 .then_some(self.compressed_segment)
2344 .flatten();2350 .flatten();
@@ -2395,8 +2401,33 @@ impl App { · 50 unmodified lines
2395 self.graph = Some(graph);2401 self.graph = Some(graph);
2396 self.lane_time = Some(lane_time);2402 self.lane_time = Some(lane_time);
2397 self.update_worktree_head_descendants();2403 self.update_worktree_head_descendants();
2404 let visual_counts = &self
2405 .graph
2406 .as_ref()
2407 .expect("the completed graph was just stored")
2408 .visual_counts;
2409 let visual_selection = previous_visual_position.and_then(|(base, count, previous)| {
2410 self.rows
2411 .iter()
2412 .enumerate()
2413 .filter(|(index, _)| {
2414 visual_counts.get(*index) == Some(&count)
2415 && index
2416 .checked_add(count)
2417 .and_then(|base_index| self.rows.get(base_index))
2418 .is_some_and(|row| row.id == base)
2419 })
2420 .min_by_key(|(index, _)| index.abs_diff(previous))
2421 .map(|(index, _)| index)
2422 });
2398 self.selected = selected2423 self.selected = selected
2399 .and_then(|id| self.rows.iter().position(|row| row.id == id))2424 .and_then(|id| self.rows.iter().position(|row| row.id == id))
2425 .or(visual_selection)
2426 .or_else(|| {
2427 previous_selection
2428 .filter(|_| !self.rows.is_empty())
2429 .map(|index| index.min(self.rows.len() - 1))
2430 })
2400 .or_else(|| (!self.rows.is_empty()).then_some(0));2431 .or_else(|| (!self.rows.is_empty()).then_some(0));
2401 self.compressed_segment = segment;2432 self.compressed_segment = segment;
2402 if self.compressed_segment.is_some() {2433 if self.compressed_segment.is_some() {
@@ -3094,13 +3125,13 @@ impl App { · 691 unmodified lines
3094 }3125 }
3095 3126
3096 pub(crate) fn begin_time_travel_animation(&mut self) {3127 pub(crate) fn begin_time_travel_animation(&mut self) {
3097 let viewport_row = if self.alignment == Alignment::Compressed {3128 let viewport_row = self
3098 self.selected_history_index()3129 .selected_history_index()
3099 .map(|selected| selected.saturating_sub(self.offset))3130 .map(|selected| selected.saturating_sub(self.offset));
3100 } else {3131 self.time_travel_animation = self
3101 None3132 .selected
3102 };3133 .and_then(|index| self.rows.get(index))
3103 self.time_travel_animation = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id);3134 .map(|row| (row.id, viewport_row.unwrap_or_default()));
3104 if let (Some(selected), Some(viewport_row)) = (self.selected_history_index(), viewport_row) {3135 if let (Some(selected), Some(viewport_row)) = (self.selected_history_index(), viewport_row) {
3105 self.offset = selected.saturating_sub(viewport_row);3136 self.offset = selected.saturating_sub(viewport_row);
3106 }3137 }
@@ -3108,25 +3139,23 @@ impl App { · 1 unmodified line
3108 }3139 }
3109 3140
3110 pub(crate) fn finish_time_travel_animation(&mut self) {3141 pub(crate) fn finish_time_travel_animation(&mut self) {
3111 let viewport_row = if self.alignment == Alignment::Compressed {3142 let Some((origin, viewport_row)) = self.time_travel_animation else {
3112 self.selected_history_index()
3113 .map(|selected| selected.saturating_sub(self.offset))
3114 } else {
3115 None
3116 };
3117 if self.time_travel_animation.take().is_none() || self.alignment != Alignment::Compressed {
3118 return;3143 return;
3144 };
3145 self.select_commit(origin);
3146 self.time_travel_animation = None;
3147 if self.alignment == Alignment::Compressed {
3148 self.compressed_anchor = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id);
3149 self.rebuild_compressed_history();
3119 }3150 }
3120 self.compressed_anchor = self.selected.and_then(|index| self.rows.get(index)).map(|row| row.id);3151 if let Some(selected) = self.selected_history_index() {
3121 self.rebuild_compressed_history();
3122 if let (Some(selected), Some(viewport_row)) = (self.selected_history_index(), viewport_row) {
3123 self.offset = selected.saturating_sub(viewport_row);3152 self.offset = selected.saturating_sub(viewport_row);
3124 }3153 }
3125 self.ensure_visible();3154 self.ensure_visible();
3126 }3155 }
3127 3156
3128 pub(crate) fn time_travel_animation_origin(&self) -> Option<ObjectId> {3157 pub(crate) fn time_travel_animation_origin(&self) -> Option<ObjectId> {
3129 self.time_travel_animation3158 self.time_travel_animation.map(|(origin, _)| origin)
3130 }3159 }
3131 3160
3132 pub(crate) fn select_commit_after_refresh(&mut self, id: ObjectId) {3161 pub(crate) fn select_commit_after_refresh(&mut self, id: ObjectId) {
@@ -4245,6 +4274,60 @@ mod tests { · 1112 unmodified lines
4245 );4274 );
4246 }4275 }
4247 4276
4277 #[test]
4278 fn refresh_restores_a_rewritten_selection_by_its_visual_position() {
4279 let mut app = App::new(3);
4280 app.extend_commits(vec![
4281 row_with_parents(6, &[5]),
4282 row_with_parents(5, &[4]),
4283 row_with_parents(4, &[3]),
4284 row_with_parents(3, &[2]),
4285 row_with_parents(2, &[1]),
4286 row(1),
4287 ]);
4288 complete(&mut app);
4289 app.select_commit(id(4));
4290 assert_eq!(app.visual_count(app.selected.expect("commit 4 is selected")), Some(3));
4291 assert_eq!(app.offset, 0, "the original selection is at the bottom of the viewport");
4292
4293 let rows = app.store_commits(
4294 vec![
4295 numbered_row(17, Some(16)),
4296 numbered_row(16, Some(15)),
4297 numbered_row(15, Some(14)),
4298 numbered_row(14, Some(13)),
4299 numbered_row(13, Some(2)),
4300 numbered_row(2, Some(1)),
4301 numbered_row(1, None),
4302 ]
4303 .into(),
4304 );
4305 app.state = State::Computing;
4306 let (rows, graph, time) = compute_lanes(rows);
4307 app.finish_lane_computation(rows, graph, time);
4308
4309 assert_eq!(
4310 app.selected.map(|index| app.rows[index].id),
4311 Some(id(14)),
4312 "the same base and displayed entry number identify the rewritten commit"
4313 );
4314 assert_eq!(
4315 app.offset, 1,
4316 "the restored selection stays at the bottom of the viewport"
4317 );
4318
4319 let rows = app.store_commits(vec![numbered_row(9, Some(8)), numbered_row(8, None)].into());
4320 app.state = State::Computing;
4321 let (rows, graph, time) = compute_lanes(rows);
4322 app.finish_lane_computation(rows, graph, time);
4323
4324 assert_eq!(
4325 app.selected.map(|index| app.rows[index].id),
4326 Some(id(8)),
4327 "a missing visual position clamps the previous row before falling back to the top"
4328 );
4329 }
4330
4248 #[test]4331 #[test]
4249 fn selected_head_follows_an_external_rewrite() {4332 fn selected_head_follows_an_external_rewrite() {
4250 let mut app = App::new(10);4333 let mut app = App::new(10);
@@ -5360,6 +5443,49 @@ mod tests { · 1109 unmodified lines
5360 );5443 );
5361 }5444 }
5362 5445
5446 #[test]
5447 fn time_travel_animation_returns_to_its_origin() {
5448 let mut app = App::new(4);
5449 app.extend_commits(
5450 (1u16..=10)
5451 .rev()
5452 .map(|commit| numbered_row(commit, (commit > 1).then_some(commit - 1)))
5453 .collect::<Vec<_>>(),
5454 );
5455 complete(&mut app);
5456 app.select_commit(id(6));
5457 assert_eq!((app.selected, app.offset), (Some(4), 1));
5458
5459 app.begin_time_travel_animation();
5460 app.select_commit_for_time_travel(id(10));
5461 app.finish_time_travel_animation();
5462
5463 assert_eq!(
5464 (app.selected.map(|index| app.rows[index].id), app.offset),
5465 (Some(id(6)), 1),
5466 "the animated cursor returns to the requested entry and viewport row"
5467 );
5468
5469 app.alignment = Alignment::None;
5470 app.update(Action::ToggleAlign);
5471 let viewport_row = app
5472 .selected_history_index()
5473 .expect("the origin is displayed in compressed history")
5474 .saturating_sub(app.offset);
5475 app.begin_time_travel_animation();
5476 app.select_commit_for_time_travel(id(10));
5477 app.finish_time_travel_animation();
5478
5479 assert_eq!(app.selected.map(|index| app.rows[index].id), Some(id(6)));
5480 assert_eq!(
5481 app.selected_history_index()
5482 .expect("the origin remains displayed")
5483 .saturating_sub(app.offset),
5484 viewport_row,
5485 "compressed history restores the origin to its former viewport row"
5486 );
5487 }
5488
5363 #[test]5489 #[test]
5364 fn time_travel_animation_temporarily_expands_compressed_history() {5490 fn time_travel_animation_temporarily_expands_compressed_history() {
5365 let mut app = App::new(4);5491 let mut app = App::new(4);
gix-tix/src/lib.rs+6−5
@@ -3266,6 +3266,7 @@ fn event_loop( · 3265 unmodified lines
3266 match result {3266 match result {
3267 Ok(edit::time_travel::Perform::Complete {3267 Ok(edit::time_travel::Perform::Complete {
3268 notice: Some(notice),3268 notice: Some(notice),
3269 selected,
3269 ref_changes,3270 ref_changes,
3270 ..3271 ..
3271 }) => {3272 }) => {
@@ -3278,20 +3279,18 @@ fn event_loop( · 6 unmodified lines
3278 &ref_changes,3279 &ref_changes,
3279 notice,3280 notice,
3280 );3281 );
3281 if let Ok(head) = open_repository(&repository_path, repository_is_bare, false)3282 app.select_commit_after_refresh(selected);
3282 .and_then(|repo| Ok(repo.head_id()?.detach()))
3283 {
3284 app.select_commit_after_refresh(head);
3285 }
3286 invalidate_worktree_changes(&mut worktree_changes);3283 invalidate_worktree_changes(&mut worktree_changes);
3287 refresh_pending = true;3284 refresh_pending = true;
3288 }3285 }
3289 Ok(edit::time_travel::Perform::Complete {3286 Ok(edit::time_travel::Perform::Complete {
3290 notice: None,3287 notice: None,
3288 selected,
3291 ref_changes,3289 ref_changes,
3292 ..3290 ..
3293 }) => {3291 }) => {
3294 if !ref_changes.is_empty() {3292 if !ref_changes.is_empty() {
3293 app.select_commit_after_refresh(selected);
3295 leave_recorded_success(3294 leave_recorded_success(
3296 &mut app,3295 &mut app,
3297 &repository_path,3296 &repository_path,
@@ -3300,6 +3299,8 @@ fn event_loop( · 2 unmodified lines
3300 &ref_changes,3299 &ref_changes,
3301 "time-travelled",3300 "time-travelled",
3302 );3301 );
3302 invalidate_worktree_changes(&mut worktree_changes);
3303 refresh_pending = true;
3303 }3304 }
3304 }3305 }
3305 Ok(edit::time_travel::Perform::Conflict(conflict)) => {3306 Ok(edit::time_travel::Perform::Conflict(conflict)) => {

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