use crate::{ accessors, components::{ command_pump, event_pump, visibility_blocking, ChangesComponent, CommandBlocking, CommandInfo, Component, DiffComponent, DrawableComponent, EventState, FileTreeItemKind, }, keys::{key_match, SharedKeyConfig}, options::SharedOptions, queue::{Action, InternalEvent, NeedsUpdate, Queue, ResetItem}, strings, try_or_popup, ui::style::{SharedTheme, Theme}, }; use anyhow::Result; use asyncgit::{ asyncjob::AsyncSingleJob, cached, sync::{ self, status::StatusType, RepoPath, RepoPathRef, RepoState, }, sync::{BranchCompare, CommitId}, AsyncBranchesJob, AsyncDiff, AsyncGitNotification, AsyncStatus, DiffParams, DiffType, PushType, StatusItem, StatusParams, }; use crossbeam_channel::Sender; use crossterm::event::Event; use itertools::Itertools; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout}, style::{Color, Style}, widgets::{Block, BorderType, Borders, Paragraph}, }; use std::convert::Into; /// what part of the screen is focused #[derive(PartialEq)] enum Focus { WorkDir, Diff, Stage, } /// focus can toggle between workdir and stage impl Focus { const fn toggled_focus(&self) -> Self { match self { Self::WorkDir => Self::Stage, Self::Stage => Self::WorkDir, Self::Diff => Self::Diff, } } } /// which target are we showing a diff against #[derive(PartialEq, Copy, Clone)] enum DiffTarget { Stage, WorkingDir, } pub struct Status { repo: RepoPathRef, visible: bool, focus: Focus, diff_target: DiffTarget, index: ChangesComponent, index_wd: ChangesComponent, diff: DiffComponent, git_diff: AsyncDiff, has_remotes: bool, git_state: RepoState, git_status_workdir: AsyncStatus, git_status_stage: AsyncStatus, git_branch_state: Option, git_branch_name: cached::BranchName, git_branches: AsyncSingleJob, queue: Queue, git_action_executed: bool, options: SharedOptions, key_config: SharedKeyConfig, } impl DrawableComponent for Status { fn draw( &self, f: &mut ratatui::Frame, rect: ratatui::layout::Rect, ) -> Result<()> { let repo_unclean = self.repo_state_unclean(); let rects = if repo_unclean { Layout::default() .direction(Direction::Vertical) .constraints( [Constraint::Min(1), Constraint::Length(3)] .as_ref(), ) .split(rect) } else { std::rc::Rc::new([rect]) }; let chunks = Layout::default() .direction(Direction::Horizontal) .constraints( if self.focus == Focus::Diff { [ Constraint::Percentage(0), Constraint::Percentage(100), ] } else { [ Constraint::Percentage(50), Constraint::Percentage(50), ] } .as_ref(), ) .split(rects[0]); let left_chunks = Layout::default() .direction(Direction::Vertical) .constraints( if self.diff_target == DiffTarget::WorkingDir { [ Constraint::Percentage(60), Constraint::Percentage(40), ] } else { [ Constraint::Percentage(40), Constraint::Percentage(60), ] } .as_ref(), ) .split(chunks[0]); self.index_wd.draw(f, left_chunks[0])?; self.index.draw(f, left_chunks[1])?; self.diff.draw(f, chunks[1])?; self.draw_branch_state(f, &left_chunks); if repo_unclean { self.draw_repo_state(f, rects[1]); } Ok(()) } } impl Status { accessors!(self, [index, index_wd, diff]); /// pub fn new( repo: RepoPathRef, queue: &Queue, sender: &Sender, theme: SharedTheme, key_config: SharedKeyConfig, options: SharedOptions, ) -> Self { let repo_clone = repo.borrow().clone(); Self { queue: queue.clone(), visible: true, has_remotes: false, git_state: RepoState::Clean, focus: Focus::WorkDir, diff_target: DiffTarget::WorkingDir, index_wd: ChangesComponent::new( repo.clone(), &strings::title_status(&key_config), true, true, queue.clone(), theme.clone(), key_config.clone(), options.clone(), ), index: ChangesComponent::new( repo.clone(), &strings::title_index(&key_config), false, false, queue.clone(), theme.clone(), key_config.clone(), options.clone(), ), diff: DiffComponent::new( repo.clone(), queue.clone(), theme, key_config.clone(), false, options.clone(), ), git_diff: AsyncDiff::new(repo_clone.clone(), sender), git_status_workdir: AsyncStatus::new( repo_clone.clone(), sender.clone(), ), git_status_stage: AsyncStatus::new( repo_clone, sender.clone(), ), git_branches: AsyncSingleJob::new(sender.clone()), git_action_executed: false, git_branch_state: None, git_branch_name: cached::BranchName::new(repo.clone()), key_config, options, repo, } } fn draw_branch_state( &self, f: &mut ratatui::Frame, chunks: &[ratatui::layout::Rect], ) { if let Some(branch_name) = self.git_branch_name.last() { let ahead_behind = self .git_branch_state .as_ref() .map_or_else(String::new, |state| { format!( "\u{2191}{} \u{2193}{} ", state.ahead, state.behind, ) }); let w = Paragraph::new(format!( "{ahead_behind}{{{branch_name}}}" )) .alignment(Alignment::Right); let mut rect = if self.index_wd.focused() { let mut rect = chunks[0]; rect.y += rect.height.saturating_sub(1); rect } else { chunks[1] }; rect.x += 1; rect.width = rect.width.saturating_sub(2); rect.height = rect .height .saturating_sub(rect.height.saturating_sub(1)); f.render_widget(w, rect); } } fn repo_state_text(repo: &RepoPath, state: &RepoState) -> String { match state { RepoState::Merge => { let ids = sync::mergehead_ids(repo).unwrap_or_default(); format!( "Commits: {}", ids.iter() .map(sync::CommitId::get_short_string) .join(",") ) } RepoState::Rebase => sync::rebase_progress(repo) .map_or_else( |_| String::new(), |p| { format!( "Step: {}/{} Current Commit: {}", p.current + 1, p.steps, p.current_commit .as_ref() .map(CommitId::get_short_string) .unwrap_or_default(), ) }, ), RepoState::Revert => { format!( "Revert {}", sync::revert_head(repo) .ok() .as_ref() .map(CommitId::get_short_string) .unwrap_or_default(), ) } _ => format!("{state:?}"), } } fn draw_repo_state( &self, f: &mut ratatui::Frame, r: ratatui::layout::Rect, ) { if self.git_state != RepoState::Clean { let txt = Self::repo_state_text( &self.repo.borrow(), &self.git_state, ); let w = Paragraph::new(txt) .block( Block::default() .border_type(BorderType::Plain) .borders(Borders::all()) .border_style(Theme::attention_block()) .title(format!( "Pending {:?}", self.git_state )), ) .style(Style::default().fg(Color::Red)) .alignment(Alignment::Left); f.render_widget(w, r); } } fn repo_state_unclean(&self) -> bool { self.git_state != RepoState::Clean } fn can_focus_diff(&self) -> bool { match self.focus { Focus::WorkDir => self.index_wd.is_file_seleted(), Focus::Stage => self.index.is_file_seleted(), Focus::Diff => false, } } fn is_focus_on_diff(&self) -> bool { self.focus == Focus::Diff } fn switch_focus(&mut self, f: Focus) -> Result { if self.focus != f { self.focus = f; match self.focus { Focus::WorkDir => { self.set_diff_target(DiffTarget::WorkingDir); self.diff.focus(false); } Focus::Stage => { self.set_diff_target(DiffTarget::Stage); self.diff.focus(false); } Focus::Diff => { self.index.focus(false); self.index_wd.focus(false); self.diff.focus(true); } }; self.update_diff()?; return Ok(true); } Ok(false) } fn set_diff_target(&mut self, target: DiffTarget) { self.diff_target = target; let is_stage = self.diff_target == DiffTarget::Stage; self.index_wd.focus_select(!is_stage); self.index.focus_select(is_stage); } pub fn selected_path(&self) -> Option<(String, bool)> { let (idx, is_stage) = match self.diff_target { DiffTarget::Stage => (&self.index, true), DiffTarget::WorkingDir => (&self.index_wd, false), }; if let Some(item) = idx.selection() { if let FileTreeItemKind::File(i) = item.kind { return Some((i.path, is_stage)); } } None } /// pub fn update(&mut self) -> Result<()> { self.git_branch_name.lookup().map(Some).unwrap_or(None); if self.is_visible() { let config = self.options.borrow().status_show_untracked(); self.git_diff.refresh()?; self.git_status_workdir.fetch(&StatusParams::new( StatusType::WorkingDir, config, ))?; self.git_status_stage.fetch(&StatusParams::new( StatusType::Stage, config, ))?; self.git_state = sync::repo_state(&self.repo.borrow()) .unwrap_or(RepoState::Clean); self.branch_compare(); } Ok(()) } /// pub fn anything_pending(&self) -> bool { self.git_diff.is_pending() || self.git_status_stage.is_pending() || self.git_status_workdir.is_pending() || self.git_branches.is_pending() } fn check_remotes(&mut self) { self.has_remotes = false; if let Some(result) = self.git_branches.take_last() { if let Some(Ok(branches)) = result.result() { self.has_remotes = !branches.is_empty(); } } else { self.git_branches.spawn(AsyncBranchesJob::new( self.repo.borrow().clone(), false, )); } } /// pub fn update_git( &mut self, ev: AsyncGitNotification, ) -> Result<()> { if !self.is_visible() { return Ok(()); } match ev { AsyncGitNotification::Diff => self.update_diff()?, AsyncGitNotification::Status => self.update_status()?, AsyncGitNotification::Branches => self.check_remotes(), AsyncGitNotification::Push | AsyncGitNotification::Pull | AsyncGitNotification::CommitFiles => { self.branch_compare(); } _ => (), } Ok(()) } pub fn get_files_changes(&mut self) -> Result> { Ok(self.git_status_stage.last()?.items) } fn update_status(&mut self) -> Result<()> { let stage_status = self.git_status_stage.last()?; self.index.set_items(&stage_status.items)?; let workdir_status = self.git_status_workdir.last()?; self.index_wd.set_items(&workdir_status.items)?; self.update_diff()?; if self.git_action_executed { self.git_action_executed = false; if self.focus == Focus::WorkDir && workdir_status.items.is_empty() && !stage_status.items.is_empty() { self.switch_focus(Focus::Stage)?; } else if self.focus == Focus::Stage && stage_status.items.is_empty() { self.switch_focus(Focus::WorkDir)?; } } Ok(()) } /// pub fn update_diff(&mut self) -> Result<()> { if let Some((path, is_stage)) = self.selected_path() { let diff_type = if is_stage { DiffType::Stage } else { DiffType::WorkDir }; let diff_params = DiffParams { path: path.clone(), diff_type, options: self.options.borrow().diff_options(), }; if self.diff.current() == (path.clone(), is_stage) { // we are already showing a diff of the right file // maybe the diff changed (outside file change) if let Some((params, last)) = self.git_diff.last()? { if params == diff_params { // all params match, so we might need to update self.diff.update(path, is_stage, last); } else { // params changed, we need to request the right diff self.request_diff( diff_params, path, is_stage, )?; } } } else { // we dont show the right diff right now, so we need to request self.request_diff(diff_params, path, is_stage)?; } } else { self.diff.clear(false); } Ok(()) } fn request_diff( &mut self, diff_params: DiffParams, path: String, is_stage: bool, ) -> Result<(), anyhow::Error> { if let Some(diff) = self.git_diff.request(diff_params)? { self.diff.update(path, is_stage, diff); } else { self.diff.clear(true); } Ok(()) } /// called after confirmation pub fn reset(&mut self, item: &ResetItem) -> bool { if let Err(e) = sync::reset_workdir( &self.repo.borrow(), item.path.as_str(), ) { self.queue.push(InternalEvent::ShowErrorMsg(format!( "reset failed:\n{e}" ))); false } else { true } } pub fn last_file_moved(&mut self) -> Result<()> { if !self.is_focus_on_diff() && self.is_visible() { self.switch_focus(self.focus.toggled_focus())?; } Ok(()) } fn push(&self, force: bool) { if self.can_push() { if let Some(branch) = self.git_branch_name.last() { if force { self.queue.push(InternalEvent::ConfirmAction( Action::ForcePush(branch, force), )); } else { self.queue.push(InternalEvent::Push( branch, PushType::Branch, force, false, )); } } } } fn fetch(&self) { if self.can_pull() { self.queue.push(InternalEvent::FetchRemotes); } } fn pull(&self) { if let Some(branch) = self.git_branch_name.last() { self.queue.push(InternalEvent::Pull(branch)); } } fn undo_last_commit(&self) { self.queue .push(InternalEvent::ConfirmAction(Action::UndoCommit)); } fn branch_compare(&mut self) { self.git_branch_state = self.git_branch_name.last().and_then(|branch| { sync::branch_compare_upstream( &self.repo.borrow(), branch.as_str(), ) .ok() }); } fn can_push(&self) -> bool { self.git_branch_state .as_ref() .map_or(true, |state| state.ahead > 0) && self.has_remotes } const fn can_pull(&self) -> bool { self.has_remotes && self.git_branch_state.is_some() } fn can_abort_merge(&self) -> bool { self.git_state == RepoState::Merge } fn pending_rebase(&self) -> bool { self.git_state == RepoState::Rebase } fn pending_revert(&self) -> bool { self.git_state == RepoState::Revert } pub fn revert_pending_state(&self) { try_or_popup!( self, "revert pending state", sync::abort_pending_state(&self.repo.borrow()) ); } pub fn abort_rebase(&self) { try_or_popup!( self, "abort rebase", sync::abort_pending_rebase(&self.repo.borrow()) ); } fn continue_rebase(&self) { try_or_popup!( self, "continue rebase", sync::continue_pending_rebase(&self.repo.borrow()) ); } fn commands_nav( &self, out: &mut Vec, force_all: bool, ) { let focus_on_diff = self.is_focus_on_diff(); out.push( CommandInfo::new( strings::commands::close_popup(&self.key_config), true, (self.visible && focus_on_diff) || force_all, ) .order(strings::order::NAV), ); out.push( CommandInfo::new( strings::commands::diff_focus_right(&self.key_config), self.can_focus_diff(), (self.visible && !focus_on_diff) || force_all, ) .order(strings::order::NAV), ); out.push( CommandInfo::new( strings::commands::select_staging(&self.key_config), !focus_on_diff, (self.visible && !focus_on_diff && self.focus == Focus::WorkDir) || force_all, ) .order(strings::order::NAV), ); out.push( CommandInfo::new( strings::commands::select_unstaged(&self.key_config), !focus_on_diff, (self.visible && !focus_on_diff && self.focus == Focus::Stage) || force_all, ) .order(strings::order::NAV), ); } fn can_commit(&self) -> bool { self.index.focused() && !self.index.is_empty() && !self.pending_rebase() } } impl Component for Status { fn commands( &self, out: &mut Vec, force_all: bool, ) -> CommandBlocking { let focus_on_diff = self.is_focus_on_diff(); if self.visible || force_all { command_pump( out, force_all, self.components().as_slice(), ); out.push( CommandInfo::new( strings::commands::commit_open(&self.key_config), true, self.can_commit() || force_all, ) .order(-1), ); out.push(CommandInfo::new( strings::commands::open_branch_select_popup( &self.key_config, ), true, !focus_on_diff, )); out.push(CommandInfo::new( strings::commands::status_push(&self.key_config), self.can_push(), !focus_on_diff, )); out.push(CommandInfo::new( strings::commands::status_force_push( &self.key_config, ), true, self.can_push() && !focus_on_diff, )); out.push(CommandInfo::new( strings::commands::status_fetch(&self.key_config), self.can_pull(), !focus_on_diff, )); out.push(CommandInfo::new( strings::commands::status_pull(&self.key_config), self.can_pull(), !focus_on_diff, )); out.push(CommandInfo::new( strings::commands::undo_commit(&self.key_config), true, (!self.pending_rebase() && !focus_on_diff) || force_all, )); out.push(CommandInfo::new( strings::commands::abort_merge(&self.key_config), true, self.can_abort_merge() || force_all, )); out.push(CommandInfo::new( strings::commands::continue_rebase(&self.key_config), true, self.pending_rebase() || force_all, )); out.push(CommandInfo::new( strings::commands::abort_rebase(&self.key_config), true, self.pending_rebase() || force_all, )); out.push(CommandInfo::new( strings::commands::abort_revert(&self.key_config), true, self.pending_revert() || force_all, )); out.push(CommandInfo::new( strings::commands::view_submodules(&self.key_config), true, true, )); } self.commands_nav(out, force_all); visibility_blocking(self) } #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] fn event( &mut self, ev: &crossterm::event::Event, ) -> Result { if self.visible { if event_pump(ev, self.components_mut().as_mut_slice())? .is_consumed() { self.git_action_executed = true; return Ok(EventState::Consumed); } if let Event::Key(k) = ev { return if key_match( k, self.key_config.keys.open_commit, ) && self.can_commit() { self.queue.push(InternalEvent::OpenCommit); Ok(EventState::Consumed) } else if key_match( k, self.key_config.keys.toggle_workarea, ) && !self.is_focus_on_diff() { self.switch_focus(self.focus.toggled_focus()) .map(Into::into) } else if key_match( k, self.key_config.keys.move_right, ) && self.can_focus_diff() { self.switch_focus(Focus::Diff).map(Into::into) } else if key_match( k, self.key_config.keys.exit_popup, ) { self.switch_focus(match self.diff_target { DiffTarget::Stage => Focus::Stage, DiffTarget::WorkingDir => Focus::WorkDir, }) .map(Into::into) } else if key_match(k, self.key_config.keys.move_down) && self.focus == Focus::WorkDir && !self.index.is_empty() { self.switch_focus(Focus::Stage).map(Into::into) } else if key_match(k, self.key_config.keys.move_up) && self.focus == Focus::Stage && !self.index_wd.is_empty() { self.switch_focus(Focus::WorkDir).map(Into::into) } else if key_match( k, self.key_config.keys.select_branch, ) && !self.is_focus_on_diff() { self.queue.push(InternalEvent::SelectBranch); Ok(EventState::Consumed) } else if key_match( k, self.key_config.keys.force_push, ) && !self.is_focus_on_diff() && self.can_push() { self.push(true); Ok(EventState::Consumed) } else if key_match(k, self.key_config.keys.push) && !self.is_focus_on_diff() { self.push(false); Ok(EventState::Consumed) } else if key_match(k, self.key_config.keys.fetch) && !self.is_focus_on_diff() && self.can_pull() { self.fetch(); Ok(EventState::Consumed) } else if key_match(k, self.key_config.keys.pull) && !self.is_focus_on_diff() && self.can_pull() { self.pull(); Ok(EventState::Consumed) } else if key_match( k, self.key_config.keys.undo_commit, ) && !self.is_focus_on_diff() { self.undo_last_commit(); self.queue.push(InternalEvent::Update( NeedsUpdate::ALL, )); Ok(EventState::Consumed) } else if key_match( k, self.key_config.keys.abort_merge, ) { if self.can_abort_merge() { self.queue.push( InternalEvent::ConfirmAction( Action::AbortMerge, ), ); } else if self.pending_rebase() { self.queue.push( InternalEvent::ConfirmAction( Action::AbortRebase, ), ); } else if self.pending_revert() { self.queue.push( InternalEvent::ConfirmAction( Action::AbortRevert, ), ); } Ok(EventState::Consumed) } else if key_match( k, self.key_config.keys.rebase_branch, ) && self.pending_rebase() { self.continue_rebase(); self.queue.push(InternalEvent::Update( NeedsUpdate::ALL, )); Ok(EventState::Consumed) } else if key_match( k, self.key_config.keys.view_submodules, ) { self.queue.push(InternalEvent::ViewSubmodules); Ok(EventState::Consumed) } else { Ok(EventState::NotConsumed) }; } } Ok(EventState::NotConsumed) } fn is_visible(&self) -> bool { self.visible } fn hide(&mut self) { self.visible = false; self.index.hide(); self.index_wd.hide(); } fn show(&mut self) -> Result<()> { self.visible = true; self.index.show()?; self.index_wd.show()?; self.check_remotes(); self.update()?; Ok(()) } }