From f4d471f116ee52b692f60e2d4fa5cb50d34d10c8 Mon Sep 17 00:00:00 2001 From: altsem Date: Mon, 17 Nov 2025 19:42:49 +0100 Subject: [PATCH] feat: open selected item in a preview split via `space` --- src/app.rs | 101 +++++++++++++++++++++++++++++++++------- src/config.rs | 1 + src/default_config.toml | 2 + src/ops/editor.rs | 58 ++++++++++++++++------- src/ops/log.rs | 6 +-- src/ops/mod.rs | 3 ++ src/ops/preview.rs | 67 ++++++++++++++++++++++++++ src/ops/show.rs | 44 ++++++++++------- src/ops/show_refs.rs | 11 +++-- src/ops/stash.rs | 2 +- src/ui.rs | 24 +++++++++- src/ui/menu.rs | 2 +- 12 files changed, 259 insertions(+), 62 deletions(-) create mode 100644 src/ops/preview.rs diff --git a/src/app.rs b/src/app.rs index 9c328707bc..39873602a0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -46,7 +46,8 @@ pub(crate) struct State { pub config: Arc, pending_keys: Vec<(KeyModifiers, KeyCode)>, pub quit: bool, - pub screens: Vec, + focused_screen: usize, + screens: Vec, pub pending_menu: Option, pending_cmd: Option<(Child, Arc>)>, enable_async_cmds: bool, @@ -99,6 +100,7 @@ impl App { enable_async_cmds, quit: false, screens, + focused_screen: 0, pending_cmd: None, pending_menu, current_cmd_log: CmdLog::new(), @@ -170,6 +172,17 @@ impl App { let handle_pending_cmd_result = self.handle_pending_cmd(); self.handle_result(handle_pending_cmd_result)?; + if self.state.is_preview_screen_open() { + // TODO Make this cleaner, don't need to update so often too + let item_data = &self.state.get_focused_screen().get_selected_item().data; + if let Some(mut action) = Op::Preview.clone().implementation().get_action(item_data) { + let result = Rc::get_mut(&mut action).unwrap()(self, term); + self.handle_result(result)?; + } else { + self.state.pop_screen(); + } + } + if self.state.needs_redraw { self.redraw_now(term)?; } @@ -278,10 +291,15 @@ impl App { match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { let click_y = mouse.row as usize; - if self.screen().is_valid_screen_line(click_y) { - let old_selected_item_id = self.screen().get_selected_item().id; + if self + .state + .get_focused_screen() + .is_valid_screen_line(click_y) + { + let old_selected_item_id = + self.state.get_focused_screen().get_selected_item().id; self.handle_op(Op::MoveToScreenLine(click_y), term)?; - let new_selected_item = self.screen().get_selected_item(); + let new_selected_item = self.state.get_focused_screen().get_selected_item(); if old_selected_item_id == new_selected_item.id { // If the item clicked was already the current item, then try to @@ -296,7 +314,11 @@ impl App { } MouseEventKind::Down(MouseButton::Right) => { let click_y = mouse.row as usize; - if self.screen().is_valid_screen_line(click_y) { + if self + .state + .get_focused_screen() + .is_valid_screen_line(click_y) + { self.handle_op(Op::MoveToScreenLine(click_y), term)?; self.handle_op(Op::Show, term)?; } @@ -304,13 +326,15 @@ impl App { MouseEventKind::ScrollUp => { let scroll_lines = self.state.config.general.mouse_scroll_lines; if scroll_lines > 0 { - self.screen_mut().scroll_up(scroll_lines); + self.state.get_mut_focused_screen().scroll_up(scroll_lines); } } MouseEventKind::ScrollDown => { let scroll_lines = self.state.config.general.mouse_scroll_lines; if scroll_lines > 0 { - self.screen_mut().scroll_down(scroll_lines); + self.state + .get_mut_focused_screen() + .scroll_down(scroll_lines); } } _ => return Ok(false), @@ -320,8 +344,7 @@ impl App { } pub(crate) fn handle_op(&mut self, op: Op, term: &mut Term) -> Res<()> { - let screen_ref = self.screen(); - let item_data = &screen_ref.get_selected_item().data; + let item_data = &self.state.get_focused_screen().get_selected_item().data; if let Some(mut action) = op.clone().implementation().get_action(item_data) { let result = Rc::get_mut(&mut action).unwrap()(self, term); @@ -351,14 +374,6 @@ impl App { self.state.pending_menu = root_menu(&self.state.config).map(PendingMenu::init) } - pub fn screen_mut(&mut self) -> &mut Screen { - self.state.screens.last_mut().expect("No screen") - } - - pub fn screen(&self) -> &Screen { - self.state.screens.last().expect("No screen") - } - /// Displays an `Info` message to the CmdLog. pub fn display_info>>(&mut self, message: S) { self.state @@ -531,7 +546,7 @@ impl App { } pub fn selected_rev(&self) -> Option { - match &self.screen().get_selected_item().data { + match &self.state.get_focused_screen().get_selected_item().data { ItemData::Reference { kind, .. } => match kind { RefKind::Tag(tag) => Some(tag.to_owned()), RefKind::Branch(branch) => Some(branch.to_owned()), @@ -620,6 +635,56 @@ impl App { } } +impl State { + pub fn get_focused_screen(&self) -> &Screen { + &self.screens[self.focused_screen] + } + + pub fn get_mut_focused_screen(&mut self) -> &mut Screen { + &mut self.screens[self.focused_screen] + } + + pub fn get_screen_count(&self) -> usize { + self.screens.len() + } + + pub fn return_to_main_screen(&mut self) { + self.screens.drain(1..); + self.focused_screen = 0; + } + + pub fn push_screen(&mut self, screen: Screen) { + self.screens.push(screen); + self.focused_screen = self.screens.len() - 1; + } + + pub fn pop_screen(&mut self) { + self.screens.pop(); + self.focused_screen = self.screens.len() - 1; + } + + pub fn set_preview_screen(&mut self, screen: Screen) { + if self.is_preview_screen_open() { + let last_i = self.screens.len() - 1; + self.screens[last_i] = screen; + } else { + self.screens.push(screen); + } + } + + pub fn focus_preview_screen(&mut self) { + self.focused_screen = self.screens.len() - 1; + } + + pub fn is_preview_screen_open(&self) -> bool { + self.focused_screen < self.screens.len() - 1 + } + + pub fn get_screens(&self) -> &[Screen] { + &self.screens + } +} + fn get_prompt_result(params: &PromptParams, app: &mut App) -> Res { let input = app.state.prompt.state.value(); let default_value = (params.create_default_value)(app); diff --git a/src/config.rs b/src/config.rs index 124fe9dddb..a0eb0d4e50 100644 --- a/src/config.rs +++ b/src/config.rs @@ -33,6 +33,7 @@ pub struct GeneralConfig { pub refresh_on_file_change: BoolConfigEntry, pub confirm_discard: ConfirmDiscardOption, pub collapsed_sections: Vec, + pub split_view: BoolConfigEntry, pub stash_list_limit: usize, pub recent_commits_limit: usize, pub mouse_support: bool, diff --git a/src/default_config.toml b/src/default_config.toml index 970f1298f0..ca0be4f131 100644 --- a/src/default_config.toml +++ b/src/default_config.toml @@ -9,6 +9,7 @@ confirm_quit.enabled = false # collapsed_sections = ["untracked", "recent_commits", "branch_status"] collapsed_sections = [] refresh_on_file_change.enabled = true +split_view.enabled = true stash_list_limit = 10 recent_commits_limit = 10 mouse_support = false @@ -101,6 +102,7 @@ root.half_page_up = ["ctrl+u"] root.half_page_down = ["ctrl+d"] root.show_refs = ["Y"] root.show = ["enter"] +root.preview = ["space"] root.discard = ["K"] root.stage = ["s"] root.unstage = ["u"] diff --git a/src/ops/editor.rs b/src/ops/editor.rs index 217b024e84..2297f5ba81 100644 --- a/src/ops/editor.rs +++ b/src/ops/editor.rs @@ -19,14 +19,21 @@ impl OpTrait for Quit { .map(|pending_menu| pending_menu.menu); if menu == root_menu(&app.state.config) { - if app.state.screens.len() == 1 { + if ({ + let this = &mut *app; + this.state.get_screen_count() + }) == 1 + { if app.state.config.general.confirm_quit.enabled { app.confirm(term, "Really quit? (y or n)")?; }; app.state.quit = true; } else { - app.state.screens.pop(); + { + let this = &mut *app; + this.state.pop_screen(); + }; } } else { app.close_menu(); @@ -137,7 +144,7 @@ impl OpTrait for ToggleSection { fn get_action(&self, _target: &ItemData) -> Option { Some(Rc::new(|app, _term| { app.close_menu(); - app.screen_mut().toggle_section(); + app.state.get_mut_focused_screen().toggle_section(); Ok(()) })) } @@ -152,7 +159,9 @@ impl OpTrait for MoveUp { fn get_action(&self, _target: &ItemData) -> Option { Some(Rc::new(|app, _term| { app.close_menu(); - app.screen_mut().select_previous(NavMode::Normal); + app.state + .get_mut_focused_screen() + .select_previous(NavMode::Normal); Ok(()) })) } @@ -167,7 +176,9 @@ impl OpTrait for MoveDown { fn get_action(&self, _target: &ItemData) -> Option { Some(Rc::new(|app, _term| { app.close_menu(); - app.screen_mut().select_next(NavMode::Normal); + app.state + .get_mut_focused_screen() + .select_next(NavMode::Normal); Ok(()) })) } @@ -182,7 +193,9 @@ impl OpTrait for MoveDownLine { fn get_action(&self, _target: &ItemData) -> Option { Some(Rc::new(|app, _term| { app.close_menu(); - app.screen_mut().select_next(NavMode::IncludeHunkLines); + app.state + .get_mut_focused_screen() + .select_next(NavMode::IncludeHunkLines); Ok(()) })) } @@ -197,7 +210,9 @@ impl OpTrait for MoveUpLine { fn get_action(&self, _target: &ItemData) -> Option { Some(Rc::new(|app, _term| { app.close_menu(); - app.screen_mut().select_previous(NavMode::IncludeHunkLines); + app.state + .get_mut_focused_screen() + .select_previous(NavMode::IncludeHunkLines); Ok(()) })) } @@ -213,7 +228,9 @@ impl OpTrait for MoveToScreenLine { let screen_line = self.0; Some(Rc::new(move |app, _term| { app.close_menu(); - app.screen_mut().move_cursor_to_screen_line(screen_line); + app.state + .get_mut_focused_screen() + .move_cursor_to_screen_line(screen_line); Ok(()) })) } @@ -228,8 +245,10 @@ impl OpTrait for MoveNextSection { fn get_action(&self, _target: &ItemData) -> Option { Some(Rc::new(|app, _term| { app.close_menu(); - let depth = app.screen().get_selected_item().depth; - app.screen_mut().select_next(NavMode::Siblings { depth }); + let depth = app.state.get_focused_screen().get_selected_item().depth; + app.state + .get_mut_focused_screen() + .select_next(NavMode::Siblings { depth }); Ok(()) })) } @@ -244,8 +263,9 @@ impl OpTrait for MovePrevSection { fn get_action(&self, _target: &ItemData) -> Option { Some(Rc::new(|app, _term| { app.close_menu(); - let depth = app.screen().get_selected_item().depth; - app.screen_mut() + let depth = app.state.get_focused_screen().get_selected_item().depth; + app.state + .get_mut_focused_screen() .select_previous(NavMode::Siblings { depth }); Ok(()) })) @@ -261,8 +281,14 @@ impl OpTrait for MoveParentSection { fn get_action(&self, _target: &ItemData) -> Option { Some(Rc::new(|app, _term| { app.close_menu(); - let depth = app.screen().get_selected_item().depth.saturating_sub(1); - app.screen_mut() + let depth = app + .state + .get_focused_screen() + .get_selected_item() + .depth + .saturating_sub(1); + app.state + .get_mut_focused_screen() .select_previous(NavMode::Siblings { depth }); Ok(()) })) @@ -278,7 +304,7 @@ impl OpTrait for HalfPageUp { fn get_action(&self, _target: &ItemData) -> Option { Some(Rc::new(|app, _term| { app.close_menu(); - app.screen_mut().scroll_half_page_up(); + app.state.get_mut_focused_screen().scroll_half_page_up(); Ok(()) })) } @@ -293,7 +319,7 @@ impl OpTrait for HalfPageDown { fn get_action(&self, _target: &ItemData) -> Option { Some(Rc::new(|app, _term| { app.close_menu(); - app.screen_mut().scroll_half_page_down(); + app.state.get_mut_focused_screen().scroll_half_page_down(); Ok(()) })) } diff --git a/src/ops/log.rs b/src/ops/log.rs index b6e1fe0ad6..ea69161c72 100644 --- a/src/ops/log.rs +++ b/src/ops/log.rs @@ -79,8 +79,8 @@ fn log_other(app: &mut App, _term: &mut Term, result: &str) -> Res<()> { } fn goto_log_screen(app: &mut App, rev: Option) { - app.state.screens.drain(1..); - let size = app.state.screens.last().unwrap().size; + app.state.return_to_main_screen(); + let size = app.state.get_focused_screen().size; let limit = *app .state .pending_menu @@ -99,7 +99,7 @@ fn goto_log_screen(app: &mut App, rev: Option) { app.close_menu(); - app.state.screens.push( + app.state.push_screen( screen::log::create( Arc::clone(&app.state.config), Rc::clone(&app.state.repo), diff --git a/src/ops/mod.rs b/src/ops/mod.rs index 4fdbe02fae..fd460506c7 100644 --- a/src/ops/mod.rs +++ b/src/ops/mod.rs @@ -17,6 +17,7 @@ pub(crate) mod editor; pub(crate) mod fetch; pub(crate) mod log; pub(crate) mod merge; +pub(crate) mod preview; pub(crate) mod pull; pub(crate) mod push; pub(crate) mod rebase; @@ -60,6 +61,7 @@ pub(crate) enum Op { FetchAll, FetchElsewhere, LogCurrent, + Preview, PullFromPushRemote, PullFromUpstream, PullFromElsewhere, @@ -150,6 +152,7 @@ impl Op { Op::FetchAll => Box::new(fetch::FetchAll), Op::FetchElsewhere => Box::new(fetch::FetchElsewhere), Op::LogCurrent => Box::new(log::LogCurrent), + Op::Preview => Box::new(preview::Preview), Op::PullFromPushRemote => Box::new(pull::PullFromPushRemote), Op::PullFromUpstream => Box::new(pull::PullFromUpstream), Op::PullFromElsewhere => Box::new(pull::PullFromElsewhere), diff --git a/src/ops/preview.rs b/src/ops/preview.rs new file mode 100644 index 0000000000..277f5f6161 --- /dev/null +++ b/src/ops/preview.rs @@ -0,0 +1,67 @@ +use std::{rc::Rc, sync::Arc}; + +use crate::{ + app::State, + error::Error, + item_data::{ItemData, RefKind}, + screen, +}; + +use super::{Action, OpTrait}; + +pub(crate) struct Preview; +impl OpTrait for Preview { + fn get_action(&self, target: &ItemData) -> Option { + match target { + ItemData::Commit { oid, .. } + | ItemData::Reference { + kind: RefKind::Tag(oid), + .. + } + | ItemData::Reference { + kind: RefKind::Branch(oid), + .. + } => goto_preview_screen(oid.clone()), + ItemData::Stash { stash_ref, .. } => goto_stash_preview_screen(stash_ref.clone()), + _ => None, + } + } + + fn is_target_op(&self) -> bool { + true + } + + fn display(&self, _: &State) -> String { + "Preview".into() + } +} + +fn goto_preview_screen(oid: String) -> Option { + Some(Rc::new(move |app, term| { + app.state.set_preview_screen( + screen::show::create( + Arc::clone(&app.state.config), + Rc::clone(&app.state.repo), + term.size().map_err(Error::Term)?, + oid.clone(), + ) + .expect("Couldn't create screen"), + ); + Ok(()) + })) +} + +fn goto_stash_preview_screen(oid: String) -> Option { + Some(Rc::new(move |app, term| { + app.state.set_preview_screen( + screen::show_stash::create( + Arc::clone(&app.state.config), + Rc::clone(&app.state.repo), + term.size().map_err(Error::Term)?, + oid.clone(), + ) + .expect("Couldn't create screen"), + ); + Ok(()) + })) +} diff --git a/src/ops/show.rs b/src/ops/show.rs index 242cdba252..f3de64fa5c 100644 --- a/src/ops/show.rs +++ b/src/ops/show.rs @@ -56,15 +56,19 @@ impl OpTrait for Show { fn goto_show_screen(r: String) -> Option { Some(Rc::new(move |app, term| { app.close_menu(); - app.state.screens.push( - screen::show::create( - Arc::clone(&app.state.config), - Rc::clone(&app.state.repo), - term.size().map_err(Error::Term)?, - r.clone(), - ) - .expect("Couldn't create screen"), - ); + if app.state.is_preview_screen_open() { + app.state.focus_preview_screen(); + } else { + app.state.push_screen( + screen::show::create( + Arc::clone(&app.state.config), + Rc::clone(&app.state.repo), + term.size().map_err(Error::Term)?, + r.clone(), + ) + .expect("Couldn't create screen"), + ); + } Ok(()) })) } @@ -72,15 +76,19 @@ fn goto_show_screen(r: String) -> Option { fn goto_show_stash_screen(stash_ref: String) -> Option { Some(Rc::new(move |app, term| { app.close_menu(); - app.state.screens.push( - screen::show_stash::create( - Arc::clone(&app.state.config), - Rc::clone(&app.state.repo), - term.size().map_err(Error::Term)?, - stash_ref.clone(), - ) - .expect("Couldn't create stash screen"), - ); + if app.state.is_preview_screen_open() { + app.state.focus_preview_screen(); + } else { + app.state.push_screen( + screen::show_stash::create( + Arc::clone(&app.state.config), + Rc::clone(&app.state.repo), + term.size().map_err(Error::Term)?, + stash_ref.clone(), + ) + .expect("Couldn't create stash screen"), + ); + } Ok(()) })) } diff --git a/src/ops/show_refs.rs b/src/ops/show_refs.rs index 8a84b122e0..7369c328b0 100644 --- a/src/ops/show_refs.rs +++ b/src/ops/show_refs.rs @@ -22,10 +22,15 @@ impl OpTrait for ShowRefs { } fn goto_refs_screen(app: &mut App) { - app.state.screens.drain(1..); - let size = app.state.screens.last().unwrap().size; + app.state.return_to_main_screen(); + + let size = { + let this = &mut *app; + this.state.get_focused_screen() + } + .size; app.close_menu(); - app.state.screens.push( + app.state.push_screen( screen::show_refs::create( Arc::clone(&app.state.config), Rc::clone(&app.state.repo), diff --git a/src/ops/stash.rs b/src/ops/stash.rs index 7dc47f0665..cddb1c627f 100644 --- a/src/ops/stash.rs +++ b/src/ops/stash.rs @@ -320,7 +320,7 @@ fn stash_drop(app: &mut App, term: &mut Term, input: &str) -> Res<()> { } fn selected_stash(app: &App) -> Option { - match app.screen().get_selected_item().data { + match app.state.get_focused_screen().get_selected_item().data { ItemData::Stash { id, .. } => Some(id.to_string()), _ => Some("0".to_string()), } diff --git a/src/ui.rs b/src/ui.rs index 5ea5be773c..71d7f3eb46 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -33,7 +33,26 @@ pub(crate) fn ui(frame: &mut Frame, state: &mut State) { layout.vertical(None, OPTS, |layout| { layout.vertical(None, OPTS.grow(), |layout| { - screen::layout_screen(layout, size, state.screens.last().unwrap()); + layout.horizontal(None, OPTS, |layout| { + if state.get_screen_count() > 1 && state.config.general.split_view.enabled { + let half_size = Size { + width: size.width / 2, + height: size.height, + }; + + let last = state.get_screens().iter().nth_back(0).unwrap(); + let second_to_last = state.get_screens().iter().nth_back(1).unwrap(); + if state.get_screen_count().is_multiple_of(2) { + screen::layout_screen(layout, half_size, second_to_last); + screen::layout_screen(layout, half_size, last); + } else { + screen::layout_screen(layout, half_size, last); + screen::layout_screen(layout, half_size, second_to_last); + }; + } else { + screen::layout_screen(layout, size, state.get_focused_screen()); + } + }); }); layout.vertical(None, OPTS, |layout| { @@ -54,7 +73,8 @@ pub(crate) fn ui(frame: &mut Frame, state: &mut State) { layout.clear(); - state.screens.last_mut().unwrap().size = frame.area().as_size(); + // TODO + state.get_mut_focused_screen().size = frame.area().as_size(); } struct SpanRef<'a>(&'a Cow<'a, str>, Style); diff --git a/src/ui/menu.rs b/src/ui/menu.rs index 780e1b4bac..094e048e5a 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -19,7 +19,7 @@ pub(crate) fn layout_menu<'a>(layout: &mut UiTree<'a>, state: &'a State, width: } let config = Arc::clone(&state.config); - let item = state.screens.last().unwrap().get_selected_item(); + let item = state.get_focused_screen().get_selected_item(); let style = &config.style; let arg_binds = config.bindings.arg_list(pending).collect::>();