diff --git a/src/commander/jj.rs b/src/commander/jj.rs index c97c6ae8..42bfffbb 100644 --- a/src/commander/jj.rs +++ b/src/commander/jj.rs @@ -44,6 +44,18 @@ impl Commander { .context("Failed executing jj describe") } + /// Rebase changes. Maps to `jj rebase -s -d ` or similar + #[instrument(level = "trace", skip(self))] + pub fn run_rebase( + &mut self, + src_mode: &str, + src_rev: &str, + tgt_mode: &str, + tgt_rev: &str, + ) -> Result<()> { + Ok(self.execute_void_jj_command(vec!["rebase", src_mode, src_rev, tgt_mode, tgt_rev])?) + } + /// Squash changes. Maps to `jj squash -u --into ` #[instrument(level = "trace", skip(self))] pub fn run_squash(&mut self, revision: &str, ignore_immutable: bool) -> Result<()> { diff --git a/src/keybinds/config.rs b/src/keybinds/config.rs index 38f4329c..b11e2161 100644 --- a/src/keybinds/config.rs +++ b/src/keybinds/config.rs @@ -41,6 +41,7 @@ pub struct LogTabKeybindsConfig { pub edit_revset: Option, pub set_bookmark: Option, pub open_files: Option, + pub rebase: Option, pub push: Option, pub push_new: Option, diff --git a/src/keybinds/log_tab.rs b/src/keybinds/log_tab.rs index 667752e4..cc43f517 100644 --- a/src/keybinds/log_tab.rs +++ b/src/keybinds/log_tab.rs @@ -31,6 +31,7 @@ pub enum LogTabEvent { CreateNew { describe: bool, }, + Rebase, Squash { ignore_immutable: bool, }, @@ -77,6 +78,7 @@ impl Default for LogTabKeybinds { LogTabEvent::Refresh => "f5", LogTabEvent::CreateNew { describe: false } => "n", LogTabEvent::CreateNew { describe: true } => "shift+n", + LogTabEvent::Rebase => "ctrl+r", LogTabEvent::Squash { ignore_immutable: false } => "s", LogTabEvent::Squash { ignore_immutable: true } => "shift+s", LogTabEvent::EditChange { ignore_immutable: false } => "e", @@ -131,6 +133,7 @@ impl LogTabKeybinds { LogTabEvent::EditRevset => config.edit_revset, LogTabEvent::SetBookmark => config.set_bookmark, LogTabEvent::OpenFiles => config.open_files, + LogTabEvent::Rebase => config.rebase, event_push(false, false) => config.push, event_push(false, true) => config.push_new, event_push(true, false) => config.push_all, @@ -156,6 +159,7 @@ impl LogTabKeybinds { LogTabEvent::CreateNew { describe: false } => "new change", LogTabEvent::CreateNew { describe: true } => "new with message", LogTabEvent::Abandon => "abandon change", + LogTabEvent::Rebase => "rebase @ to the selected change", LogTabEvent::Squash { ignore_immutable: false } => "squash @ into the selected change", LogTabEvent::Squash { ignore_immutable: true } => "squash @ into the selected change ignoring immutability", LogTabEvent::SetBookmark => "set bookmark", diff --git a/src/keybinds/mod.rs b/src/keybinds/mod.rs index e8c5f9a8..1c5aca02 100644 --- a/src/keybinds/mod.rs +++ b/src/keybinds/mod.rs @@ -8,6 +8,7 @@ pub use log_tab::{LogTabEvent, LogTabKeybinds}; mod config; mod keybinds_store; mod log_tab; +pub mod rebase_popup; /*#[derive(Debug)] pub struct Keybinds { diff --git a/src/keybinds/rebase_popup.rs b/src/keybinds/rebase_popup.rs new file mode 100644 index 00000000..d7fd50bf --- /dev/null +++ b/src/keybinds/rebase_popup.rs @@ -0,0 +1,73 @@ +/*! Key bindings specific for rebase popup */ + +use ratatui::crossterm::event::KeyEvent; +use std::str::FromStr; // used by set_keybinds macro + +use super::{Shortcut, keybinds_store::KeybindsStore}; +use crate::set_keybinds; + +/// How should rebase cut revisions from source +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum CutOption { + IncludeDescendants, // -s + IncludeBranch, // -b + SingleRevision, // -r +} + +/// How should rebase paste revisions at target +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum PasteOption { + NewBranch, // -d + InsertAfter, // -A + InsertBefore, // -B +} + +/// Actions available inside a RebasePopup +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum PopupAction { + None, + Ok, + Cancel, + SetSourceMode(CutOption), + SetTargetMode(PasteOption), +} + +fn default_keybinds() -> KeybindsStore { + let mut keys = KeybindsStore::::default(); + set_keybinds!( + keys, + PopupAction::Ok => "enter", + PopupAction::Cancel => "esc", + PopupAction::Cancel => "q", + PopupAction::SetSourceMode(CutOption::IncludeDescendants) => "s", + PopupAction::SetSourceMode(CutOption::IncludeBranch) => "b", + PopupAction::SetSourceMode(CutOption::SingleRevision) => "r", + PopupAction::SetTargetMode(PasteOption::NewBranch) => "d", + PopupAction::SetTargetMode(PasteOption::InsertAfter) => "shift+a", + PopupAction::SetTargetMode(PasteOption::InsertBefore) => "shift+b", + ); + keys +} + +#[derive(Debug)] +pub struct Keybinds { + keys: KeybindsStore, +} + +impl Default for Keybinds { + fn default() -> Self { + Self { + keys: default_keybinds(), + } + } +} + +impl Keybinds { + pub fn match_event(&self, event: KeyEvent) -> PopupAction { + if let Some(action) = self.keys.match_event(event) { + action + } else { + PopupAction::None + } + } +} diff --git a/src/ui/log_tab.rs b/src/ui/log_tab.rs index cc69aaa6..1f5d98de 100644 --- a/src/ui/log_tab.rs +++ b/src/ui/log_tab.rs @@ -24,6 +24,7 @@ use crate::{ message_popup::MessagePopup, panel::DetailsPanel, panel::LogPanel, + rebase_popup::RebasePopup, utils::{centered_rect, centered_rect_line_height, tabs_to_spaces}, }, }; @@ -66,6 +67,8 @@ pub struct LogTab<'a> { describe_textarea: Option>, describe_after_new: bool, + rebase_popup: Option, + squash_ignore_immutable: bool, edit_ignore_immutable: bool, @@ -121,6 +124,8 @@ impl<'a> LogTab<'a> { describe_textarea: None, describe_after_new: false, + rebase_popup: None, + squash_ignore_immutable: false, edit_ignore_immutable: false, @@ -195,6 +200,14 @@ impl<'a> LogTab<'a> { .open(); self.describe_after_new = describe; } + LogTabEvent::Rebase => { + let source_change = commander.get_current_head()?; + let target_change = &self.head; + self.rebase_popup = Some(RebasePopup::new( + source_change.clone(), + target_change.clone(), + )); + } LogTabEvent::Squash { ignore_immutable } => { if self.head.change_id == commander.get_current_head()?.change_id { return Ok(ComponentInputResult::HandledAction( @@ -604,6 +617,13 @@ impl Component for LogTab<'_> { } } + // Draw rebase popup + { + if let Some(log_rebase_popup) = &mut self.rebase_popup { + log_rebase_popup.render_widget(f) + } + } + Ok(()) } @@ -657,7 +677,34 @@ impl Component for LogTab<'_> { return Ok(ComponentInputResult::Handled); } - if let Event::Key(key) = event { + if let Some(rebase_popup) = &mut self.rebase_popup { + let handled = rebase_popup.handle_input(commander, event.clone()); + if handled.is_err() { + // Close popup and show error message + self.rebase_popup = None; + let msg = handled.err().unwrap(); + let error_message = msg.to_string().into_text()?; + return Ok(ComponentInputResult::HandledAction( + ComponentAction::SetPopup(Some(Box::new(MessagePopup { + title: "Error".into(), + messages: error_message, + text_align: None, + }))), + )); + } + if handled.ok() == Some(true) { + // when handle_input returns true, + // the popup should be closed + self.rebase_popup = None; + return Ok(ComponentInputResult::HandledAction( + ComponentAction::RefreshTab(), + )); + } + return Ok(ComponentInputResult::Handled); + } + + if let Event::Key(key) = &event { + let key = *key; if key.kind != KeyEventKind::Press { return Ok(ComponentInputResult::Handled); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ef64e7d6..2fd8c769 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -7,6 +7,7 @@ pub mod help_popup; pub mod log_tab; pub mod message_popup; pub mod panel; +pub mod rebase_popup; pub mod styles; pub mod utils; diff --git a/src/ui/rebase_popup.rs b/src/ui/rebase_popup.rs new file mode 100644 index 00000000..7a3a8646 --- /dev/null +++ b/src/ui/rebase_popup.rs @@ -0,0 +1,285 @@ +/*! The rebase popup allows the user to pick a rebase configuration and + start rebase, or cancel the opreation. + + The UI looks like this + ~~~ + Source (zsztoxlv) + ( ) -s this and descendants + ( ) -b whole branch + (*) -r only one change moves + Target @ (umrpslui) + (*) -d rebase onto @ as new branch + ( ) -A rebase after @ + ( ) -B rebase before @ + + Esc: Cancel Enter: Rebase +~~~ +It has keyboard shortcuts s, b, r, d, shift+a, shift+b for selecting +a radiobutton, and shortcuts Enter, Esc, q for closing the popup. + + +*/ + +use anyhow::Result; +use ratatui::{ + Frame, + crossterm::event::Event, + layout::{Alignment, Rect}, + prelude::{Buffer, Constraint, Direction, Layout, Size}, + style::{Color, Style, Stylize}, + text::{Line, Span, Text}, + widgets::{Block, BorderType, Clear, Paragraph, StatefulWidget}, +}; + +use crate::{ + ComponentInputResult, + commander::{Commander, log::Head}, + keybinds::rebase_popup::{CutOption, PasteOption, PopupAction}, + ui::Component, +}; + +type Keybinds = crate::keybinds::rebase_popup::Keybinds; + +/// A transient popup for configuring a rebase command +pub struct RebasePopup { + pub keybinds: Keybinds, + + pub source_rev: Head, + pub target_rev: Head, + + pub source_mode: CutOption, + pub target_mode: PasteOption, +} + +impl RebasePopup { + pub fn new(source_rev: Head, target_rev: Head) -> Self { + Self { + keybinds: Keybinds::default(), + source_rev, + target_rev, + source_mode: CutOption::SingleRevision, + target_mode: PasteOption::NewBranch, + } + } + + /// Collect all the rendering code that would have been in + /// log_tab.rs/draw + pub fn render_widget(&mut self, frame: &mut Frame) { + let area = center_rect( + frame.area(), + Size { + width: 32, + height: 12, + }, + ); + self.draw(frame, area) + .expect("Expected drawing without failues"); + } + + /// Map an event to a popup action + // TODO: This should be done by a custom keybinds object + fn match_event(&self, event: Event) -> PopupAction { + if let Event::Key(key) = event { + return self.keybinds.match_event(key); + } + PopupAction::None + } + + /// Run the command that the popup is currently configured to do + fn run_command(&self, commander: &mut Commander) -> Result<()> { + let src_rev = self.source_rev.commit_id.as_str(); + let tgt_rev = self.target_rev.commit_id.as_str(); + let src_mode = match self.source_mode { + CutOption::IncludeDescendants => "-s", + CutOption::IncludeBranch => "-b", + CutOption::SingleRevision => "-r", + }; + let tgt_mode = match self.target_mode { + PasteOption::NewBranch => "-d", + PasteOption::InsertAfter => "-A", + PasteOption::InsertBefore => "-B", + }; + commander.run_rebase(src_mode, src_rev, tgt_mode, tgt_rev)?; + Ok(()) + } + + /// Process the input event. If this function returns Ok(true), + /// then the popup should be closed. Either a rebase was executed + /// or the operation was cancelled. + /// If the result is Ok(false) the function did handle + /// the input, but the popup should not be closed yet. + /// Err(_) will be returned if the jj command failed. + pub fn handle_input(&mut self, commander: &mut Commander, event: Event) -> Result { + match self.match_event(event) { + PopupAction::Ok => { + self.run_command(commander)?; + return Ok(true); + } + PopupAction::Cancel => return Ok(true), + PopupAction::SetSourceMode(m) => self.source_mode = m, + PopupAction::SetTargetMode(m) => self.target_mode = m, + PopupAction::None => (), + } + Ok(false) + } +} + +impl Component for RebasePopup { + /// Render the dialog into the area. + fn draw(&mut self, frame: &mut Frame<'_>, area: Rect) -> Result<()> { + // The border of the dialog + let block = Block::bordered() + .title(Span::styled(" Rebase ", Style::new().bold().cyan())) + .title_alignment(Alignment::Center) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::Green)); + frame.render_widget(Clear, area); + frame.render_widget(&block, area); + + // Split area into chunks. Even though the area size is constant, + // we pretend it can change in the future. + let chunks = Layout::default() + .direction(Direction::Vertical) + .vertical_margin(1) + .horizontal_margin(2) + .constraints( + [ + Constraint::Length(1), // title "Source" + Constraint::Min(3), // buttons for source mode + Constraint::Length(1), // title "Target" + Constraint::Min(3), // buttons for target mode + Constraint::Length(2), // help text + ] + .as_ref(), + ) + .split(area); + + // Radio buttons for source + let src_change_id: String = self.source_rev.change_id.as_str().chars().take(8).collect(); + let src_commit_id: String = self.source_rev.commit_id.as_str().chars().take(8).collect(); + let src_options = vec![ + "-s this and descendants", + "-b whole branch", + "-r only one change moves", + ]; + let mut src_select: usize = match self.source_mode { + CutOption::IncludeDescendants => 0, + CutOption::IncludeBranch => 1, + CutOption::SingleRevision => 2, + }; + frame.render_widget( + Paragraph::new(Span::raw(format!( + "Source @ {src_change_id} {src_commit_id}" + ))), + chunks[0], + ); + frame.render_stateful_widget(RadioButton::new(src_options), chunks[1], &mut src_select); + + // Radio buttons for target + let tgt_change_id: String = self.target_rev.change_id.as_str().chars().take(8).collect(); + let tgt_commit_id: String = self.target_rev.commit_id.as_str().chars().take(8).collect(); + let tgt_options = vec![ + "-d rebase as new branch", + "-A rebase after", + "-B rebase before", + ]; + let mut tgt_select: usize = match self.target_mode { + PasteOption::NewBranch => 0, + PasteOption::InsertAfter => 1, + PasteOption::InsertBefore => 2, + }; + frame.render_widget( + Paragraph::new(Span::raw(format!("Target {tgt_change_id} {tgt_commit_id}"))), + chunks[2], + ); + frame.render_stateful_widget(RadioButton::new(tgt_options), chunks[3], &mut tgt_select); + + // Help on terminating dialog + frame.render_widget( + Paragraph::new(Text::from(vec![ + Line::raw(""), + Line::raw("Esc: Cancel Enter: Rebase"), + ])), + chunks[4], + ); + + Ok(()) + } + + fn input(&mut self, _commander: &mut Commander, _event: Event) -> Result { + unreachable!(); + //return Ok(ComponentInputResult::Handled); + } +} + +/****************************************************************/ +// TODO(@peso): Move this function to ui::utils + +/// Find a rect of the given size at the center of an outside rect +fn center_rect(outside: Rect, area: Size) -> Rect { + Rect { + x: outside.x + (outside.width - area.width) / 2, + y: outside.y + (outside.height - area.height) / 2, + width: area.width, + height: area.height, + } +} + +/****************************************************************/ +// TODO(@peso): Move this widget to a separate file + +/** A widget for a group of radio buttons. + +It is a stateful widget. +The state is an usize number that indicates which label is +selected. + +Example: +~~~ +( ) apples +( ) bananas +(*) lemons +~~~ +*/ +struct RadioButton { + /// Button labels + pub labels: Vec, + /// Button style can be modified before drawing + pub button_style: Style, + /// Label style can be modified before drawing + pub label_style: Style, +} + +impl RadioButton { + pub fn new(labels: Vec<&str>) -> Self { + let button_style = Style::default().fg(Color::White); + let label_style = Style::default().fg(Color::White); + Self { + labels: labels.iter().map(|s| s.to_string()).collect(), + button_style, + label_style, + } + } +} + +impl StatefulWidget for RadioButton { + type State = usize; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + for (row, label) in self.labels.iter().enumerate() { + let button = if row == *state { "(*)" } else { "( )" }; + buf.set_string( + area.left(), + area.top() + row as u16, + button, + self.button_style, + ); + buf.set_string( + area.left() + 4_u16, + area.top() + row as u16, + label, + self.label_style, + ); + } + } +}