use super::{
utils::{
filetree::{FileTreeItem, FileTreeItemKind},
statustree::{MoveSelection, StatusTree},
},
BlameFileOpen, CommandBlocking, DrawableComponent, FileRevOpen,
};
use crate::{
components::{CommandInfo, Component, EventState},
keys::{key_match, SharedKeyConfig},
queue::{InternalEvent, NeedsUpdate, Queue, StackablePopupOpen},
strings::{self, order},
ui,
ui::style::SharedTheme,
};
use anyhow::Result;
use asyncgit::{hash, sync::CommitId, StatusItem, StatusItemType};
use crossterm::event::Event;
use std::{borrow::Cow, cell::Cell, convert::From, path::Path};
use tui::{backend::Backend, layout::Rect, text::Span, Frame};
#[allow(clippy::struct_excessive_bools)]
pub struct StatusTreeComponent {
title: String,
tree: StatusTree,
pending: bool,
current_hash: u64,
focused: bool,
show_selection: bool,
queue: Option<Queue>,
theme: SharedTheme,
key_config: SharedKeyConfig,
scroll_top: Cell<usize>,
visible: bool,
revision: Option<CommitId>,
}
impl StatusTreeComponent {
pub fn new(
title: &str,
focus: bool,
queue: Option<Queue>,
theme: SharedTheme,
key_config: SharedKeyConfig,
) -> Self {
Self {
title: title.to_string(),
tree: StatusTree::default(),
current_hash: 0,
focused: focus,
show_selection: focus,
queue,
theme,
key_config,
scroll_top: Cell::new(0),
pending: true,
visible: false,
revision: None,
}
}
pub fn set_commit(&mut self, revision: Option<CommitId>) {
self.revision = revision;
}
pub fn update(&mut self, list: &[StatusItem]) -> Result<()> {
self.pending = false;
let new_hash = hash(list);
if self.current_hash != new_hash {
self.tree.update(list)?;
self.current_hash = new_hash;
}
Ok(())
}
pub fn selection(&self) -> Option<FileTreeItem> {
self.tree.selected_item()
}
pub fn selection_file(&self) -> Option<StatusItem> {
self.tree.selected_item().and_then(|f| {
if let FileTreeItemKind::File(f) = f.kind {
Some(f)
} else {
None
}
})
}
pub fn show_selection(&mut self, show: bool) {
self.show_selection = show;
}
pub fn is_empty(&self) -> bool {
self.tree.is_empty()
}
pub const fn file_count(&self) -> usize {
self.tree.tree.file_count()
}
pub fn set_title(&mut self, title: String) {
self.title = title;
}
pub fn clear(&mut self) -> Result<()> {
self.current_hash = 0;
self.pending = true;
self.tree.update(&[])
}
pub fn is_file_seleted(&self) -> bool {
self.tree.selected_item().map_or(false, |item| {
match item.kind {
FileTreeItemKind::File(_) => true,
FileTreeItemKind::Path(..) => false,
}
})
}
fn move_selection(&mut self, dir: MoveSelection) -> bool {
let changed = self.tree.move_selection(dir);
if changed {
if let Some(ref queue) = self.queue {
queue.push(InternalEvent::Update(NeedsUpdate::DIFF));
}
}
changed
}
const fn item_status_char(item_type: StatusItemType) -> char {
match item_type {
StatusItemType::Modified => 'M',
StatusItemType::New => '+',
StatusItemType::Deleted => '-',
StatusItemType::Renamed => 'R',
StatusItemType::Typechange => ' ',
StatusItemType::Conflicted => '!',
}
}
fn item_to_text<'b>(
string: &str,
indent: usize,
visible: bool,
file_item_kind: &FileTreeItemKind,
width: u16,
selected: bool,
theme: &'b SharedTheme,
) -> Option<Span<'b>> {
let indent_str = if indent == 0 {
String::new()
} else {
format!("{:w$}", " ", w = indent * 2)
};
if !visible {
return None;
}
match file_item_kind {
FileTreeItemKind::File(status_item) => {
let status_char =
Self::item_status_char(status_item.status);
let file = Path::new(&status_item.path)
.file_name()
.and_then(std::ffi::OsStr::to_str)
.expect("invalid path.");
let txt = if selected {
format!(
"{} {}{:w$}",
status_char,
indent_str,
file,
w = width as usize
)
} else {
format!("{status_char} {indent_str}{file}")
};
Some(Span::styled(
Cow::from(txt),
theme.item(status_item.status, selected),
))
}
FileTreeItemKind::Path(path_collapsed) => {
let collapse_char =
if path_collapsed.0 { 'â–¸' } else { 'â–¾' };
let txt = if selected {
format!(
" {}{}{:w$}",
indent_str,
collapse_char,
string,
w = width as usize
)
} else {
format!(
" {}{}{}",
indent_str, collapse_char, string,
)
};
Some(Span::styled(
Cow::from(txt),
theme.text(true, selected),
))
}
}
}
fn build_vec_text_draw_info_for_drawing(
&self,
) -> (Vec<TextDrawInfo>, usize, usize) {
let mut should_skip_over: usize = 0;
let mut selection_offset: usize = 0;
let mut selection_offset_visible: usize = 0;
let mut vec_draw_text_info: Vec<TextDrawInfo> = vec![];
let tree_items = self.tree.tree.items();
for (index, item) in tree_items.iter().enumerate() {
if should_skip_over > 0 {
should_skip_over -= 1;
continue;
}
let index_above_select =
index < self.tree.selection.unwrap_or(0);
if !item.info.visible && index_above_select {
selection_offset_visible += 1;
}
vec_draw_text_info.push(TextDrawInfo {
name: item.info.path.clone(),
indent: item.info.indent,
visible: item.info.visible,
item_kind: &item.kind,
});
let mut idx_temp = index;
while idx_temp < tree_items.len().saturating_sub(2)
&& tree_items[idx_temp].info.indent
< tree_items[idx_temp + 1].info.indent
{
idx_temp += 1;
should_skip_over += 1;
if let FileTreeItemKind::File(_) =
&tree_items[idx_temp].kind
{
should_skip_over -= 1;
break;
}
else if self
.tree
.tree
.multiple_items_at_path(idx_temp)
{
should_skip_over -= 1;
break;
}
let vec_draw_text_info_len = vec_draw_text_info.len();
vec_draw_text_info[vec_draw_text_info_len - 1]
.name += &(String::from("/")
+ &tree_items[idx_temp].info.path);
if index_above_select {
selection_offset += 1;
}
}
}
(
vec_draw_text_info,
selection_offset,
selection_offset_visible,
)
}
}
struct TextDrawInfo<'a> {
name: String,
indent: u8,
visible: bool,
item_kind: &'a FileTreeItemKind,
}
impl DrawableComponent for StatusTreeComponent {
fn draw<B: Backend>(
&self,
f: &mut Frame<B>,
r: Rect,
) -> Result<()> {
if !self.is_visible() {
return Ok(());
}
if self.pending {
let items = vec![Span::styled(
Cow::from(strings::loading_text(&self.key_config)),
self.theme.text(false, false),
)];
ui::draw_list(
f,
r,
self.title.as_str(),
items.into_iter(),
self.focused,
&self.theme,
);
} else {
let (
vec_draw_text_info,
selection_offset,
selection_offset_visible,
) = self.build_vec_text_draw_info_for_drawing();
let select = self
.tree
.selection
.map(|idx| idx.saturating_sub(selection_offset))
.unwrap_or_default();
let tree_height = r.height.saturating_sub(2) as usize;
self.scroll_top.set(ui::calc_scroll_top(
self.scroll_top.get(),
tree_height,
select.saturating_sub(selection_offset_visible),
));
let items = vec_draw_text_info
.iter()
.enumerate()
.filter_map(|(index, draw_text_info)| {
Self::item_to_text(
&draw_text_info.name,
draw_text_info.indent as usize,
draw_text_info.visible,
draw_text_info.item_kind,
r.width,
self.show_selection && select == index,
&self.theme,
)
})
.skip(self.scroll_top.get());
ui::draw_list(
f,
r,
self.title.as_str(),
items,
self.focused,
&self.theme,
);
}
Ok(())
}
}
impl Component for StatusTreeComponent {
fn commands(
&self,
out: &mut Vec<CommandInfo>,
force_all: bool,
) -> CommandBlocking {
out.push(
CommandInfo::new(
strings::commands::navigate_tree(&self.key_config),
!self.is_empty(),
self.focused || force_all,
)
.order(order::NAV),
);
out.push(
CommandInfo::new(
strings::commands::blame_file(&self.key_config),
self.selection_file().is_some(),
self.focused || force_all,
)
.order(order::RARE_ACTION),
);
out.push(
CommandInfo::new(
strings::commands::open_file_history(
&self.key_config,
),
self.selection_file().is_some(),
self.focused || force_all,
)
.order(order::RARE_ACTION),
);
out.push(
CommandInfo::new(
strings::commands::edit_item(&self.key_config),
self.selection_file().is_some(),
self.focused || force_all,
)
.order(order::RARE_ACTION),
);
CommandBlocking::PassingOn
}
fn event(&mut self, ev: &Event) -> Result<EventState> {
if self.focused {
if let Event::Key(e) = ev {
return if key_match(e, self.key_config.keys.blame) {
if let Some(status_item) = self.selection_file() {
self.hide();
if let Some(queue) = &self.queue {
queue.push(InternalEvent::OpenPopup(
StackablePopupOpen::BlameFile(
BlameFileOpen {
file_path: status_item.path,
commit_id: self.revision,
selection: None,
},
),
));
}
}
Ok(EventState::Consumed)
} else if key_match(
e,
self.key_config.keys.file_history,
) {
if let Some(status_item) = self.selection_file() {
self.hide();
if let Some(queue) = &self.queue {
queue.push(InternalEvent::OpenPopup(
StackablePopupOpen::FileRevlog(
FileRevOpen::new(
status_item.path,
),
),
));
}
}
Ok(EventState::Consumed)
} else if key_match(e, self.key_config.keys.edit_file)
{
if let Some(status_item) = self.selection_file() {
if let Some(queue) = &self.queue {
queue.push(
InternalEvent::OpenExternalEditor(
Some(status_item.path),
),
);
}
}
Ok(EventState::Consumed)
} else if key_match(e, self.key_config.keys.move_down)
{
Ok(self
.move_selection(MoveSelection::Down)
.into())
} else if key_match(e, self.key_config.keys.move_up) {
Ok(self.move_selection(MoveSelection::Up).into())
} else if key_match(e, self.key_config.keys.home)
|| key_match(e, self.key_config.keys.shift_up)
{
Ok(self
.move_selection(MoveSelection::Home)
.into())
} else if key_match(e, self.key_config.keys.end)
|| key_match(e, self.key_config.keys.shift_down)
{
Ok(self.move_selection(MoveSelection::End).into())
} else if key_match(e, self.key_config.keys.move_left)
{
Ok(self
.move_selection(MoveSelection::Left)
.into())
} else if key_match(
e,
self.key_config.keys.move_right,
) {
Ok(self
.move_selection(MoveSelection::Right)
.into())
} else {
Ok(EventState::NotConsumed)
};
}
}
Ok(EventState::NotConsumed)
}
fn focused(&self) -> bool {
self.focused
}
fn focus(&mut self, focus: bool) {
self.focused = focus;
self.show_selection(focus);
}
fn is_visible(&self) -> bool {
self.visible
}
fn hide(&mut self) {
self.visible = false;
}
fn show(&mut self) -> Result<()> {
self.visible = true;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use asyncgit::StatusItemType;
fn string_vec_to_status(items: &[&str]) -> Vec<StatusItem> {
items
.iter()
.map(|a| StatusItem {
path: String::from(*a),
status: StatusItemType::Modified,
})
.collect::<Vec<_>>()
}
#[test]
fn test_correct_scroll_position() {
let items = string_vec_to_status(&[
"a/b/b1", "a/b/b2", "a/c/c1", ]);
let test_backend = tui::backend::TestBackend::new(100, 100);
let mut terminal = tui::Terminal::new(test_backend)
.expect("Unable to set up terminal");
let mut frame = terminal.get_frame();
let mut ftc = StatusTreeComponent::new(
"title",
true,
None,
SharedTheme::default(),
SharedKeyConfig::default(),
);
ftc.update(&items)
.expect("Updating FileTreeComponent failed");
ftc.move_selection(MoveSelection::Down); ftc.move_selection(MoveSelection::Left); ftc.move_selection(MoveSelection::Down);
ftc.draw(&mut frame, Rect::new(0, 0, 10, 5))
.expect("Draw failed");
assert_eq!(ftc.scroll_top.get(), 0); }
#[test]
fn test_correct_foldup_and_not_visible_scroll_position() {
let items = string_vec_to_status(&[
"a/b/b1", "c/d1", "c/d2", ]);
let test_backend = tui::backend::TestBackend::new(100, 100);
let mut terminal = tui::Terminal::new(test_backend)
.expect("Unable to set up terminal");
let mut frame = terminal.get_frame();
let mut ftc = StatusTreeComponent::new(
"title",
true,
None,
SharedTheme::default(),
SharedKeyConfig::default(),
);
ftc.update(&items)
.expect("Updating FileTreeComponent failed");
ftc.move_selection(MoveSelection::Left); ftc.move_selection(MoveSelection::Down);
ftc.draw(&mut frame, Rect::new(0, 0, 10, 5))
.expect("Draw failed");
assert_eq!(ftc.scroll_top.get(), 0); }
}