From eeaa5157bbd04b874bcb082498ac343b4cc4a308 Mon Sep 17 00:00:00 2001 From: Stephan Dilly Date: Sun, 26 Apr 2020 01:31:34 +0200 Subject: [PATCH 1/3] Switch to a tree view for changed files list --- .gitignore | 1 + Cargo.lock | 7 + Cargo.toml | 2 + asyncgit/Cargo.toml | 3 +- asyncgit/src/sync/status.rs | 4 +- scopetime/Cargo.toml | 3 +- src/app.rs | 26 +- src/components/changes.rs | 291 ++++++++++------ src/components/filetree.rs | 295 ++++++++++++++++ src/components/mod.rs | 3 + src/components/statustree.rs | 642 +++++++++++++++++++++++++++++++++++ src/keys.rs | 2 + src/main.rs | 2 +- src/strings.rs | 6 + src/ui/mod.rs | 1 - 15 files changed, 1176 insertions(+), 112 deletions(-) create mode 100644 src/components/filetree.rs create mode 100644 src/components/statustree.rs diff --git a/.gitignore b/.gitignore index 05923927ff..1c60ceded3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target +/release .DS_Store diff --git a/Cargo.lock b/Cargo.lock index 6adf6d974d..9de8d9c4e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -291,6 +291,7 @@ dependencies = [ "dirs", "itertools", "log", + "maplit", "rayon-core", "scopeguard", "scopetime", @@ -418,6 +419,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + [[package]] name = "matches" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index da7cf3e1c7..3a7dad01fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ description = "blazing fast terminal-ui for git" edition = "2018" exclude = [".github/*",".vscode/*"] homepage = "https://github.com/extrawurst/gitui" +repository = "https://github.com/extrawurst/gitui" readme = "README.md" license = "MIT" categories = ["command-line-utilities"] @@ -28,6 +29,7 @@ dirs = "2.0" crossbeam-channel = "0.4" scopeguard = "1.1" bitflags = "1.2" +maplit = "1.0" backtrace = { version = "0.3" } scopetime = { path = "./scopetime", version = "0.1" } asyncgit = { path = "./asyncgit", version = "0.1" } diff --git a/asyncgit/Cargo.toml b/asyncgit/Cargo.toml index 7a70387b03..0e1aaf993b 100644 --- a/asyncgit/Cargo.toml +++ b/asyncgit/Cargo.toml @@ -4,7 +4,8 @@ version = "0.1.8" authors = ["Stephan Dilly "] edition = "2018" description = "allow using git2 in a asynchronous context" -homepage = "https://gitui.org" +homepage = "https://github.com/extrawurst/gitui" +repository = "https://github.com/extrawurst/gitui" readme = "README.md" license = "MIT" categories = ["concurrency","asynchronous"] diff --git a/asyncgit/src/sync/status.rs b/asyncgit/src/sync/status.rs index 1e081797d0..af44ddad12 100644 --- a/asyncgit/src/sync/status.rs +++ b/asyncgit/src/sync/status.rs @@ -5,7 +5,7 @@ use git2::{Status, StatusOptions, StatusShow}; use scopetime::scope_time; /// -#[derive(Copy, Clone, Hash)] +#[derive(Copy, Clone, Hash, PartialEq, Debug)] pub enum StatusItemType { /// New, @@ -36,7 +36,7 @@ impl From for StatusItemType { } /// -#[derive(Default, Clone, Hash)] +#[derive(Default, Clone, Hash, PartialEq, Debug)] pub struct StatusItem { /// pub path: String, diff --git a/scopetime/Cargo.toml b/scopetime/Cargo.toml index a60cb5cd7e..ddee4a5882 100644 --- a/scopetime/Cargo.toml +++ b/scopetime/Cargo.toml @@ -4,7 +4,8 @@ version = "0.1.1" authors = ["Stephan Dilly "] edition = "2018" description = "log runtime of arbitrary scope" -homepage = "https://gitui.org" +homepage = "https://github.com/extrawurst/gitui" +repository = "https://github.com/extrawurst/gitui" license = "MIT" readme = "README.md" categories = ["development-tools::profiling"] diff --git a/src/app.rs b/src/app.rs index 67571061f2..e88a3bbe29 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,7 +2,8 @@ use crate::{ components::{ ChangesComponent, CommandBlocking, CommandInfo, CommitComponent, Component, DiffComponent, DrawableComponent, - HelpComponent, MsgComponent, ResetComponent, + FileTreeItemKind, HelpComponent, MsgComponent, + ResetComponent, }, keys, queue::{InternalEvent, NeedsUpdate, Queue}, @@ -177,7 +178,9 @@ impl App { self.switch_focus(Focus::WorkDir) } keys::FOCUS_STAGE => self.switch_focus(Focus::Stage), - keys::FOCUS_RIGHT => self.switch_focus(Focus::Diff), + keys::FOCUS_RIGHT if self.can_focus_diff() => { + self.switch_focus(Focus::Diff) + } keys::FOCUS_LEFT => { self.switch_focus(match self.diff_target { DiffTarget::Stage => Focus::Stage, @@ -225,6 +228,14 @@ impl App { pub fn is_quit(&self) -> bool { self.do_quit } + + fn can_focus_diff(&self) -> bool { + match self.focus { + Focus::WorkDir => self.index_wd.is_file_seleted(), + Focus::Stage => self.index.is_file_seleted(), + _ => false, + } + } } // private impls @@ -259,11 +270,12 @@ impl App { DiffTarget::WorkingDir => (&self.index_wd, false), }; - if let Some(i) = idx.selection() { - Some((i.path, is_stage)) - } else { - None + if let Some(item) = idx.selection() { + if let FileTreeItemKind::File(i) = item.kind { + return Some((i.path, is_stage)); + } } + None } fn update_commands(&mut self) { @@ -381,7 +393,7 @@ impl App { )); res.push(CommandInfo::new( commands::STATUS_FOCUS_RIGHT, - true, + self.can_focus_diff(), main_cmds_available && !focus_on_diff, )); } diff --git a/src/components/changes.rs b/src/components/changes.rs index 9ce64c6cb3..57063a516e 100644 --- a/src/components/changes.rs +++ b/src/components/changes.rs @@ -1,4 +1,8 @@ -use super::{CommandBlocking, DrawableComponent}; +use super::{ + filetree::{FileTreeItem, FileTreeItemKind}, + statustree::{MoveSelection, StatusTree}, + CommandBlocking, DrawableComponent, +}; use crate::{ components::{CommandInfo, Component}, keys, @@ -7,17 +11,12 @@ use crate::{ }; use asyncgit::{hash, sync, StatusItem, StatusItemType, CWD}; use crossterm::event::Event; -use std::{ - borrow::Cow, - cmp, - convert::{From, TryFrom}, - path::Path, -}; +use std::{borrow::Cow, convert::From, path::Path}; use strings::commands; use tui::{ backend::Backend, layout::Rect, - style::{Color, Modifier, Style}, + style::{Color, Style}, widgets::Text, Frame, }; @@ -25,8 +24,8 @@ use tui::{ /// pub struct ChangesComponent { title: String, - items: Vec, - selection: Option, + tree: StatusTree, + current_hash: u64, focused: bool, show_selection: bool, is_working_dir: bool, @@ -43,9 +42,8 @@ impl ChangesComponent { ) -> Self { Self { title: title.to_string(), - items: Vec::new(), - - selection: None, + tree: StatusTree::default(), + current_hash: 0, focused: focus, show_selection: focus, is_working_dir, @@ -55,24 +53,16 @@ impl ChangesComponent { /// pub fn update(&mut self, list: &[StatusItem]) { - if hash(&self.items) != hash(list) { - self.items = list.to_owned(); - - let old_selection = self.selection.unwrap_or_default(); - self.selection = if self.items.is_empty() { - None - } else { - Some(cmp::min(old_selection, self.items.len() - 1)) - }; + let new_hash = hash(list); + if self.current_hash != new_hash { + self.tree.update(list); + self.current_hash = new_hash; } } /// - pub fn selection(&self) -> Option { - match self.selection { - None => None, - Some(i) => Some(self.items[i].clone()), - } + pub fn selection(&self) -> Option { + self.tree.selected_item() } /// @@ -81,52 +71,55 @@ impl ChangesComponent { self.show_selection = focus; } - /// + /// returns true if list is empty pub fn is_empty(&self) -> bool { - self.items.is_empty() + self.tree.is_empty() } - fn move_selection(&mut self, delta: i32) -> bool { - let items_len = self.items.len(); - if items_len > 0 { - if let Some(i) = self.selection { - if let Ok(mut i) = i32::try_from(i) { - if let Ok(max) = i32::try_from(items_len) { - i = cmp::min(i + delta, max - 1); - i = cmp::max(i, 0); - - if let Ok(i) = usize::try_from(i) { - self.selection = Some(i); - self.queue.borrow_mut().push_back( - InternalEvent::Update( - NeedsUpdate::DIFF, - ), - ); - return true; - } - } - } + /// + pub fn is_file_seleted(&self) -> bool { + if let Some(item) = self.tree.selected_item() { + match item.kind { + FileTreeItemKind::File(_) => true, + _ => false, } + } else { + false } - false + } + + fn move_selection(&mut self, dir: MoveSelection) -> bool { + let changed = self.tree.move_selection(dir); + + if changed { + self.queue + .borrow_mut() + .push_back(InternalEvent::Update(NeedsUpdate::DIFF)); + } + + changed } fn index_add_remove(&mut self) -> bool { - if let Some(i) = self.selection() { - if self.is_working_dir { - if let Some(status) = i.status { + if let Some(tree_item) = self.selection() { + if let FileTreeItemKind::File(i) = tree_item.kind { + if self.is_working_dir { + if let Some(status) = i.status { + let path = Path::new(i.path.as_str()); + return match status { + StatusItemType::Deleted => { + sync::stage_addremoved(CWD, path) + } + _ => sync::stage_add(CWD, path), + }; + } + } else { let path = Path::new(i.path.as_str()); - return match status { - StatusItemType::Deleted => { - sync::stage_addremoved(CWD, path) - } - _ => sync::stage_add(CWD, path), - }; + + return sync::reset_stage(CWD, path); } } else { - let path = Path::new(i.path.as_str()); - - return sync::reset_stage(CWD, path); + todo!() } } @@ -134,55 +127,145 @@ impl ChangesComponent { } fn dispatch_reset_workdir(&mut self) -> bool { - if let Some(i) = self.selection() { - self.queue - .borrow_mut() - .push_back(InternalEvent::ConfirmResetFile(i.path)); + if let Some(tree_item) = self.selection() { + if let FileTreeItemKind::File(i) = tree_item.kind { + self.queue.borrow_mut().push_back( + InternalEvent::ConfirmResetFile(i.path), + ); - return true; + return true; + } else { + todo!() + } } false } + + fn item_to_text( + item: &FileTreeItem, + width: u16, + selected: bool, + ) -> Option { + let indent_str = if item.info.indent == 0 { + String::from("") + } else { + format!("{:w$}", " ", w = (item.info.indent as usize) * 2) + }; + + if !item.info.visible { + return None; + } + + match &item.kind { + FileTreeItemKind::File(status_item) => { + let file = Path::new(&status_item.path) + .file_name() + .unwrap() + .to_str() + .unwrap(); + + let txt = if selected { + format!( + "{}{:w$}", + indent_str, + file, + w = width as usize + ) + } else { + format!("{}{}", indent_str, file) + }; + + let mut style = Style::default().fg( + match status_item + .status + .unwrap_or(StatusItemType::Modified) + { + StatusItemType::Modified => { + Color::LightYellow + } + StatusItemType::New => Color::LightGreen, + StatusItemType::Deleted => Color::LightRed, + _ => Color::White, + }, + ); + + if selected { + style = style.bg(Color::Blue); + } + + Some(Text::Styled(Cow::from(txt), style)) + } + + FileTreeItemKind::Path(path_collapsed) => { + let collapse_char = + if path_collapsed.0 { '▸' } else { '▾' }; + + let txt = if selected { + format!( + "{}{}{:w$}", + indent_str, + collapse_char, + item.info.path, + w = width as usize + ) + } else { + format!( + "{}{}{}", + indent_str, collapse_char, item.info.path, + ) + }; + + let mut style = Style::default(); + + if selected { + style = style.bg(Color::Blue); + } + + Some(Text::Styled(Cow::from(txt), style)) + } + } + } } impl DrawableComponent for ChangesComponent { fn draw(&self, f: &mut Frame, r: Rect) { - let item_to_text = |idx: usize, i: &StatusItem| -> Text { - let selected = self.show_selection - && self.selection.map_or(false, |e| e == idx); - let txt = if selected { - format!("> {}", i.path) - } else { - format!(" {}", i.path) - }; - let mut style = Style::default().fg( - match i.status.unwrap_or(StatusItemType::Modified) { - StatusItemType::Modified => Color::LightYellow, - StatusItemType::New => Color::LightGreen, - StatusItemType::Deleted => Color::LightRed, - _ => Color::White, + let selection_offset = + self.tree.tree.items().iter().enumerate().fold( + 0, + |acc, (idx, e)| { + let visible = e.info.visible; + let index_above_select = + idx < self.tree.selection.unwrap_or(0); + + if !visible && index_above_select { + acc + 1 + } else { + acc + } }, ); - if selected { - style = style.modifier(Modifier::BOLD); - } - Text::Styled(Cow::from(txt), style) - }; + let items = + self.tree.tree.items().iter().enumerate().filter_map( + |(idx, e)| { + Self::item_to_text( + e, + r.width, + self.show_selection + && self + .tree + .selection + .map_or(false, |e| e == idx), + ) + }, + ); ui::draw_list( f, r, &self.title.to_string(), - self.items - .iter() - .enumerate() - .map(|(idx, e)| item_to_text(idx, e)), - if self.show_selection { - self.selection - } else { - None - }, + items, + self.tree.selection.map(|idx| idx - selection_offset), self.focused, ); } @@ -215,8 +298,8 @@ impl Component for ChangesComponent { } out.push(CommandInfo::new( - commands::SCROLL, - self.items.len() > 1, + commands::NAVIGATE_TREE, + !self.is_empty(), self.focused, )); @@ -242,8 +325,18 @@ impl Component for ChangesComponent { self.is_working_dir && self.dispatch_reset_workdir() } - keys::MOVE_DOWN => self.move_selection(1), - keys::MOVE_UP => self.move_selection(-1), + keys::MOVE_DOWN => { + self.move_selection(MoveSelection::Down) + } + keys::MOVE_UP => { + self.move_selection(MoveSelection::Up) + } + keys::MOVE_LEFT => { + self.move_selection(MoveSelection::Left) + } + keys::MOVE_RIGHT => { + self.move_selection(MoveSelection::Right) + } _ => false, }; } diff --git a/src/components/filetree.rs b/src/components/filetree.rs new file mode 100644 index 0000000000..94d09aa839 --- /dev/null +++ b/src/components/filetree.rs @@ -0,0 +1,295 @@ +use asyncgit::StatusItem; +use std::{ + collections::{BTreeSet, BinaryHeap}, + convert::TryFrom, + ops::{Index, IndexMut}, + path::Path, +}; + +/// holds the information shared among all `FileTreeItem` in a `FileTree` +#[derive(Debug, Clone)] +pub struct TreeItemInfo { + /// indent level + pub indent: u8, + /// currently visible depending on the folder collapse states + pub visible: bool, + /// just the last path element + pub path: String, + /// the full path + pub full_path: String, +} + +impl TreeItemInfo { + fn new(indent: u8, path: String, full_path: String) -> Self { + Self { + indent, + visible: true, + path, + full_path, + } + } +} + +/// attribute used to indicate the collapse/expand state of a path item +#[derive(PartialEq, Debug, Copy, Clone)] +pub struct PathCollapsed(pub bool); + +/// `FileTreeItem` can be of two kinds +#[derive(PartialEq, Debug, Clone)] +pub enum FileTreeItemKind { + Path(PathCollapsed), + File(StatusItem), +} + +/// `FileTreeItem` can be of two kinds: see `FileTreeItem` but shares an info +#[derive(Debug, Clone)] +pub struct FileTreeItem { + pub info: TreeItemInfo, + pub kind: FileTreeItemKind, +} + +impl FileTreeItem { + fn new_file(item: &StatusItem) -> Self { + let item_path = Path::new(&item.path); + let indent = u8::try_from( + item_path.ancestors().count().saturating_sub(2), + ) + .unwrap(); + let path = String::from( + item_path.file_name().unwrap().to_str().unwrap(), + ); + + Self { + info: TreeItemInfo::new(indent, path, item.path.clone()), + kind: FileTreeItemKind::File(item.clone()), + } + } + + fn new_path( + path: &Path, + path_string: String, + collapsed: bool, + ) -> Self { + let indent = + u8::try_from(path.ancestors().count().saturating_sub(2)) + .unwrap(); + let path = String::from( + path.components() + .last() + .unwrap() + .as_os_str() + .to_str() + .unwrap(), + ); + + Self { + info: TreeItemInfo::new(indent, path, path_string), + kind: FileTreeItemKind::Path(PathCollapsed(collapsed)), + } + } +} + +impl Eq for FileTreeItem {} + +impl PartialEq for FileTreeItem { + fn eq(&self, other: &Self) -> bool { + self.info.full_path.eq(&other.info.full_path) + } +} + +impl PartialOrd for FileTreeItem { + fn partial_cmp( + &self, + other: &Self, + ) -> Option { + self.info.full_path.partial_cmp(&other.info.full_path) + } +} + +impl Ord for FileTreeItem { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.info.path.cmp(&other.info.path) + } +} + +/// +#[derive(Default)] +pub struct FileTreeItems(Vec); + +impl FileTreeItems { + /// + pub(crate) fn new( + list: &[StatusItem], + collapsed: &BTreeSet<&String>, + ) -> Self { + let mut nodes = BinaryHeap::with_capacity(list.len()); + let mut paths_added = BTreeSet::new(); + + for e in list { + let item_path = Path::new(&e.path); + + FileTreeItems::push_dirs( + item_path, + &mut nodes, + &mut paths_added, + &collapsed, + ); + + nodes.push(FileTreeItem::new_file(e)); + } + + Self(nodes.into_sorted_vec()) + } + + /// + pub(crate) fn items(&self) -> &Vec { + &self.0 + } + + /// + pub(crate) fn len(&self) -> usize { + self.0.len() + } + + fn push_dirs( + item_path: &Path, + nodes: &mut BinaryHeap, + paths_added: &mut BTreeSet, //TODO: use a ref string here + collapsed: &BTreeSet<&String>, + ) { + for c in item_path.ancestors().skip(1) { + if c.parent().is_some() { + let path_string = String::from(c.to_str().unwrap()); + if !paths_added.contains(&path_string) { + paths_added.insert(path_string.clone()); + let is_collapsed = + collapsed.contains(&path_string); + nodes.push(FileTreeItem::new_path( + c, + path_string, + is_collapsed, + )); + } + } + } + } +} + +impl IndexMut for FileTreeItems { + fn index_mut(&mut self, idx: usize) -> &mut Self::Output { + &mut self.0[idx] + } +} + +impl Index for FileTreeItems { + type Output = FileTreeItem; + + fn index(&self, idx: usize) -> &Self::Output { + &self.0[idx] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn string_vec_to_status(items: &[&str]) -> Vec { + items + .iter() + .map(|a| StatusItem { + path: String::from(*a), + status: None, + }) + .collect::>() + } + + #[test] + fn test_simple() { + let items = string_vec_to_status(&[ + "file.txt", // + ]); + + let res = FileTreeItems::new(&items, &BTreeSet::new()); + + assert_eq!( + res.0, + vec![FileTreeItem { + info: TreeItemInfo { + path: items[0].path.clone(), + full_path: items[0].path.clone(), + indent: 0, + visible: true, + }, + kind: FileTreeItemKind::File(items[0].clone()) + }] + ); + + let items = string_vec_to_status(&[ + "file.txt", // + "file2.txt", // + ]); + + let res = FileTreeItems::new(&items, &BTreeSet::new()); + + assert_eq!(res.0.len(), 2); + assert_eq!(res.0[1].info.path, items[1].path); + } + + #[test] + fn test_folder() { + let items = string_vec_to_status(&[ + "a/file.txt", // + ]); + + let res = FileTreeItems::new(&items, &BTreeSet::new()) + .0 + .iter() + .map(|i| i.info.full_path.clone()) + .collect::>(); + + assert_eq!( + res, + vec![String::from("a"), items[0].path.clone(),] + ); + } + + #[test] + fn test_indent() { + let items = string_vec_to_status(&[ + "a/b/file.txt", // + ]); + + let list = FileTreeItems::new(&items, &BTreeSet::new()); + let mut res = list + .0 + .iter() + .map(|i| (i.info.indent, i.info.path.as_str())); + + assert_eq!(res.next(), Some((0, "a"))); + assert_eq!(res.next(), Some((1, "b"))); + assert_eq!(res.next(), Some((2, "file.txt"))); + } + + #[test] + fn test_folder_dup() { + let items = string_vec_to_status(&[ + "a/file.txt", // + "a/file2.txt", // + ]); + + let res = FileTreeItems::new(&items, &BTreeSet::new()) + .0 + .iter() + .map(|i| i.info.full_path.clone()) + .collect::>(); + + assert_eq!( + res, + vec![ + String::from("a"), + items[0].path.clone(), + items[1].path.clone() + ] + ); + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 343bdb7d86..b4f8c22c3a 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -5,13 +5,16 @@ mod changes; mod command; mod commit; mod diff; +mod filetree; mod help; mod msg; mod reset; +mod statustree; pub use changes::ChangesComponent; pub use command::{CommandInfo, CommandText}; pub use commit::CommitComponent; pub use diff::DiffComponent; +pub use filetree::FileTreeItemKind; pub use help::HelpComponent; pub use msg::MsgComponent; pub use reset::ResetComponent; diff --git a/src/components/statustree.rs b/src/components/statustree.rs new file mode 100644 index 0000000000..ce3aa6bb87 --- /dev/null +++ b/src/components/statustree.rs @@ -0,0 +1,642 @@ +use super::filetree::{ + FileTreeItem, FileTreeItemKind, FileTreeItems, PathCollapsed, +}; +use asyncgit::StatusItem; +use std::{cmp, collections::BTreeSet, path::Path}; + +/// +#[derive(Default)] +pub struct StatusTree { + pub tree: FileTreeItems, + pub selection: Option, +} + +/// +#[derive(Copy, Clone, Debug)] +pub enum MoveSelection { + Up, + Down, + Left, + Right, +} + +struct SelectionChange { + new_index: usize, + changes: bool, +} +impl SelectionChange { + fn new(new_index: usize, changes: bool) -> Self { + Self { new_index, changes } + } +} + +impl StatusTree { + /// update tree with a new list, try to retain selection and collapse states + pub fn update(&mut self, list: &[StatusItem]) { + let last_collapsed = self.all_collapsed(); + + let last_selection = + self.selected_item().map(|e| e.info.full_path); + let last_selection_index = self.selection.unwrap_or(0); + + self.tree = FileTreeItems::new(list, &last_collapsed); + self.selection = + if let Some(ref last_selection) = last_selection { + self.find_last_selection( + last_selection, + last_selection_index, + ) + .or_else(|| self.tree.items().first().map(|_| 0)) + } else { + // simply select first + self.tree.items().first().map(|_| 0) + }; + + self.update_visibility(None, 0, true); + } + + /// + pub fn move_selection(&mut self, dir: MoveSelection) -> bool { + if let Some(selection) = self.selection { + let selection_change = match dir { + MoveSelection::Up => { + self.selection_updown(selection, true) + } + MoveSelection::Down => { + self.selection_updown(selection, false) + } + + MoveSelection::Left => self.selection_left(selection), + MoveSelection::Right => { + self.selection_right(selection) + } + }; + + let changed = selection_change.new_index != selection; + + self.selection = Some(selection_change.new_index); + + changed || selection_change.changes + } else { + false + } + } + + /// + pub fn selected_item(&self) -> Option { + self.selection.map(|i| self.tree[i].clone()) + } + + /// + pub fn is_empty(&self) -> bool { + self.tree.items().is_empty() + } + + fn all_collapsed(&self) -> BTreeSet<&String> { + let mut res = BTreeSet::new(); + + for i in self.tree.items() { + if let FileTreeItemKind::Path(PathCollapsed(collapsed)) = + i.kind + { + if collapsed { + res.insert(&i.info.full_path); + } + } + } + + res + } + + fn find_last_selection( + &self, + last_selection: &str, + last_index: usize, + ) -> Option { + if self.is_empty() { + return None; + } + + if let Ok(i) = self.tree.items().binary_search_by(|e| { + e.info.full_path.as_str().cmp(last_selection) + }) { + return Some(i); + } + + Some(cmp::min(last_index, self.tree.len() - 1)) + } + + fn selection_updown( + &self, + current_index: usize, + up: bool, + ) -> SelectionChange { + let mut new_index = current_index; + + let items_max = self.tree.len().saturating_sub(1); + + loop { + new_index = if up { + new_index.saturating_sub(1) + } else { + new_index.saturating_add(1) + }; + + new_index = cmp::min(new_index, items_max); + + if self.is_visible_index(new_index) { + break; + } + + if new_index == 0 || new_index == items_max { + // limit reached, dont update + new_index = current_index; + break; + } + } + + SelectionChange::new(new_index, false) + } + + fn is_visible_index(&self, idx: usize) -> bool { + self.tree[idx].info.visible + } + + fn selection_right( + &mut self, + current_selection: usize, + ) -> SelectionChange { + let item_kind = self.tree[current_selection].kind.clone(); + let item_path = + self.tree[current_selection].info.full_path.clone(); + + if matches!(item_kind, FileTreeItemKind::Path(PathCollapsed(collapsed)) + if collapsed) + { + self.expand(&item_path, current_selection); + return SelectionChange::new(current_selection, true); + } + + SelectionChange::new(current_selection, false) + } + + fn selection_left( + &mut self, + current_selection: usize, + ) -> SelectionChange { + let item_kind = self.tree[current_selection].kind.clone(); + let item_path = + self.tree[current_selection].info.full_path.clone(); + + if matches!(item_kind, FileTreeItemKind::File(_)) + || matches!(item_kind,FileTreeItemKind::Path(PathCollapsed(collapsed)) + if collapsed) + { + SelectionChange::new( + self.find_parent_index(&item_path, current_selection), + false, + ) + } else if matches!(item_kind, FileTreeItemKind::Path(PathCollapsed(collapsed)) + if !collapsed) + { + self.collapse(&item_path, current_selection); + SelectionChange::new(current_selection, true) + } else { + SelectionChange::new(current_selection, false) + } + } + + fn collapse(&mut self, path: &str, index: usize) { + if let FileTreeItemKind::Path(PathCollapsed( + ref mut collapsed, + )) = self.tree[index].kind + { + *collapsed = true; + } + + let path = format!("{}/", path); + + for i in index + 1..self.tree.len() { + let item = &mut self.tree[i]; + let item_path = &item.info.full_path; + if item_path.starts_with(&path) { + item.info.visible = false + } else { + return; + } + } + } + + fn expand(&mut self, path: &str, current_index: usize) { + if let FileTreeItemKind::Path(PathCollapsed( + ref mut collapsed, + )) = self.tree[current_index].kind + { + *collapsed = false; + } + + let path = format!("{}/", path); + + self.update_visibility( + Some(path.as_str()), + current_index + 1, + false, + ); + } + + fn update_visibility( + &mut self, + prefix: Option<&str>, + start_idx: usize, + set_defaults: bool, + ) { + // if we are in any subpath that is collapsed we keep skipping over it + let mut inner_collapsed: Option = None; + + for i in start_idx..self.tree.len() { + if let Some(ref collapsed_path) = inner_collapsed { + let p: &String = &self.tree[i].info.full_path; + if p.starts_with(collapsed_path) { + if set_defaults { + self.tree[i].info.visible = false; + } + // we are still in a collapsed inner path + continue; + } else { + inner_collapsed = None; + } + } + + let item_kind = self.tree[i].kind.clone(); + let item_path = &self.tree[i].info.full_path; + + if matches!(item_kind, FileTreeItemKind::Path(PathCollapsed(collapsed)) if collapsed) + { + // we encountered an inner path that is still collapsed + inner_collapsed = Some(format!("{}/", &item_path)); + } + + if prefix.is_none() + || item_path.starts_with(prefix.unwrap()) + { + self.tree[i].info.visible = true + } else { + // if we do not set defaults we can early out + if set_defaults { + self.tree[i].info.visible = false; + } else { + return; + } + } + } + } + + fn find_parent_index( + &self, + path: &str, + current_index: usize, + ) -> usize { + let path = Path::new(path); + + if let Some(path) = path.parent() { + for i in (0..=current_index).rev() { + let item = self.tree.items().get(i).unwrap(); + let item_path = &item.info.full_path; + //TODO: use parameter path here + if item_path.ends_with(path.to_str().unwrap()) { + return i; + } + } + } + + 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn string_vec_to_status(items: &[&str]) -> Vec { + items + .iter() + .map(|a| StatusItem { + path: String::from(*a), + status: None, + }) + .collect::>() + } + + fn get_visibles(tree: &StatusTree) -> Vec { + tree.tree + .items() + .iter() + .map(|e| e.info.visible) + .collect::>() + } + + #[test] + fn test_selection() { + let items = string_vec_to_status(&[ + "a/b", // + ]); + + let mut res = StatusTree::default(); + res.update(&items); + + assert!(res.move_selection(MoveSelection::Down)); + + assert_eq!(res.selection, Some(1)); + + assert!(res.move_selection(MoveSelection::Left)); + + assert_eq!(res.selection, Some(0)); + } + + #[test] + fn test_select_parent() { + let items = string_vec_to_status(&[ + "a/b/c", // + "a/b/d", // + ]); + + //0 a/ + //1 b/ + //2 c + //3 d + + let mut res = StatusTree::default(); + res.update(&items); + + assert_eq!( + res.find_parent_index(&String::from("a/b/c"), 3), + 1 + ); + } + + #[test] + fn test_keep_selected_item() { + let mut res = StatusTree::default(); + res.update(&string_vec_to_status(&["b"])); + + assert_eq!(res.selection, Some(0)); + + res.update(&string_vec_to_status(&["b", "a"])); + + assert_eq!(res.selection, Some(1)); + } + + #[test] + fn test_keep_selected_index() { + let mut res = StatusTree::default(); + res.update(&string_vec_to_status(&["a", "b"])); + res.selection = Some(1); + + res.update(&string_vec_to_status(&["d", "c", "a"])); + assert_eq!(res.selection, Some(1)); + } + + #[test] + fn test_keep_collapsed_states() { + let mut res = StatusTree::default(); + res.update(&string_vec_to_status(&[ + "a/b", // + "c", + ])); + + res.collapse("a", 0); + + assert_eq!( + res.all_collapsed().iter().collect::>(), + vec![&&String::from("a")] + ); + + assert_eq!( + get_visibles(&res), + vec![ + true, // + false, // + true, // + ] + ); + + res.update(&string_vec_to_status(&[ + "a/b", // + "c", // + "d", + ])); + + assert_eq!( + res.all_collapsed().iter().collect::>(), + vec![&&String::from("a")] + ); + + assert_eq!( + get_visibles(&res), + vec![ + true, // + false, // + true, // + true + ] + ); + } + + #[test] + fn test_expand() { + let items = string_vec_to_status(&[ + "a/b/c", // + "a/d", // + ]); + + //0 a/ + //1 b/ + //2 c + //3 d + + let mut res = StatusTree::default(); + res.update(&items); + + res.collapse(&String::from("a/b"), 1); + + let visibles = get_visibles(&res); + + assert_eq!( + visibles, + vec![ + true, // + true, // + false, // + true, + ] + ); + + res.expand(&String::from("a/b"), 1); + + let visibles = get_visibles(&res); + + assert_eq!( + visibles, + vec![ + true, // + true, // + true, // + true, + ] + ); + } + + #[test] + fn test_expand_bug() { + let items = string_vec_to_status(&[ + "a/b/c", // + "a/b2/d", // + ]); + + //0 a/ + //1 b/ + //2 c + //3 b2/ + //4 d + + let mut res = StatusTree::default(); + res.update(&items); + + res.collapse(&String::from("b"), 1); + res.collapse(&String::from("a"), 0); + + assert_eq!( + get_visibles(&res), + vec![ + true, // + false, // + false, // + false, // + false, + ] + ); + + res.expand(&String::from("a"), 0); + + assert_eq!( + get_visibles(&res), + vec![ + true, // + true, // + false, // + true, // + true, + ] + ); + } + + #[test] + fn test_collapse_too_much() { + let items = string_vec_to_status(&[ + "a/b", // + "a2/c", // + ]); + + //0 a/ + //1 b + //2 a2/ + //3 c + + let mut res = StatusTree::default(); + res.update(&items); + + res.collapse(&String::from("a"), 0); + + let visibles = get_visibles(&res); + + assert_eq!( + visibles, + vec![ + true, // + false, // + true, // + true, + ] + ); + } + + #[test] + fn test_expand_with_collapsed_sub_parts() { + let items = string_vec_to_status(&[ + "a/b/c", // + "a/d", // + ]); + + //0 a/ + //1 b/ + //2 c + //3 d + + let mut res = StatusTree::default(); + res.update(&items); + + res.collapse(&String::from("a/b"), 1); + + let visibles = get_visibles(&res); + + assert_eq!( + visibles, + vec![ + true, // + true, // + false, // + true, + ] + ); + + res.collapse(&String::from("a"), 0); + + let visibles = get_visibles(&res); + + assert_eq!( + visibles, + vec![ + true, // + false, // + false, // + false, + ] + ); + + res.expand(&String::from("a"), 0); + + let visibles = get_visibles(&res); + + assert_eq!( + visibles, + vec![ + true, // + true, // + false, // + true, + ] + ); + } + + #[test] + fn test_selection_skips_collapsed() { + let items = string_vec_to_status(&[ + "a/b/c", // + "a/d", // + ]); + + //0 a/ + //1 b/ + //2 c + //3 d + + let mut res = StatusTree::default(); + res.update(&items); + res.collapse(&String::from("a/b"), 1); + res.selection = Some(1); + + assert!(res.move_selection(MoveSelection::Down)); + + assert_eq!(res.selection, Some(3)); + } +} diff --git a/src/keys.rs b/src/keys.rs index b19038c823..ee183dfc4e 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -24,6 +24,8 @@ pub const EXIT_2: KeyEvent = no_mod(KeyCode::Char('q')); pub const CLOSE_MSG: KeyEvent = no_mod(KeyCode::Enter); pub const OPEN_COMMIT: KeyEvent = no_mod(KeyCode::Char('c')); pub const OPEN_HELP: KeyEvent = no_mod(KeyCode::Char('h')); +pub const MOVE_LEFT: KeyEvent = no_mod(KeyCode::Left); +pub const MOVE_RIGHT: KeyEvent = no_mod(KeyCode::Right); pub const MOVE_UP: KeyEvent = no_mod(KeyCode::Up); pub const MOVE_DOWN: KeyEvent = no_mod(KeyCode::Down); pub const STATUS_STAGE_FILE: KeyEvent = no_mod(KeyCode::Enter); diff --git a/src/main.rs b/src/main.rs index f128bef9ba..90b53d170e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -155,7 +155,7 @@ fn setup_logging() { let mut path = dirs::cache_dir().unwrap(); path.push("gitui"); path.push("gitui.log"); - fs::create_dir(path.parent().unwrap()).unwrap_or_default(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); let _ = WriteLogger::init( LevelFilter::Trace, diff --git a/src/strings.rs b/src/strings.rs index a9a925b344..9c256e2ea2 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -30,6 +30,12 @@ pub mod commands { CMD_GROUP_GENERAL, ); /// + pub static NAVIGATE_TREE: CommandText = CommandText::new( + "Nav [\u{2190}\u{2191}\u{2192}\u{2193}]", + "navigate tree view", + CMD_GROUP_GENERAL, + ); + /// pub static SCROLL: CommandText = CommandText::new( "Scroll [\u{2191}\u{2193}]", "scroll up or down in focused view", diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 0d1af3b18e..2ec7f4f340 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -80,6 +80,5 @@ pub fn draw_list<'b, B: Backend, L>( ) .scroll(select.unwrap_or_default()) .style(Style::default().fg(Color::White)); - // .render(f, r); f.render_widget(list, r) } From 2934d9ee74e9a26d42181ab46498c4ecb0f422ac Mon Sep 17 00:00:00 2001 From: Stephan Dilly Date: Tue, 28 Apr 2020 11:11:29 +0200 Subject: [PATCH 2/3] fix crash on todo --- src/components/changes.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/changes.rs b/src/components/changes.rs index 57063a516e..c0299bbc77 100644 --- a/src/components/changes.rs +++ b/src/components/changes.rs @@ -11,6 +11,7 @@ use crate::{ }; use asyncgit::{hash, sync, StatusItem, StatusItemType, CWD}; use crossterm::event::Event; +use log::trace; use std::{borrow::Cow, convert::From, path::Path}; use strings::commands; use tui::{ @@ -119,7 +120,8 @@ impl ChangesComponent { return sync::reset_stage(CWD, path); } } else { - todo!() + //TODO: + trace!("tbd"); } } @@ -135,7 +137,8 @@ impl ChangesComponent { return true; } else { - todo!() + //TODO: + trace!("tbd"); } } false @@ -277,7 +280,8 @@ impl Component for ChangesComponent { out: &mut Vec, _force_all: bool, ) -> CommandBlocking { - let some_selection = self.selection().is_some(); + let some_selection = + self.selection().is_some() && self.is_file_seleted(); if self.is_working_dir { out.push(CommandInfo::new( commands::STAGE_FILE, From 6abe44a17775d15b52e5798c4c94805dde17e9e0 Mon Sep 17 00:00:00 2001 From: Stephan Dilly Date: Tue, 28 Apr 2020 11:13:45 +0200 Subject: [PATCH 3/3] consistent select color --- src/components/changes.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/changes.rs b/src/components/changes.rs index c0299bbc77..65b3ecfff3 100644 --- a/src/components/changes.rs +++ b/src/components/changes.rs @@ -149,6 +149,8 @@ impl ChangesComponent { width: u16, selected: bool, ) -> Option { + let select_color = Color::Rgb(0, 0, 100); + let indent_str = if item.info.indent == 0 { String::from("") } else { @@ -193,7 +195,7 @@ impl ChangesComponent { ); if selected { - style = style.bg(Color::Blue); + style = style.bg(select_color); } Some(Text::Styled(Cow::from(txt), style)) @@ -221,7 +223,7 @@ impl ChangesComponent { let mut style = Style::default(); if selected { - style = style.bg(Color::Blue); + style = style.bg(select_color); } Some(Text::Styled(Cow::from(txt), style))