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
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 gauge1070- 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 intermediate1071 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 checked1079 omission deletes them and newly named refs require nonexistence. Refs checked
1075 out by linked worktrees are displayed normally and may move, with their index1080 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
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_conflicted1187 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);
gix-tix/src/lib.rs+207−18
@@ -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_graph3141 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
3975fn 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")]
3942fn draw(3995fn 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
5023enum TravelWorkerEvent<T> {
5024 Rebased(gix::ObjectId),
5025 Complete(Result<T>),
5026}
5027
5028fn 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
4979fn run_rebase_plan(5104fn 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")?;
gix-tix/src/ui.rs+43−5
@@ -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.selected1884 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))?;

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