From 0e31d57a33c0e0d5028d5efc65f7794b66e2f139 Mon Sep 17 00:00:00 2001 From: Stephan Dilly Date: Fri, 21 May 2021 14:52:05 +0200 Subject: [PATCH] New file tree (#718) --- Cargo.lock | 9 + Cargo.toml | 1 + Makefile | 8 +- asyncgit/src/sync/status.rs | 2 +- asyncgit/src/sync/tree.rs | 133 ++++- filetree/Cargo.toml | 18 + filetree/LICENSE.md | 1 + filetree/src/error.rs | 16 + filetree/src/filetree.rs | 471 ++++++++++++++++++ filetree/src/filetreeitems.rs | 816 +++++++++++++++++++++++++++++++ filetree/src/item.rs | 207 ++++++++ filetree/src/lib.rs | 29 ++ filetree/src/tree_iter.rs | 33 ++ filetree/src/treeitems_iter.rs | 60 +++ src/components/filetree.rs | 5 +- src/components/mod.rs | 2 +- src/components/revision_files.rs | 128 ++++- src/keys.rs | 4 + src/ui/style.rs | 14 + vim_style_key_config.ron | 2 + 20 files changed, 1935 insertions(+), 24 deletions(-) create mode 100644 filetree/Cargo.toml create mode 120000 filetree/LICENSE.md create mode 100644 filetree/src/error.rs create mode 100644 filetree/src/filetree.rs create mode 100644 filetree/src/filetreeitems.rs create mode 100644 filetree/src/item.rs create mode 100644 filetree/src/lib.rs create mode 100644 filetree/src/tree_iter.rs create mode 100644 filetree/src/treeitems_iter.rs diff --git a/Cargo.lock b/Cargo.lock index 9a4a9566..8931b78c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -320,6 +320,14 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" +[[package]] +name = "filetree" +version = "0.1.0" +dependencies = [ + "pretty_assertions", + "thiserror", +] + [[package]] name = "form_urlencoded" version = "1.0.1" @@ -400,6 +408,7 @@ dependencies = [ "crossterm", "dirs-next", "easy-cast", + "filetree", "itertools", "log", "pprof", diff --git a/Cargo.toml b/Cargo.toml index 8b4195cd..fa826da6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ keywords = [ [dependencies] scopetime = { path = "./scopetime", version = "0.1" } asyncgit = { path = "./asyncgit", version = "0.15" } +filetree = { path = "./filetree" } crossterm = { version = "0.19", features = [ "serde" ] } clap = { version = "2.33", default-features = false } tui = { version = "0.15", default-features = false, features = ['crossterm', 'serde'] } diff --git a/Makefile b/Makefile index f3923d18..2e4ec958 100644 --- a/Makefile +++ b/Makefile @@ -45,18 +45,14 @@ fmt: clippy: touch src/main.rs - cargo clean -p gitui -p asyncgit -p scopetime + cargo clean -p gitui -p asyncgit -p scopetime -p filetree cargo clippy --workspace --all-features clippy-nightly: touch src/main.rs - cargo clean -p gitui -p asyncgit -p scopetime + cargo clean -p gitui -p asyncgit -p scopetime -p filetree cargo +nightly clippy --all-features -clippy-pedantic: - cargo clean -p gitui -p asyncgit -p scopetime - cargo clippy --all-features -- -W clippy::pedantic - check: fmt clippy test install: diff --git a/asyncgit/src/sync/status.rs b/asyncgit/src/sync/status.rs index 3e58c3c3..328a91a1 100644 --- a/asyncgit/src/sync/status.rs +++ b/asyncgit/src/sync/status.rs @@ -88,7 +88,7 @@ impl From for StatusShow { } } -/// +/// gurantees sorting pub fn get_status( repo_path: &str, status_type: StatusType, diff --git a/asyncgit/src/sync/tree.rs b/asyncgit/src/sync/tree.rs index 53eaec5e..65b03604 100644 --- a/asyncgit/src/sync/tree.rs +++ b/asyncgit/src/sync/tree.rs @@ -2,7 +2,10 @@ use super::{utils::bytes2string, CommitId}; use crate::{error::Result, sync::utils::repo}; use git2::{Oid, Repository, Tree}; use scopetime::scope_time; -use std::path::{Path, PathBuf}; +use std::{ + cmp::Ordering, + path::{Path, PathBuf}, +}; /// `tree_files` returns a list of `FileTree` #[derive(Debug, PartialEq)] @@ -15,7 +18,7 @@ pub struct TreeFile { id: Oid, } -/// +/// guarantees sorting the result pub fn tree_files( repo_path: &str, commit: CommitId, @@ -31,9 +34,40 @@ pub fn tree_files( tree_recurse(&repo, &PathBuf::from("./"), &tree, &mut files)?; + sort_file_list(&mut files); + Ok(files) } +fn sort_file_list(files: &mut Vec) { + files.sort_by(|a, b| path_cmp(&a.path, &b.path)); +} + +// applies topologically order on paths sorting +fn path_cmp(a: &Path, b: &Path) -> Ordering { + let mut comp_a = a.components().into_iter().peekable(); + let mut comp_b = b.components().into_iter().peekable(); + + loop { + let a = comp_a.next(); + let b = comp_b.next(); + + let a_is_file = comp_a.peek().is_none(); + let b_is_file = comp_b.peek().is_none(); + + if a_is_file && !b_is_file { + return Ordering::Greater; + } else if !a_is_file && b_is_file { + return Ordering::Less; + } + + let cmp = a.cmp(&b); + if cmp != Ordering::Equal { + return cmp; + } + } +} + /// pub fn tree_file_content( repo_path: &str, @@ -109,4 +143,99 @@ mod tests { assert_eq!(files_c2.len(), 1); assert_ne!(files_c2[0], files[0]); } + + #[test] + fn test_sorting() { + let mut list = vec!["file", "folder/file", "folder/afile"] + .iter() + .map(|f| TreeFile { + path: PathBuf::from(f), + filemode: 0, + id: Oid::zero(), + }) + .collect(); + + sort_file_list(&mut list); + + assert_eq!( + list.iter() + .map(|f| f.path.to_string_lossy()) + .collect::>(), + vec![ + String::from("folder/afile"), + String::from("folder/file"), + String::from("file") + ] + ); + } + + #[test] + fn test_sorting_folders() { + let mut list = vec!["bfolder/file", "afolder/file"] + .iter() + .map(|f| TreeFile { + path: PathBuf::from(f), + filemode: 0, + id: Oid::zero(), + }) + .collect(); + + sort_file_list(&mut list); + + assert_eq!( + list.iter() + .map(|f| f.path.to_string_lossy()) + .collect::>(), + vec![ + String::from("afolder/file"), + String::from("bfolder/file"), + ] + ); + } + + #[test] + fn test_sorting_folders2() { + let mut list = vec!["bfolder/sub/file", "afolder/file"] + .iter() + .map(|f| TreeFile { + path: PathBuf::from(f), + filemode: 0, + id: Oid::zero(), + }) + .collect(); + + sort_file_list(&mut list); + + assert_eq!( + list.iter() + .map(|f| f.path.to_string_lossy()) + .collect::>(), + vec![ + String::from("afolder/file"), + String::from("bfolder/sub/file"), + ] + ); + } + + #[test] + fn test_path_cmp() { + assert_eq!( + path_cmp( + &PathBuf::from("bfolder/sub/file"), + &PathBuf::from("afolder/file") + ), + Ordering::Greater + ); + } + + #[test] + fn test_path_file_cmp() { + assert_eq!( + path_cmp( + &PathBuf::from("a"), + &PathBuf::from("afolder/file") + ), + Ordering::Greater + ); + } } diff --git a/filetree/Cargo.toml b/filetree/Cargo.toml new file mode 100644 index 00000000..0e341baa --- /dev/null +++ b/filetree/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "filetree" +version = "0.1.0" +authors = ["Stephan Dilly "] +edition = "2018" +description = "filetree abstraction based on a sorted path list" +homepage = "https://github.com/extrawurst/gitui" +repository = "https://github.com/extrawurst/gitui" +readme = "README.md" +license-file = "LICENSE.md" +categories = ["command-line-utilities"] +keywords = ["gui","cli","terminal","ui"] + +[dependencies] +thiserror = "1.0" + +[dev-dependencies] +pretty_assertions = "0.7" \ No newline at end of file diff --git a/filetree/LICENSE.md b/filetree/LICENSE.md new file mode 120000 index 00000000..7eabdb1c --- /dev/null +++ b/filetree/LICENSE.md @@ -0,0 +1 @@ +../LICENSE.md \ No newline at end of file diff --git a/filetree/src/error.rs b/filetree/src/error.rs new file mode 100644 index 00000000..a68fcaf3 --- /dev/null +++ b/filetree/src/error.rs @@ -0,0 +1,16 @@ +use std::{num::TryFromIntError, path::PathBuf}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("InvalidPath: `{0}`")] + InvalidPath(PathBuf), + + #[error("InvalidFilePath: `{0}`")] + InvalidFilePath(String), + + #[error("TryFromInt error:{0}")] + IntConversion(#[from] TryFromIntError), +} + +pub type Result = std::result::Result; diff --git a/filetree/src/filetree.rs b/filetree/src/filetree.rs new file mode 100644 index 00000000..b7d11a34 --- /dev/null +++ b/filetree/src/filetree.rs @@ -0,0 +1,471 @@ +use crate::{ + error::Result, filetreeitems::FileTreeItems, + tree_iter::TreeIterator, +}; +use std::{collections::BTreeSet, usize}; + +/// +#[derive(Copy, Clone, Debug)] +pub enum MoveSelection { + Up, + Down, + Left, + Right, + Top, + End, +} + +#[derive(Debug, Clone, Copy)] +pub struct VisualSelection { + pub count: usize, + pub index: usize, +} + +/// wraps `FileTreeItems` as a datastore and adds selection functionality +#[derive(Default)] +pub struct FileTree { + items: FileTreeItems, + selection: Option, + // caches the absolute selection translated to visual index + visual_selection: Option, +} + +impl FileTree { + /// + pub fn new( + list: &[&str], + collapsed: &BTreeSet<&String>, + ) -> Result { + let mut new_self = Self { + items: FileTreeItems::new(list, collapsed)?, + selection: if list.is_empty() { None } else { Some(0) }, + visual_selection: None, + }; + new_self.visual_selection = new_self.calc_visual_selection(); + + Ok(new_self) + } + + /// + pub fn collapse_but_root(&mut self) { + self.items.collapse(0, true); + self.items.expand(0, false); + } + + /// iterates visible elements starting from `start_index_visual` + pub fn iterate( + &self, + start_index_visual: usize, + max_amount: usize, + ) -> TreeIterator<'_> { + let start = self + .visual_index_to_absolute(start_index_visual) + .unwrap_or_default(); + TreeIterator::new( + self.items.iterate(start, max_amount), + self.selection, + ) + } + + fn visual_index_to_absolute( + &self, + visual_index: usize, + ) -> Option { + self.items + .iterate(0, self.items.len()) + .enumerate() + .find_map(|(i, (abs, _))| { + if i == visual_index { + Some(abs) + } else { + None + } + }) + } + + /// + pub const fn visual_selection(&self) -> Option<&VisualSelection> { + self.visual_selection.as_ref() + } + + fn calc_visual_selection(&self) -> Option { + self.selection.map(|selection_absolute| { + let mut count = 0; + let mut visual_index = 0; + for (index, _item) in + self.items.iterate(0, self.items.len()) + { + if selection_absolute == index { + visual_index = count; + } + + count += 1; + } + + VisualSelection { + index: visual_index, + count, + } + }) + } + + /// + pub fn move_selection(&mut self, dir: MoveSelection) -> bool { + self.selection.map_or(false, |selection| { + let new_index = 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) + } + MoveSelection::Top => { + Self::selection_start(selection) + } + MoveSelection::End => self.selection_end(selection), + }; + + let changed_index = + new_index.map(|i| i != selection).unwrap_or_default(); + + if changed_index { + self.selection = new_index; + self.visual_selection = self.calc_visual_selection(); + } + + changed_index || new_index.is_some() + }) + } + + pub fn collapse_recursive(&mut self) { + if let Some(selection) = self.selection { + self.items.collapse(selection, true); + } + } + + pub fn expand_recursive(&mut self) { + if let Some(selection) = self.selection { + self.items.expand(selection, true); + } + } + + const fn selection_start(current_index: usize) -> Option { + if current_index == 0 { + None + } else { + Some(0) + } + } + + fn selection_end(&self, current_index: usize) -> Option { + let items_max = self.items.len().saturating_sub(1); + + let mut new_index = items_max; + + loop { + if self.is_visible_index(new_index) { + break; + } + + if new_index == 0 { + break; + } + + new_index = new_index.saturating_sub(1); + new_index = std::cmp::min(new_index, items_max); + } + + if new_index == current_index { + None + } else { + Some(new_index) + } + } + + fn selection_updown( + &self, + current_index: usize, + up: bool, + ) -> Option { + let mut index = current_index; + + loop { + index = { + let new_index = if up { + index.saturating_sub(1) + } else { + index.saturating_add(1) + }; + + // when reaching usize bounds + if new_index == index { + break; + } + + if new_index >= self.items.len() { + break; + } + + new_index + }; + + if self.is_visible_index(index) { + break; + } + } + + if index == current_index { + None + } else { + Some(index) + } + } + + fn select_parent( + &mut self, + current_index: usize, + ) -> Option { + let indent = + self.items.tree_items[current_index].info().indent(); + + let mut index = current_index; + + while let Some(selection) = self.selection_updown(index, true) + { + index = selection; + + if self.items.tree_items[index].info().indent() < indent { + break; + } + } + + if index == current_index { + None + } else { + Some(index) + } + } + + fn selection_left( + &mut self, + current_index: usize, + ) -> Option { + let item = &mut self.items.tree_items[current_index]; + + if item.kind().is_path() && !item.kind().is_path_collapsed() { + self.items.collapse(current_index, false); + return Some(current_index); + } + + self.select_parent(current_index) + } + + fn selection_right( + &mut self, + current_selection: usize, + ) -> Option { + let item = &mut self.items.tree_items[current_selection]; + + if item.kind().is_path() { + if item.kind().is_path_collapsed() { + self.items.expand(current_selection, false); + return Some(current_selection); + } + return self.selection_updown(current_selection, false); + } + + None + } + + fn is_visible_index(&self, index: usize) -> bool { + self.items + .tree_items + .get(index) + .map(|item| item.info().is_visible()) + .unwrap_or_default() + } +} + +#[cfg(test)] +mod test { + use crate::{FileTree, MoveSelection}; + use pretty_assertions::assert_eq; + use std::collections::BTreeSet; + + #[test] + fn test_selection() { + let items = vec![ + "a/b", // + ]; + + let mut tree = + FileTree::new(&items, &BTreeSet::new()).unwrap(); + + assert!(tree.move_selection(MoveSelection::Down)); + + assert_eq!(tree.selection, Some(1)); + + assert!(!tree.move_selection(MoveSelection::Down)); + + assert_eq!(tree.selection, Some(1)); + } + + #[test] + fn test_selection_skips_collapsed() { + let items = vec![ + "a/b/c", // + "a/d", // + ]; + + //0 a/ + //1 b/ + //2 c + //3 d + + let mut tree = + FileTree::new(&items, &BTreeSet::new()).unwrap(); + + tree.items.collapse(1, false); + tree.selection = Some(1); + + assert!(tree.move_selection(MoveSelection::Down)); + + assert_eq!(tree.selection, Some(3)); + } + + #[test] + fn test_selection_left_collapse() { + let items = vec![ + "a/b/c", // + "a/d", // + ]; + + //0 a/ + //1 b/ + //2 c + //3 d + + let mut tree = + FileTree::new(&items, &BTreeSet::new()).unwrap(); + + tree.selection = Some(1); + + //collapses 1 + assert!(tree.move_selection(MoveSelection::Left)); + // index will not change + assert_eq!(tree.selection, Some(1)); + + assert!(tree.items.tree_items[1].kind().is_path_collapsed()); + assert!(!tree.items.tree_items[2].info().is_visible()); + } + + #[test] + fn test_selection_left_parent() { + let items = vec![ + "a/b/c", // + "a/d", // + ]; + + //0 a/ + //1 b/ + //2 c + //3 d + + let mut tree = + FileTree::new(&items, &BTreeSet::new()).unwrap(); + + tree.selection = Some(2); + + assert!(tree.move_selection(MoveSelection::Left)); + assert_eq!(tree.selection, Some(1)); + + assert!(tree.move_selection(MoveSelection::Left)); + assert_eq!(tree.selection, Some(1)); + + assert!(tree.move_selection(MoveSelection::Left)); + assert_eq!(tree.selection, Some(0)); + } + + #[test] + fn test_selection_right_expand() { + let items = vec![ + "a/b/c", // + "a/d", // + ]; + + //0 a/ + //1 b/ + //2 c + //3 d + + let mut tree = + FileTree::new(&items, &BTreeSet::new()).unwrap(); + + tree.items.collapse(1, false); + tree.items.collapse(0, false); + tree.selection = Some(0); + + assert!(tree.move_selection(MoveSelection::Right)); + assert_eq!(tree.selection, Some(0)); + assert!(!tree.items.tree_items[0].kind().is_path_collapsed()); + + assert!(tree.move_selection(MoveSelection::Right)); + assert_eq!(tree.selection, Some(1)); + assert!(tree.items.tree_items[1].kind().is_path_collapsed()); + + assert!(tree.move_selection(MoveSelection::Right)); + assert_eq!(tree.selection, Some(1)); + assert!(!tree.items.tree_items[1].kind().is_path_collapsed()); + } + + #[test] + fn test_selection_top() { + let items = vec![ + "a/b/c", // + "a/d", // + ]; + + //0 a/ + //1 b/ + //2 c + //3 d + + let mut tree = + FileTree::new(&items, &BTreeSet::new()).unwrap(); + + tree.selection = Some(3); + + assert!(tree.move_selection(MoveSelection::Top)); + assert_eq!(tree.selection, Some(0)); + } + + #[test] + fn test_visible_selection() { + let items = vec![ + "a/b/c", // + "a/b/c2", // + "a/d", // + ]; + + //0 a/ + //1 b/ + //2 c + //3 c2 + //4 d + + let mut tree = + FileTree::new(&items, &BTreeSet::new()).unwrap(); + + tree.selection = Some(1); + assert!(tree.move_selection(MoveSelection::Left)); + assert!(tree.move_selection(MoveSelection::Down)); + let s = tree.visual_selection().unwrap(); + + assert_eq!(s.count, 3); + assert_eq!(s.index, 2); + } +} diff --git a/filetree/src/filetreeitems.rs b/filetree/src/filetreeitems.rs new file mode 100644 index 00000000..d966afef --- /dev/null +++ b/filetree/src/filetreeitems.rs @@ -0,0 +1,816 @@ +use crate::{ + error::Error, + item::{FileTreeItemKind, PathCollapsed}, + FileTreeItem, +}; +use crate::{error::Result, treeitems_iter::TreeItemsIterator}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, + usize, +}; + +/// +#[derive(Default)] +pub struct FileTreeItems { + pub tree_items: Vec, + files: usize, +} + +impl FileTreeItems { + /// + pub fn new( + list: &[&str], + collapsed: &BTreeSet<&String>, + ) -> Result { + let (mut items, paths) = Self::create_items(list, collapsed)?; + + Self::fold_paths(&mut items, &paths); + + Ok(Self { + tree_items: items, + files: list.len(), + }) + } + + fn create_items<'a>( + list: &'a [&str], + collapsed: &BTreeSet<&String>, + ) -> Result<(Vec, BTreeMap<&'a Path, usize>)> { + let mut items = Vec::with_capacity(list.len()); + let mut paths_added: BTreeMap<&Path, usize> = BTreeMap::new(); + + for e in list { + { + let item_path = Path::new(e); + Self::push_dirs( + item_path, + &mut items, + &mut paths_added, + collapsed, + )?; + } + + items.push(FileTreeItem::new_file(e)?); + } + + Ok((items, paths_added)) + } + + /// how many individual items (files/paths) are in the list + pub fn len(&self) -> usize { + self.tree_items.len() + } + + /// how many files were added to this list + pub const fn file_count(&self) -> usize { + self.files + } + + /// iterates visible elements + pub const fn iterate( + &self, + start: usize, + max_amount: usize, + ) -> TreeItemsIterator<'_> { + TreeItemsIterator::new(self, start, max_amount) + } + + fn push_dirs<'a>( + item_path: &'a Path, + nodes: &mut Vec, + // helps to only add new nodes for paths that were not added before + // we also count the number of children a node has for later folding + paths_added: &mut BTreeMap<&'a Path, usize>, + collapsed: &BTreeSet<&String>, + ) -> Result<()> { + let mut ancestors = + item_path.ancestors().skip(1).collect::>(); + ancestors.reverse(); + + for c in &ancestors { + if c.parent().is_some() && !paths_added.contains_key(c) { + // add node and set count to have no children + paths_added.entry(c).or_insert(0); + + // increase the number of children in the parent node count + if let Some(parent) = c.parent() { + if !parent.as_os_str().is_empty() { + *paths_added.entry(parent).or_insert(0) += 1; + } + } + + let path_string = Self::path_to_string(c)?; + let is_collapsed = collapsed.contains(&path_string); + nodes.push(FileTreeItem::new_path( + c, + path_string, + is_collapsed, + )?); + } + } + + // increase child count in parent node (the above ancenstor ignores the leaf component) + if let Some(parent) = item_path.parent() { + *paths_added.entry(parent).or_insert(0) += 1; + } + + Ok(()) + } + + fn path_to_string(p: &Path) -> Result { + Ok(p.to_str() + .map_or_else( + || Err(Error::InvalidPath(p.to_path_buf())), + Ok, + )? + .to_string()) + } + + pub fn collapse(&mut self, index: usize, recursive: bool) { + if self.tree_items[index].kind().is_path() { + self.tree_items[index].collapse_path(); + + let path = format!( + "{}/", + self.tree_items[index].info().full_path() + ); + + for i in index + 1..self.tree_items.len() { + let item = &mut self.tree_items[i]; + + if recursive && item.kind().is_path() { + item.collapse_path(); + } + + let item_path = &item.info().full_path(); + + if item_path.starts_with(&path) { + item.hide(); + } else { + return; + } + } + } + } + + pub fn expand(&mut self, index: usize, recursive: bool) { + if self.tree_items[index].kind().is_path() { + self.tree_items[index].expand_path(); + + let full_path = format!( + "{}/", + self.tree_items[index].info().full_path() + ); + + if recursive { + for i in index + 1..self.tree_items.len() { + let item = &mut self.tree_items[i]; + + if !item + .info() + .full_path() + .starts_with(&full_path) + { + break; + } + + if item.kind().is_path() + && item.kind().is_path_collapsed() + { + item.expand_path(); + } + } + } + + self.update_visibility( + Some(full_path.as_str()), + 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_items.len() { + if let Some(ref collapsed_path) = inner_collapsed { + let p = self.tree_items[i].info().full_path(); + if p.starts_with(collapsed_path) { + if set_defaults { + self.tree_items[i] + .info_mut() + .set_visible(false); + } + // we are still in a collapsed inner path + continue; + } + inner_collapsed = None; + } + + let item_kind = self.tree_items[i].kind().clone(); + let item_path = self.tree_items[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 + .map_or(true, |prefix| item_path.starts_with(prefix)) + { + self.tree_items[i].info_mut().set_visible(true); + } else { + // if we do not set defaults we can early out + if set_defaults { + self.tree_items[i].info_mut().set_visible(false); + } else { + return; + } + } + } + } + + fn fold_paths( + items: &mut Vec, + paths: &BTreeMap<&Path, usize>, + ) { + let mut i = 0; + + while i < items.len() { + let item = &items[i]; + if item.kind().is_path() { + let children = + paths.get(&Path::new(item.info().full_path())); + + if let Some(children) = children { + if *children == 1 { + if i + 1 >= items.len() { + return; + } + + if items + .get(i + 1) + .map(|item| item.kind().is_path()) + .unwrap_or_default() + { + let next_item = items.remove(i + 1); + let item_mut = &mut items[i]; + item_mut.fold(next_item); + + let prefix = item_mut + .info() + .full_path() + .to_owned(); + + Self::unindent(items, &prefix, i + 1); + continue; + } + } + } + } + + i += 1; + } + } + + fn unindent( + items: &mut Vec, + prefix: &str, + start: usize, + ) { + for elem in items.iter_mut().skip(start) { + if elem.info().full_path().starts_with(prefix) { + elem.info_mut().unindent(); + } else { + return; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_simple() { + let items = vec![ + "file.txt", // + ]; + + let res = + FileTreeItems::new(&items, &BTreeSet::new()).unwrap(); + + assert!(res.tree_items[0].info().is_visible()); + assert_eq!(res.tree_items[0].info().indent(), 0); + assert_eq!(res.tree_items[0].info().path(), items[0]); + assert_eq!(res.tree_items[0].info().full_path(), items[0]); + + let items = vec![ + "file.txt", // + "file2.txt", // + ]; + + let res = + FileTreeItems::new(&items, &BTreeSet::new()).unwrap(); + + assert_eq!(res.tree_items.len(), 2); + assert_eq!(res.tree_items.len(), res.len()); + assert_eq!( + res.tree_items[1].info().path(), + items[1].to_string() + ); + } + + #[test] + fn test_push_path() { + let mut items = Vec::new(); + let mut paths: BTreeMap<&Path, usize> = BTreeMap::new(); + + FileTreeItems::push_dirs( + Path::new("a/b/c"), + &mut items, + &mut paths, + &BTreeSet::new(), + ) + .unwrap(); + + assert_eq!(*paths.get(&Path::new("a")).unwrap(), 1); + + FileTreeItems::push_dirs( + Path::new("a/b2/c"), + &mut items, + &mut paths, + &BTreeSet::new(), + ) + .unwrap(); + + assert_eq!(*paths.get(&Path::new("a")).unwrap(), 2); + } + + #[test] + fn test_push_path2() { + let mut items = Vec::new(); + let mut paths: BTreeMap<&Path, usize> = BTreeMap::new(); + + FileTreeItems::push_dirs( + Path::new("a/b/c"), + &mut items, + &mut paths, + &BTreeSet::new(), + ) + .unwrap(); + + assert_eq!(*paths.get(&Path::new("a")).unwrap(), 1); + assert_eq!(*paths.get(&Path::new("a/b")).unwrap(), 1); + + FileTreeItems::push_dirs( + Path::new("a/b/d"), + &mut items, + &mut paths, + &BTreeSet::new(), + ) + .unwrap(); + + assert_eq!(*paths.get(&Path::new("a")).unwrap(), 1); + assert_eq!(*paths.get(&Path::new("a/b")).unwrap(), 2); + } + + #[test] + fn test_folder() { + let items = vec![ + "a/file.txt", // + ]; + + let res = FileTreeItems::new(&items, &BTreeSet::new()) + .unwrap() + .tree_items + .iter() + .map(|i| i.info().full_path().to_string()) + .collect::>(); + + assert_eq!( + res, + vec![String::from("a"), String::from("a/file.txt"),] + ); + } + + #[test] + fn test_indent() { + let items = vec![ + "a/b/file.txt", // + ]; + + let list = + FileTreeItems::new(&items, &BTreeSet::new()).unwrap(); + let mut res = list + .tree_items + .iter() + .map(|i| (i.info().indent(), i.info().path())); + + assert_eq!(res.next(), Some((0, "a/b"))); + assert_eq!(res.next(), Some((1, "file.txt"))); + } + + #[test] + fn test_indent_folder_file_name() { + let items = vec![ + "a/b", // + "a.txt", // + ]; + + let list = + FileTreeItems::new(&items, &BTreeSet::new()).unwrap(); + let mut res = list + .tree_items + .iter() + .map(|i| (i.info().indent(), i.info().path())); + + assert_eq!(res.next(), Some((0, "a"))); + assert_eq!(res.next(), Some((1, "b"))); + assert_eq!(res.next(), Some((0, "a.txt"))); + } + + #[test] + fn test_folder_dup() { + let items = vec![ + "a/file.txt", // + "a/file2.txt", // + ]; + + let tree = + FileTreeItems::new(&items, &BTreeSet::new()).unwrap(); + + assert_eq!(tree.file_count(), 2); + assert_eq!(tree.len(), 3); + + let res = tree + .tree_items + .iter() + .map(|i| i.info().full_path().to_string()) + .collect::>(); + + assert_eq!( + res, + vec![ + String::from("a"), + String::from("a/file.txt"), + String::from("a/file2.txt"), + ] + ); + } + + #[test] + fn test_collapse() { + let items = vec![ + "a/file1.txt", // + "b/file2.txt", // + ]; + + let mut tree = + FileTreeItems::new(&items, &BTreeSet::new()).unwrap(); + + assert!(tree.tree_items[1].info().is_visible()); + + tree.collapse(0, false); + + assert!(!tree.tree_items[1].info().is_visible()); + } + + #[test] + fn test_iterate_collapsed() { + let items = vec![ + "a/file1.txt", // + "b/file2.txt", // + ]; + + let mut tree = + FileTreeItems::new(&items, &BTreeSet::new()).unwrap(); + + tree.collapse(0, false); + + let mut it = tree.iterate(0, 10); + + assert_eq!(it.next().unwrap().0, 0); + assert_eq!(it.next().unwrap().0, 2); + assert_eq!(it.next().unwrap().0, 3); + assert_eq!(it.next(), None); + } + + pub fn get_visibles(tree: &FileTreeItems) -> Vec { + tree.tree_items + .iter() + .map(|e| e.info().is_visible()) + .collect::>() + } + + #[test] + fn test_expand() { + let items = vec![ + "a/b/c", // + "a/d", // + ]; + + //0 a/ + //1 b/ + //2 c + //3 d + + let mut tree = + FileTreeItems::new(&items, &BTreeSet::new()).unwrap(); + + tree.collapse(1, false); + + let visibles = get_visibles(&tree); + + assert_eq!( + visibles, + vec![ + true, // + true, // + false, // + true, + ] + ); + + tree.expand(1, false); + + let visibles = get_visibles(&tree); + + assert_eq!( + visibles, + vec![ + true, // + true, // + true, // + true, + ] + ); + } + + #[test] + fn test_expand_bug() { + let items = vec![ + "a/b/c", // + "a/b2/d", // + ]; + + //0 a/ + //1 b/ + //2 c + //3 b2/ + //4 d + + let mut tree = + FileTreeItems::new(&items, &BTreeSet::new()).unwrap(); + + tree.collapse(1, false); + tree.collapse(0, false); + + assert_eq!( + get_visibles(&tree), + vec![ + true, // + false, // + false, // + false, // + false, + ] + ); + + tree.expand(0, false); + + assert_eq!( + get_visibles(&tree), + vec![ + true, // + true, // + false, // + true, // + true, + ] + ); + } + + #[test] + fn test_collapse_too_much() { + let items = vec![ + "a/b", // + "a2/c", // + ]; + + //0 a/ + //1 b + //2 a2/ + //3 c + + let mut tree = + FileTreeItems::new(&items, &BTreeSet::new()).unwrap(); + + tree.collapse(0, false); + + let visibles = get_visibles(&tree); + + assert_eq!( + visibles, + vec![ + true, // + false, // + true, // + true, + ] + ); + } + + #[test] + fn test_expand_with_collapsed_sub_parts() { + let items = vec![ + "a/b/c", // + "a/d", // + ]; + + //0 a/ + //1 b/ + //2 c + //3 d + + let mut tree = + FileTreeItems::new(&items, &BTreeSet::new()).unwrap(); + + tree.collapse(1, false); + + let visibles = get_visibles(&tree); + + assert_eq!( + visibles, + vec![ + true, // + true, // + false, // + true, + ] + ); + + tree.collapse(0, false); + + let visibles = get_visibles(&tree); + + assert_eq!( + visibles, + vec![ + true, // + false, // + false, // + false, + ] + ); + + tree.expand(0, false); + + let visibles = get_visibles(&tree); + + assert_eq!( + visibles, + vec![ + true, // + true, // + false, // + true, + ] + ); + } +} + +#[cfg(test)] +mod test_merging { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_merge_simple() { + let list = vec!["a/b/c"]; + let (mut items, paths) = + FileTreeItems::create_items(&list, &BTreeSet::new()) + .unwrap(); + + assert_eq!(items.len(), 3); + + FileTreeItems::fold_paths(&mut items, &paths); + + assert_eq!(items.len(), 2); + } + + #[test] + fn test_merge_simple2() { + let list = vec![ + "a/b/c", // + "a/b/d", + ]; + let (mut items, paths) = + FileTreeItems::create_items(&list, &BTreeSet::new()) + .unwrap(); + + assert_eq!(paths.len(), 2); + assert_eq!(*paths.get(&Path::new("a")).unwrap(), 1); + assert_eq!(*paths.get(&Path::new("a/b")).unwrap(), 2); + assert_eq!(items.len(), 4); + + FileTreeItems::fold_paths(&mut items, &paths); + + assert_eq!(items.len(), 3); + } + + #[test] + fn test_merge_indent() { + let list = vec![ + "a/b/c/d", // + "a/e/f", + ]; + + //0:0 a/ + //1:1 b/c + //2:2 d + //3:1 e/ + //4:2 f + + let (mut items, paths) = + FileTreeItems::create_items(&list, &BTreeSet::new()) + .unwrap(); + + assert_eq!(items.len(), 6); + + assert_eq!(paths.len(), 4); + assert_eq!(*paths.get(&Path::new("a")).unwrap(), 2); + assert_eq!(*paths.get(&Path::new("a/b")).unwrap(), 1); + assert_eq!(*paths.get(&Path::new("a/b/c")).unwrap(), 1); + assert_eq!(*paths.get(&Path::new("a/e")).unwrap(), 1); + + FileTreeItems::fold_paths(&mut items, dbg!(&paths)); + + let indents: Vec = + items.iter().map(|i| i.info().indent()).collect(); + assert_eq!(indents, vec![0, 1, 2, 1, 2]); + } + + #[test] + fn test_merge_single_paths() { + let items = vec![ + "a/b/c", // + "a/b/d", // + ]; + + //0 a/b/ + //1 c + //2 d + + let tree = + FileTreeItems::new(&items, &BTreeSet::new()).unwrap(); + + let mut it = tree + .iterate(0, 10) + .map(|(_, item)| item.info().full_path()); + + assert_eq!(it.next().unwrap(), "a/b"); + assert_eq!(it.next().unwrap(), "a/b/c"); + assert_eq!(it.next().unwrap(), "a/b/d"); + assert_eq!(it.next(), None); + } + + #[test] + fn test_merge_nothing() { + let items = vec![ + "a/b/c", // + "a/b2/d", // + ]; + + //0 a/ + //1 b/ + //2 c + //3 b2/ + //4 d + + let tree = + FileTreeItems::new(&items, &BTreeSet::new()).unwrap(); + + let mut it = tree + .iterate(0, 10) + .map(|(_, item)| item.info().full_path()); + + assert_eq!(it.next().unwrap(), "a"); + assert_eq!(it.next().unwrap(), "a/b"); + assert_eq!(it.next().unwrap(), "a/b/c"); + assert_eq!(it.next().unwrap(), "a/b2"); + assert_eq!(it.next().unwrap(), "a/b2/d"); + assert_eq!(it.next(), None); + } +} diff --git a/filetree/src/item.rs b/filetree/src/item.rs new file mode 100644 index 00000000..1f14203a --- /dev/null +++ b/filetree/src/item.rs @@ -0,0 +1,207 @@ +use crate::error::{Error, Result}; +use std::{convert::TryFrom, path::Path}; + +/// holds the information shared among all `FileTreeItem` in a `FileTree` +#[derive(Debug, Clone)] +pub struct TreeItemInfo { + /// indent level + indent: u8, + /// currently visible depending on the folder collapse states + visible: bool, + /// just the last path element + path: String, + /// the full path + full_path: String, +} + +impl TreeItemInfo { + /// + pub const fn new( + indent: u8, + path: String, + full_path: String, + ) -> Self { + Self { + indent, + visible: true, + path, + full_path, + } + } + + /// + pub const fn is_visible(&self) -> bool { + self.visible + } + + /// + pub fn full_path(&self) -> &str { + &self.full_path + } + + /// + pub fn path(&self) -> &str { + &self.path + } + + /// + pub const fn indent(&self) -> u8 { + self.indent + } + + /// + pub fn unindent(&mut self) { + self.indent = self.indent.saturating_sub(1); + } + + pub fn set_visible(&mut self, visible: bool) { + self.visible = visible; + } +} + +/// 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, +} + +impl FileTreeItemKind { + pub const fn is_path(&self) -> bool { + matches!(self, Self::Path(_)) + } + + pub const fn is_path_collapsed(&self) -> bool { + match self { + Self::Path(collapsed) => collapsed.0, + Self::File => false, + } + } +} + +/// `FileTreeItem` can be of two kinds: see `FileTreeItem` but shares an info +#[derive(Debug, Clone)] +pub struct FileTreeItem { + info: TreeItemInfo, + kind: FileTreeItemKind, +} + +impl FileTreeItem { + pub fn new_file(path: &str) -> Result { + let item_path = Path::new(&path); + + let indent = u8::try_from( + item_path.ancestors().count().saturating_sub(2), + )?; + + let filename = item_path + .file_name() + .map_or_else( + || Err(Error::InvalidFilePath(path.to_string())), + Ok, + )? + .to_string_lossy() + .to_string(); + + Ok(Self { + info: TreeItemInfo::new( + indent, + filename, + item_path.to_string_lossy().to_string(), + ), + kind: FileTreeItemKind::File, + }) + } + + pub fn new_path( + path: &Path, + path_string: String, + collapsed: bool, + ) -> Result { + let indent = + u8::try_from(path.ancestors().count().saturating_sub(2))?; + + let last_path_component = + path.components().last().map_or_else( + || Err(Error::InvalidPath(path.to_path_buf())), + Ok, + )?; + let last_path_component = last_path_component + .as_os_str() + .to_string_lossy() + .to_string(); + + Ok(Self { + info: TreeItemInfo::new( + indent, + last_path_component, + path_string, + ), + kind: FileTreeItemKind::Path(PathCollapsed(collapsed)), + }) + } + + /// + pub fn fold(&mut self, next: Self) { + self.info.path = + format!("{}/{}", self.info.path, next.info.path); + self.info.full_path = next.info.full_path; + } + + /// + pub const fn info(&self) -> &TreeItemInfo { + &self.info + } + + /// + pub fn info_mut(&mut self) -> &mut TreeItemInfo { + &mut self.info + } + + /// + pub const fn kind(&self) -> &FileTreeItemKind { + &self.kind + } + + /// + pub fn collapse_path(&mut self) { + self.kind = FileTreeItemKind::Path(PathCollapsed(true)); + } + + /// + pub fn expand_path(&mut self) { + self.kind = FileTreeItemKind::Path(PathCollapsed(false)); + } + + /// + pub fn hide(&mut self) { + self.info.visible = false; + } +} + +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) + } +} diff --git a/filetree/src/lib.rs b/filetree/src/lib.rs new file mode 100644 index 00000000..6a04acc4 --- /dev/null +++ b/filetree/src/lib.rs @@ -0,0 +1,29 @@ +// #![forbid(missing_docs)] +#![forbid(unsafe_code)] +#![deny(unused_imports)] +#![deny(unused_must_use)] +#![deny(dead_code)] +#![deny(clippy::all, clippy::perf, clippy::nursery, clippy::pedantic)] +#![deny(clippy::expect_used)] +#![deny(clippy::filetype_is_file)] +#![deny(clippy::cargo)] +#![deny(clippy::unwrap_used)] +#![deny(clippy::panic)] +#![deny(clippy::match_like_matches_macro)] +#![deny(clippy::needless_update)] +#![allow(clippy::module_name_repetitions)] +#![allow(clippy::must_use_candidate)] +#![allow(clippy::missing_errors_doc)] + +mod error; +mod filetree; +mod filetreeitems; +mod item; +mod tree_iter; +mod treeitems_iter; + +pub use crate::{ + filetree::FileTree, + filetree::MoveSelection, + item::{FileTreeItem, TreeItemInfo}, +}; diff --git a/filetree/src/tree_iter.rs b/filetree/src/tree_iter.rs new file mode 100644 index 00000000..b5e331a1 --- /dev/null +++ b/filetree/src/tree_iter.rs @@ -0,0 +1,33 @@ +use crate::{item::FileTreeItem, treeitems_iter::TreeItemsIterator}; + +pub struct TreeIterator<'a> { + item_iter: TreeItemsIterator<'a>, + selection: Option, +} + +impl<'a> TreeIterator<'a> { + pub const fn new( + item_iter: TreeItemsIterator<'a>, + selection: Option, + ) -> Self { + Self { + item_iter, + selection, + } + } +} + +impl<'a> Iterator for TreeIterator<'a> { + type Item = (&'a FileTreeItem, bool); + + fn next(&mut self) -> Option { + self.item_iter.next().map(|(index, item)| { + ( + item, + self.selection + .map(|i| i == index) + .unwrap_or_default(), + ) + }) + } +} diff --git a/filetree/src/treeitems_iter.rs b/filetree/src/treeitems_iter.rs new file mode 100644 index 00000000..7ca187a1 --- /dev/null +++ b/filetree/src/treeitems_iter.rs @@ -0,0 +1,60 @@ +use crate::{filetreeitems::FileTreeItems, item::FileTreeItem}; + +pub struct TreeItemsIterator<'a> { + tree: &'a FileTreeItems, + index: usize, + increments: Option, + max_amount: usize, +} + +impl<'a> TreeItemsIterator<'a> { + pub const fn new( + tree: &'a FileTreeItems, + start: usize, + max_amount: usize, + ) -> Self { + TreeItemsIterator { + max_amount, + increments: None, + index: start, + tree, + } + } +} + +impl<'a> Iterator for TreeItemsIterator<'a> { + type Item = (usize, &'a FileTreeItem); + + fn next(&mut self) -> Option { + if self.increments.unwrap_or_default() < self.max_amount { + let items = &self.tree.tree_items; + + let mut init = self.increments.is_none(); + + if let Some(i) = self.increments.as_mut() { + *i += 1; + } else { + self.increments = Some(0); + }; + + loop { + if !init { + self.index += 1; + } + init = false; + + if self.index >= self.tree.len() { + break; + } + + let elem = &items[self.index]; + + if elem.info().is_visible() { + return Some((self.index, &items[self.index])); + } + } + } + + None + } +} diff --git a/src/components/filetree.rs b/src/components/filetree.rs index 7dd9f589..c42dadd8 100644 --- a/src/components/filetree.rs +++ b/src/components/filetree.rs @@ -417,8 +417,9 @@ impl Component for FileTreeComponent { _ => Ok(EventState::NotConsumed), } } else if e == self.key_config.move_down { - Ok(self.move_selection(MoveSelection::Down)) - .map(Into::into) + Ok(self + .move_selection(MoveSelection::Down) + .into()) } else if e == self.key_config.move_up { Ok(self.move_selection(MoveSelection::Up).into()) } else if e == self.key_config.home diff --git a/src/components/mod.rs b/src/components/mod.rs index fb8c88eb..aebe1c65 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -24,6 +24,7 @@ mod tag_commit; mod textinput; mod utils; +pub use self::filetree::FileTreeComponent; pub use blame_file::BlameFileComponent; pub use branchlist::BranchListComponent; pub use changes::ChangesComponent; @@ -34,7 +35,6 @@ pub use commitlist::CommitList; pub use create_branch::CreateBranchComponent; pub use diff::DiffComponent; pub use externaleditor::ExternalEditorComponent; -pub use filetree::FileTreeComponent; pub use help::HelpComponent; pub use inspect_commit::InspectCommitComponent; pub use msg::MsgComponent; diff --git a/src/components/revision_files.rs b/src/components/revision_files.rs index 78a7d111..182864a0 100644 --- a/src/components/revision_files.rs +++ b/src/components/revision_files.rs @@ -1,3 +1,5 @@ +use std::{cell::Cell, collections::BTreeSet, convert::From}; + use super::{ visibility_blocking, CommandBlocking, CommandInfo, Component, DrawableComponent, EventState, @@ -15,15 +17,21 @@ use asyncgit::{ }; use crossbeam_channel::Sender; use crossterm::event::Event; +use filetree::{FileTree, MoveSelection}; use tui::{ backend::Backend, layout::Rect, text::Span, widgets::Clear, Frame, }; +const FOLDER_ICON_COLLAPSED: &str = "\u{25b8}"; //▸ +const FOLDER_ICON_EXPANDED: &str = "\u{25be}"; //▾ +const EMPTY_STR: &str = ""; + pub struct RevisionFilesComponent { title: String, theme: SharedTheme, - // queue: Queue, files: Vec, + tree: FileTree, + scroll_top: Cell, revision: Option, visible: bool, key_config: SharedKeyConfig, @@ -40,10 +48,11 @@ impl RevisionFilesComponent { ) -> Self { Self { title: String::new(), + tree: FileTree::default(), theme, + scroll_top: Cell::new(0), files: Vec::new(), revision: None, - // queue: queue.clone(), visible: false, key_config, current_height: std::cell::Cell::new(0), @@ -53,9 +62,16 @@ impl RevisionFilesComponent { /// pub fn open(&mut self, commit: CommitId) -> Result<()> { self.files = sync::tree_files(CWD, commit)?; + let filenames: Vec<&str> = self + .files + .iter() + .map(|f| f.path.to_str().unwrap_or_default()) + .collect(); + self.tree = FileTree::new(&filenames, &BTreeSet::new())?; + self.tree.collapse_but_root(); self.revision = Some(commit); self.title = format!( - "File Tree at {}", + "File Tree at [{}]", self.revision .map(|c| c.get_short_string()) .unwrap_or_default() @@ -64,6 +80,39 @@ impl RevisionFilesComponent { Ok(()) } + + fn tree_item_to_span<'a>( + item: &'a filetree::FileTreeItem, + theme: &SharedTheme, + selected: bool, + ) -> Span<'a> { + let path = item.info().path(); + let indent = item.info().indent(); + + let indent_str = if indent == 0 { + String::from("") + } else { + format!("{:w$}", " ", w = (indent as usize) * 2) + }; + + let is_path = item.kind().is_path(); + let path_arrow = if is_path { + if item.kind().is_path_collapsed() { + FOLDER_ICON_COLLAPSED + } else { + FOLDER_ICON_EXPANDED + } + } else { + EMPTY_STR + }; + + let path = format!("{}{}{}", indent_str, path_arrow, path); + Span::styled(path, theme.file_tree_item(is_path, selected)) + } + + fn move_selection(&mut self, dir: MoveSelection) -> bool { + self.tree.move_selection(dir) + } } impl DrawableComponent for RevisionFilesComponent { @@ -73,18 +122,45 @@ impl DrawableComponent for RevisionFilesComponent { area: Rect, ) -> Result<()> { if self.is_visible() { - let items = self.files.iter().map(|f| { - Span::styled( - f.path.to_string_lossy(), - self.theme.text(true, false), - ) - }); + let tree_height = + usize::from(area.height.saturating_sub(2)); + + let selection = self.tree.visual_selection(); + + selection.map_or_else( + || self.scroll_top.set(0), + |selection| { + self.scroll_top.set(ui::calc_scroll_top( + self.scroll_top.get(), + tree_height, + selection.index, + )) + }, + ); + + let items = self + .tree + .iterate(self.scroll_top.get(), tree_height) + .map(|(item, selected)| { + Self::tree_item_to_span( + item, + &self.theme, + selected, + ) + }); f.render_widget(Clear, area); ui::draw_list( f, area, &self.title, + // &format!( + // "{}/{} (height: {}) (top: {})", + // selection.index, + // selection.count, + // tree_height, + // self.scroll_top.get() + // ), items, true, &self.theme, @@ -123,11 +199,39 @@ impl Component for RevisionFilesComponent { ) -> Result { if self.is_visible() { if let Event::Key(key) = event { - if key == self.key_config.exit_popup { + let consumed = if key == self.key_config.exit_popup { self.hide(); - } + true + } else if key == self.key_config.move_down { + self.move_selection(MoveSelection::Down) + } else if key == self.key_config.move_up { + self.move_selection(MoveSelection::Up) + } else if key == self.key_config.move_right { + self.move_selection(MoveSelection::Right) + } else if key == self.key_config.move_left { + self.move_selection(MoveSelection::Left) + } else if key == self.key_config.home + || key == self.key_config.shift_up + { + self.move_selection(MoveSelection::Top) + } else if key == self.key_config.end + || key == self.key_config.shift_down + { + self.move_selection(MoveSelection::End) + } else if key + == self.key_config.tree_collapse_recursive + { + self.tree.collapse_recursive(); + true + } else if key == self.key_config.tree_expand_recursive + { + self.tree.expand_recursive(); + true + } else { + false + }; - return Ok(EventState::Consumed); + return Ok(consumed.into()); } } diff --git a/src/keys.rs b/src/keys.rs index 7c4e46d4..aa223809 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -39,6 +39,8 @@ pub struct KeyConfig { pub open_help: KeyEvent, pub move_left: KeyEvent, pub move_right: KeyEvent, + pub tree_collapse_recursive: KeyEvent, + pub tree_expand_recursive: KeyEvent, pub home: KeyEvent, pub end: KeyEvent, pub move_up: KeyEvent, @@ -99,6 +101,8 @@ impl Default for KeyConfig { open_help: KeyEvent { code: KeyCode::Char('h'), modifiers: KeyModifiers::empty()}, move_left: KeyEvent { code: KeyCode::Left, modifiers: KeyModifiers::empty()}, move_right: KeyEvent { code: KeyCode::Right, modifiers: KeyModifiers::empty()}, + tree_collapse_recursive: KeyEvent { code: KeyCode::Left, modifiers: KeyModifiers::SHIFT}, + tree_expand_recursive: KeyEvent { code: KeyCode::Right, modifiers: KeyModifiers::SHIFT}, home: KeyEvent { code: KeyCode::Home, modifiers: KeyModifiers::empty()}, end: KeyEvent { code: KeyCode::End, modifiers: KeyModifiers::empty()}, move_up: KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::empty()}, diff --git a/src/ui/style.rs b/src/ui/style.rs index 2a19e1f2..f62f3b4c 100644 --- a/src/ui/style.rs +++ b/src/ui/style.rs @@ -144,6 +144,20 @@ impl Theme { self.apply_select(style, selected) } + pub fn file_tree_item( + &self, + is_folder: bool, + selected: bool, + ) -> Style { + let style = if is_folder { + Style::default() + } else { + Style::default().fg(self.diff_file_modified) + }; + + self.apply_select(style, selected) + } + fn apply_select(&self, style: Style, selected: bool) -> Style { if selected { style.bg(self.selection_bg) diff --git a/vim_style_key_config.ron b/vim_style_key_config.ron index 29b85991..94f5556a 100644 --- a/vim_style_key_config.ron +++ b/vim_style_key_config.ron @@ -40,6 +40,8 @@ move_down: ( code: Char('j'), modifiers: ( bits: 0,),), page_up: ( code: Char('b'), modifiers: ( bits: 2,),), page_down: ( code: Char('f'), modifiers: ( bits: 2,),), + tree_collapse_recursive: ( code: Left, modifiers: ( bits: 1,),), + tree_expand_recursive: ( code: Right, modifiers: ( bits: 1,),), shift_up: ( code: Char('K'), modifiers: ( bits: 1,),), shift_down: ( code: Char('J'), modifiers: ( bits: 1,),),