mirror of
https://github.com/gitui-org/gitui.git
synced 2026-10-12 02:14:16 +00:00
749 lines
16 KiB
Rust
749 lines
16 KiB
Rust
use super::{
|
|
utils::scroll_vertical::VerticalScroll, visibility_blocking,
|
|
CommandBlocking, CommandInfo, Component, DrawableComponent,
|
|
EventState, InspectCommitOpen,
|
|
};
|
|
use crate::{
|
|
components::ScrollType,
|
|
keys::{key_match, SharedKeyConfig},
|
|
queue::{
|
|
Action, InternalEvent, NeedsUpdate, Queue, StackablePopupOpen,
|
|
},
|
|
strings, try_or_popup,
|
|
ui::{self, Size},
|
|
};
|
|
use anyhow::Result;
|
|
use asyncgit::{
|
|
sync::{
|
|
self,
|
|
branch::{
|
|
checkout_remote_branch, BranchDetails, LocalBranch,
|
|
RemoteBranch,
|
|
},
|
|
checkout_branch, get_branches_info, BranchInfo, BranchType,
|
|
CommitId, RepoPathRef, RepoState,
|
|
},
|
|
AsyncGitNotification,
|
|
};
|
|
use crossterm::event::Event;
|
|
use std::{cell::Cell, convert::TryInto};
|
|
use tui::{
|
|
backend::Backend,
|
|
layout::{
|
|
Alignment, Constraint, Direction, Layout, Margin, Rect,
|
|
},
|
|
text::{Span, Spans, Text},
|
|
widgets::{Block, BorderType, Borders, Clear, Paragraph, Tabs},
|
|
Frame,
|
|
};
|
|
use ui::style::SharedTheme;
|
|
use unicode_truncate::UnicodeTruncateStr;
|
|
|
|
///
|
|
pub struct BranchListComponent {
|
|
repo: RepoPathRef,
|
|
branches: Vec<BranchInfo>,
|
|
local: bool,
|
|
has_remotes: bool,
|
|
visible: bool,
|
|
selection: u16,
|
|
scroll: VerticalScroll,
|
|
current_height: Cell<u16>,
|
|
queue: Queue,
|
|
theme: SharedTheme,
|
|
key_config: SharedKeyConfig,
|
|
}
|
|
|
|
impl DrawableComponent for BranchListComponent {
|
|
fn draw<B: Backend>(
|
|
&self,
|
|
f: &mut Frame<B>,
|
|
rect: Rect,
|
|
) -> Result<()> {
|
|
if self.is_visible() {
|
|
const PERCENT_SIZE: Size = Size::new(80, 50);
|
|
const MIN_SIZE: Size = Size::new(60, 20);
|
|
|
|
let area = ui::centered_rect(
|
|
PERCENT_SIZE.width,
|
|
PERCENT_SIZE.height,
|
|
f.size(),
|
|
);
|
|
let area =
|
|
ui::rect_inside(MIN_SIZE, f.size().into(), area);
|
|
let area = area.intersection(rect);
|
|
|
|
f.render_widget(Clear, area);
|
|
|
|
f.render_widget(
|
|
Block::default()
|
|
.title(strings::title_branches())
|
|
.border_type(BorderType::Thick)
|
|
.borders(Borders::ALL),
|
|
area,
|
|
);
|
|
|
|
let area = area.inner(&Margin {
|
|
vertical: 1,
|
|
horizontal: 1,
|
|
});
|
|
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints(
|
|
[Constraint::Length(2), Constraint::Min(1)]
|
|
.as_ref(),
|
|
)
|
|
.split(area);
|
|
|
|
self.draw_tabs(f, chunks[0]);
|
|
self.draw_list(f, chunks[1])?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Component for BranchListComponent {
|
|
fn commands(
|
|
&self,
|
|
out: &mut Vec<CommandInfo>,
|
|
force_all: bool,
|
|
) -> CommandBlocking {
|
|
if self.visible || force_all {
|
|
if !force_all {
|
|
out.clear();
|
|
}
|
|
|
|
out.push(CommandInfo::new(
|
|
strings::commands::scroll(&self.key_config),
|
|
true,
|
|
true,
|
|
));
|
|
|
|
out.push(CommandInfo::new(
|
|
strings::commands::close_popup(&self.key_config),
|
|
true,
|
|
true,
|
|
));
|
|
|
|
out.push(CommandInfo::new(
|
|
strings::commands::commit_details_open(
|
|
&self.key_config,
|
|
),
|
|
true,
|
|
true,
|
|
));
|
|
|
|
out.push(CommandInfo::new(
|
|
strings::commands::compare_with_head(
|
|
&self.key_config,
|
|
),
|
|
!self.selection_is_cur_branch(),
|
|
true,
|
|
));
|
|
|
|
out.push(CommandInfo::new(
|
|
strings::commands::toggle_branch_popup(
|
|
&self.key_config,
|
|
self.local,
|
|
),
|
|
true,
|
|
true,
|
|
));
|
|
|
|
out.push(CommandInfo::new(
|
|
strings::commands::select_branch_popup(
|
|
&self.key_config,
|
|
),
|
|
!self.selection_is_cur_branch()
|
|
&& self.valid_selection(),
|
|
true,
|
|
));
|
|
|
|
out.push(CommandInfo::new(
|
|
strings::commands::open_branch_create_popup(
|
|
&self.key_config,
|
|
),
|
|
true,
|
|
self.local,
|
|
));
|
|
|
|
out.push(CommandInfo::new(
|
|
strings::commands::delete_branch_popup(
|
|
&self.key_config,
|
|
),
|
|
!self.selection_is_cur_branch(),
|
|
true,
|
|
));
|
|
|
|
out.push(CommandInfo::new(
|
|
strings::commands::merge_branch_popup(
|
|
&self.key_config,
|
|
),
|
|
!self.selection_is_cur_branch(),
|
|
true,
|
|
));
|
|
|
|
out.push(CommandInfo::new(
|
|
strings::commands::branch_popup_rebase(
|
|
&self.key_config,
|
|
),
|
|
!self.selection_is_cur_branch(),
|
|
true,
|
|
));
|
|
|
|
out.push(CommandInfo::new(
|
|
strings::commands::rename_branch_popup(
|
|
&self.key_config,
|
|
),
|
|
true,
|
|
self.local,
|
|
));
|
|
|
|
out.push(CommandInfo::new(
|
|
strings::commands::fetch_remotes(&self.key_config),
|
|
self.has_remotes,
|
|
!self.local,
|
|
));
|
|
}
|
|
visibility_blocking(self)
|
|
}
|
|
|
|
//TODO: cleanup
|
|
#[allow(clippy::cognitive_complexity)]
|
|
fn event(&mut self, ev: &Event) -> Result<EventState> {
|
|
if !self.visible {
|
|
return Ok(EventState::NotConsumed);
|
|
}
|
|
|
|
if let Event::Key(e) = ev {
|
|
if key_match(e, self.key_config.keys.exit_popup) {
|
|
self.hide();
|
|
} else if key_match(e, self.key_config.keys.move_down) {
|
|
return self
|
|
.move_selection(ScrollType::Up)
|
|
.map(Into::into);
|
|
} else if key_match(e, self.key_config.keys.move_up) {
|
|
return self
|
|
.move_selection(ScrollType::Down)
|
|
.map(Into::into);
|
|
} else if key_match(e, self.key_config.keys.page_down) {
|
|
return self
|
|
.move_selection(ScrollType::PageDown)
|
|
.map(Into::into);
|
|
} else if key_match(e, self.key_config.keys.page_up) {
|
|
return self
|
|
.move_selection(ScrollType::PageUp)
|
|
.map(Into::into);
|
|
} else if key_match(e, self.key_config.keys.home) {
|
|
return self
|
|
.move_selection(ScrollType::Home)
|
|
.map(Into::into);
|
|
} else if key_match(e, self.key_config.keys.end) {
|
|
return self
|
|
.move_selection(ScrollType::End)
|
|
.map(Into::into);
|
|
} else if key_match(e, self.key_config.keys.tab_toggle) {
|
|
self.local = !self.local;
|
|
self.check_remotes();
|
|
self.update_branches()?;
|
|
} else if key_match(e, self.key_config.keys.enter) {
|
|
try_or_popup!(
|
|
self,
|
|
"switch branch error:",
|
|
self.switch_to_selected_branch()
|
|
);
|
|
} else if key_match(e, self.key_config.keys.create_branch)
|
|
&& self.local
|
|
{
|
|
self.queue.push(InternalEvent::CreateBranch);
|
|
} else if key_match(e, self.key_config.keys.rename_branch)
|
|
&& self.valid_selection()
|
|
{
|
|
self.rename_branch();
|
|
} else if key_match(e, self.key_config.keys.delete_branch)
|
|
&& !self.selection_is_cur_branch()
|
|
&& self.valid_selection()
|
|
{
|
|
self.delete_branch();
|
|
} else if key_match(e, self.key_config.keys.merge_branch)
|
|
&& !self.selection_is_cur_branch()
|
|
&& self.valid_selection()
|
|
{
|
|
try_or_popup!(
|
|
self,
|
|
"merge branch error:",
|
|
self.merge_branch()
|
|
);
|
|
} else if key_match(e, self.key_config.keys.rebase_branch)
|
|
&& !self.selection_is_cur_branch()
|
|
&& self.valid_selection()
|
|
{
|
|
try_or_popup!(
|
|
self,
|
|
"rebase error:",
|
|
self.rebase_branch()
|
|
);
|
|
} else if key_match(e, self.key_config.keys.move_right)
|
|
&& self.valid_selection()
|
|
{
|
|
self.inspect_head_of_branch();
|
|
} else if key_match(
|
|
e,
|
|
self.key_config.keys.compare_commits,
|
|
) && self.valid_selection()
|
|
{
|
|
self.hide();
|
|
if let Some(commit_id) = self.get_selected() {
|
|
self.queue.push(InternalEvent::OpenPopup(
|
|
StackablePopupOpen::CompareCommits(
|
|
InspectCommitOpen::new(commit_id),
|
|
),
|
|
));
|
|
}
|
|
} else if key_match(e, self.key_config.keys.pull)
|
|
&& !self.local && self.has_remotes
|
|
{
|
|
self.queue.push(InternalEvent::FetchRemotes);
|
|
} else if key_match(
|
|
e,
|
|
self.key_config.keys.cmd_bar_toggle,
|
|
) {
|
|
//do not consume if its the more key
|
|
return Ok(EventState::NotConsumed);
|
|
}
|
|
}
|
|
|
|
Ok(EventState::Consumed)
|
|
}
|
|
|
|
fn is_visible(&self) -> bool {
|
|
self.visible
|
|
}
|
|
|
|
fn hide(&mut self) {
|
|
self.visible = false;
|
|
}
|
|
|
|
fn show(&mut self) -> Result<()> {
|
|
self.visible = true;
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl BranchListComponent {
|
|
pub fn new(
|
|
repo: RepoPathRef,
|
|
queue: Queue,
|
|
theme: SharedTheme,
|
|
key_config: SharedKeyConfig,
|
|
) -> Self {
|
|
Self {
|
|
branches: Vec::new(),
|
|
local: true,
|
|
has_remotes: false,
|
|
visible: false,
|
|
selection: 0,
|
|
scroll: VerticalScroll::new(),
|
|
queue,
|
|
theme,
|
|
key_config,
|
|
current_height: Cell::new(0),
|
|
repo,
|
|
}
|
|
}
|
|
|
|
///
|
|
pub fn open(&mut self) -> Result<()> {
|
|
self.show()?;
|
|
self.update_branches()?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn check_remotes(&mut self) {
|
|
if !self.local {
|
|
self.has_remotes =
|
|
get_branches_info(&self.repo.borrow(), false)
|
|
.map(|branches| !branches.is_empty())
|
|
.unwrap_or(false);
|
|
}
|
|
}
|
|
|
|
/// fetch list of branches
|
|
pub fn update_branches(&mut self) -> Result<()> {
|
|
if self.is_visible() {
|
|
self.check_remotes();
|
|
self.branches =
|
|
get_branches_info(&self.repo.borrow(), self.local)?;
|
|
//remove remote branch called `HEAD`
|
|
if !self.local {
|
|
self.branches
|
|
.iter()
|
|
.position(|b| b.name.ends_with("/HEAD"))
|
|
.map(|idx| self.branches.remove(idx));
|
|
}
|
|
self.set_selection(self.selection)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
///
|
|
pub fn update_git(
|
|
&mut self,
|
|
ev: AsyncGitNotification,
|
|
) -> Result<()> {
|
|
if self.is_visible() && ev == AsyncGitNotification::Push {
|
|
self.update_branches()?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn valid_selection(&self) -> bool {
|
|
!self.branches.is_empty()
|
|
}
|
|
|
|
fn merge_branch(&mut self) -> Result<()> {
|
|
if let Some(branch) =
|
|
self.branches.get(usize::from(self.selection))
|
|
{
|
|
sync::merge_branch(
|
|
&self.repo.borrow(),
|
|
&branch.name,
|
|
self.get_branch_type(),
|
|
)?;
|
|
|
|
self.hide_and_switch_tab()?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn rebase_branch(&mut self) -> Result<()> {
|
|
if let Some(branch) =
|
|
self.branches.get(usize::from(self.selection))
|
|
{
|
|
sync::rebase_branch(
|
|
&self.repo.borrow(),
|
|
&branch.name,
|
|
self.get_branch_type(),
|
|
)?;
|
|
|
|
self.hide_and_switch_tab()?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn inspect_head_of_branch(&mut self) {
|
|
if let Some(commit_id) = self.get_selected() {
|
|
self.hide();
|
|
self.queue.push(InternalEvent::OpenPopup(
|
|
StackablePopupOpen::InspectCommit(
|
|
InspectCommitOpen::new(commit_id),
|
|
),
|
|
));
|
|
}
|
|
}
|
|
|
|
const fn get_branch_type(&self) -> BranchType {
|
|
if self.local {
|
|
BranchType::Local
|
|
} else {
|
|
BranchType::Remote
|
|
}
|
|
}
|
|
|
|
fn hide_and_switch_tab(&mut self) -> Result<()> {
|
|
self.hide();
|
|
self.queue.push(InternalEvent::Update(NeedsUpdate::ALL));
|
|
|
|
if sync::repo_state(&self.repo.borrow())? != RepoState::Clean
|
|
{
|
|
self.queue.push(InternalEvent::TabSwitchStatus);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn selection_is_cur_branch(&self) -> bool {
|
|
self.branches
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(index, b)| {
|
|
b.local_details()
|
|
.map(|details| {
|
|
details.is_head
|
|
&& *index == self.selection as usize
|
|
})
|
|
.unwrap_or_default()
|
|
})
|
|
.count() > 0
|
|
}
|
|
|
|
fn get_selected(&self) -> Option<CommitId> {
|
|
self.branches
|
|
.get(usize::from(self.selection))
|
|
.map(|b| b.top_commit)
|
|
}
|
|
|
|
///
|
|
fn move_selection(&mut self, scroll: ScrollType) -> Result<bool> {
|
|
let new_selection = match scroll {
|
|
ScrollType::Up => self.selection.saturating_add(1),
|
|
ScrollType::Down => self.selection.saturating_sub(1),
|
|
ScrollType::PageDown => self
|
|
.selection
|
|
.saturating_add(self.current_height.get()),
|
|
ScrollType::PageUp => self
|
|
.selection
|
|
.saturating_sub(self.current_height.get()),
|
|
ScrollType::Home => 0,
|
|
ScrollType::End => {
|
|
let num_branches: u16 =
|
|
self.branches.len().try_into()?;
|
|
num_branches.saturating_sub(1)
|
|
}
|
|
};
|
|
|
|
self.set_selection(new_selection)?;
|
|
|
|
Ok(true)
|
|
}
|
|
|
|
fn set_selection(&mut self, selection: u16) -> Result<()> {
|
|
let num_branches: u16 = self.branches.len().try_into()?;
|
|
let num_branches = num_branches.saturating_sub(1);
|
|
|
|
let selection = if selection > num_branches {
|
|
num_branches
|
|
} else {
|
|
selection
|
|
};
|
|
|
|
self.selection = selection;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get branches to display
|
|
fn get_text(
|
|
&self,
|
|
theme: &SharedTheme,
|
|
width_available: u16,
|
|
height: usize,
|
|
) -> Text {
|
|
const UPSTREAM_SYMBOL: char = '\u{2191}';
|
|
const TRACKING_SYMBOL: char = '\u{2193}';
|
|
const HEAD_SYMBOL: char = '*';
|
|
const EMPTY_SYMBOL: char = ' ';
|
|
const THREE_DOTS: &str = "...";
|
|
const THREE_DOTS_LENGTH: usize = THREE_DOTS.len(); // "..."
|
|
const COMMIT_HASH_LENGTH: usize = 8;
|
|
const IS_HEAD_STAR_LENGTH: usize = 3; // "* "
|
|
|
|
let branch_name_length: usize =
|
|
width_available as usize * 40 / 100;
|
|
// commit message takes up the remaining width
|
|
let commit_message_length: usize = (width_available as usize)
|
|
.saturating_sub(COMMIT_HASH_LENGTH)
|
|
.saturating_sub(branch_name_length)
|
|
.saturating_sub(IS_HEAD_STAR_LENGTH)
|
|
.saturating_sub(THREE_DOTS_LENGTH);
|
|
let mut txt = Vec::new();
|
|
|
|
for (i, displaybranch) in self
|
|
.branches
|
|
.iter()
|
|
.skip(self.scroll.get_top())
|
|
.take(height)
|
|
.enumerate()
|
|
{
|
|
let mut commit_message =
|
|
displaybranch.top_commit_message.clone();
|
|
if commit_message.len() > commit_message_length {
|
|
commit_message.unicode_truncate(
|
|
commit_message_length
|
|
.saturating_sub(THREE_DOTS_LENGTH),
|
|
);
|
|
commit_message += THREE_DOTS;
|
|
}
|
|
|
|
let mut branch_name = displaybranch.name.clone();
|
|
if branch_name.len()
|
|
> branch_name_length.saturating_sub(THREE_DOTS_LENGTH)
|
|
{
|
|
branch_name = branch_name
|
|
.unicode_truncate(
|
|
branch_name_length
|
|
.saturating_sub(THREE_DOTS_LENGTH),
|
|
)
|
|
.0
|
|
.to_string();
|
|
branch_name += THREE_DOTS;
|
|
}
|
|
|
|
let selected = (self.selection as usize
|
|
- self.scroll.get_top())
|
|
== i;
|
|
|
|
let is_head = displaybranch
|
|
.local_details()
|
|
.map(|details| details.is_head)
|
|
.unwrap_or_default();
|
|
let is_head_str =
|
|
if is_head { HEAD_SYMBOL } else { EMPTY_SYMBOL };
|
|
let upstream_tracking_str = match displaybranch.details {
|
|
BranchDetails::Local(LocalBranch {
|
|
has_upstream,
|
|
..
|
|
}) if has_upstream => UPSTREAM_SYMBOL,
|
|
BranchDetails::Remote(RemoteBranch {
|
|
has_tracking,
|
|
..
|
|
}) if has_tracking => TRACKING_SYMBOL,
|
|
_ => EMPTY_SYMBOL,
|
|
};
|
|
|
|
let span_prefix = Span::styled(
|
|
format!("{}{} ", is_head_str, upstream_tracking_str),
|
|
theme.commit_author(selected),
|
|
);
|
|
let span_hash = Span::styled(
|
|
format!(
|
|
"{} ",
|
|
displaybranch.top_commit.get_short_string()
|
|
),
|
|
theme.commit_hash(selected),
|
|
);
|
|
let span_msg = Span::styled(
|
|
commit_message.to_string(),
|
|
theme.text(true, selected),
|
|
);
|
|
let span_name = Span::styled(
|
|
format!(
|
|
"{:w$} ",
|
|
branch_name,
|
|
w = branch_name_length
|
|
),
|
|
theme.branch(selected, is_head),
|
|
);
|
|
|
|
txt.push(Spans::from(vec![
|
|
span_prefix,
|
|
span_name,
|
|
span_hash,
|
|
span_msg,
|
|
]));
|
|
}
|
|
|
|
Text::from(txt)
|
|
}
|
|
|
|
///
|
|
fn switch_to_selected_branch(&mut self) -> Result<()> {
|
|
if !self.valid_selection() {
|
|
anyhow::bail!("no valid branch selected");
|
|
}
|
|
|
|
if self.local {
|
|
checkout_branch(
|
|
&self.repo.borrow(),
|
|
&self.branches[self.selection as usize].reference,
|
|
)?;
|
|
self.hide();
|
|
} else {
|
|
checkout_remote_branch(
|
|
&self.repo.borrow(),
|
|
&self.branches[self.selection as usize],
|
|
)?;
|
|
self.local = true;
|
|
self.update_branches()?;
|
|
}
|
|
|
|
self.queue.push(InternalEvent::Update(NeedsUpdate::ALL));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn draw_tabs<B: Backend>(&self, f: &mut Frame<B>, r: Rect) {
|
|
let tabs = [Span::raw("Local"), Span::raw("Remote")]
|
|
.iter()
|
|
.cloned()
|
|
.map(Spans::from)
|
|
.collect();
|
|
|
|
f.render_widget(
|
|
Tabs::new(tabs)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::BOTTOM)
|
|
.border_style(self.theme.block(false)),
|
|
)
|
|
.style(self.theme.tab(false))
|
|
.highlight_style(self.theme.tab(true))
|
|
.divider(strings::tab_divider(&self.key_config))
|
|
.select(if self.local { 0 } else { 1 }),
|
|
r,
|
|
);
|
|
}
|
|
|
|
fn draw_list<B: Backend>(
|
|
&self,
|
|
f: &mut Frame<B>,
|
|
r: Rect,
|
|
) -> Result<()> {
|
|
let height_in_lines = r.height as usize;
|
|
self.current_height.set(height_in_lines.try_into()?);
|
|
|
|
self.scroll.update(
|
|
self.selection as usize,
|
|
self.branches.len(),
|
|
height_in_lines,
|
|
);
|
|
|
|
f.render_widget(
|
|
Paragraph::new(self.get_text(
|
|
&self.theme,
|
|
r.width,
|
|
height_in_lines,
|
|
))
|
|
.alignment(Alignment::Left),
|
|
r,
|
|
);
|
|
|
|
let mut r = r;
|
|
r.width += 1;
|
|
r.height += 2;
|
|
r.y = r.y.saturating_sub(1);
|
|
|
|
self.scroll.draw(f, r, &self.theme);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn rename_branch(&mut self) {
|
|
let cur_branch = &self.branches[self.selection as usize];
|
|
self.queue.push(InternalEvent::RenameBranch(
|
|
cur_branch.reference.clone(),
|
|
cur_branch.name.clone(),
|
|
));
|
|
}
|
|
|
|
fn delete_branch(&mut self) {
|
|
let reference =
|
|
self.branches[self.selection as usize].reference.clone();
|
|
|
|
self.queue.push(InternalEvent::ConfirmAction(
|
|
if self.local {
|
|
Action::DeleteLocalBranch(reference)
|
|
} else {
|
|
Action::DeleteRemoteBranch(reference)
|
|
},
|
|
));
|
|
}
|
|
}
|