New file tree (#718)

This commit is contained in:
Stephan Dilly 2021-05-21 14:52:05 +02:00 committed by GitHub
parent ca35426c6c
commit 0e31d57a33
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1935 additions and 24 deletions

9
Cargo.lock generated
View File

@ -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",

View File

@ -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'] }

View File

@ -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:

View File

@ -88,7 +88,7 @@ impl From<StatusType> for StatusShow {
}
}
///
/// gurantees sorting
pub fn get_status(
repo_path: &str,
status_type: StatusType,

View File

@ -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<TreeFile>) {
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<_>>(),
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<_>>(),
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<_>>(),
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
);
}
}

18
filetree/Cargo.toml Normal file
View File

@ -0,0 +1,18 @@
[package]
name = "filetree"
version = "0.1.0"
authors = ["Stephan Dilly <dilly.stephan@gmail.com>"]
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"

1
filetree/LICENSE.md Symbolic link
View File

@ -0,0 +1 @@
../LICENSE.md

16
filetree/src/error.rs Normal file
View File

@ -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<T> = std::result::Result<T, Error>;

471
filetree/src/filetree.rs Normal file
View File

@ -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<usize>,
// caches the absolute selection translated to visual index
visual_selection: Option<VisualSelection>,
}
impl FileTree {
///
pub fn new(
list: &[&str],
collapsed: &BTreeSet<&String>,
) -> Result<Self> {
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<usize> {
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<VisualSelection> {
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<usize> {
if current_index == 0 {
None
} else {
Some(0)
}
}
fn selection_end(&self, current_index: usize) -> Option<usize> {
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<usize> {
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<usize> {
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<usize> {
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<usize> {
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);
}
}

View File

@ -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<FileTreeItem>,
files: usize,
}
impl FileTreeItems {
///
pub fn new(
list: &[&str],
collapsed: &BTreeSet<&String>,
) -> Result<Self> {
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<FileTreeItem>, 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<FileTreeItem>,
// 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::<Vec<_>>();
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<String> {
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<String> = 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<FileTreeItem>,
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<FileTreeItem>,
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::<Vec<_>>();
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::<Vec<_>>();
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<bool> {
tree.tree_items
.iter()
.map(|e| e.info().is_visible())
.collect::<Vec<_>>()
}
#[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<u8> =
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);
}
}

207
filetree/src/item.rs Normal file
View File

@ -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<Self> {
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<Self> {
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<std::cmp::Ordering> {
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)
}
}

29
filetree/src/lib.rs Normal file
View File

@ -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},
};

33
filetree/src/tree_iter.rs Normal file
View File

@ -0,0 +1,33 @@
use crate::{item::FileTreeItem, treeitems_iter::TreeItemsIterator};
pub struct TreeIterator<'a> {
item_iter: TreeItemsIterator<'a>,
selection: Option<usize>,
}
impl<'a> TreeIterator<'a> {
pub const fn new(
item_iter: TreeItemsIterator<'a>,
selection: Option<usize>,
) -> Self {
Self {
item_iter,
selection,
}
}
}
impl<'a> Iterator for TreeIterator<'a> {
type Item = (&'a FileTreeItem, bool);
fn next(&mut self) -> Option<Self::Item> {
self.item_iter.next().map(|(index, item)| {
(
item,
self.selection
.map(|i| i == index)
.unwrap_or_default(),
)
})
}
}

View File

@ -0,0 +1,60 @@
use crate::{filetreeitems::FileTreeItems, item::FileTreeItem};
pub struct TreeItemsIterator<'a> {
tree: &'a FileTreeItems,
index: usize,
increments: Option<usize>,
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<Self::Item> {
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
}
}

View File

@ -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

View File

@ -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;

View File

@ -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<TreeFile>,
tree: FileTree,
scroll_top: Cell<usize>,
revision: Option<CommitId>,
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,10 +122,30 @@ 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,
)
});
@ -85,6 +154,13 @@ impl DrawableComponent for RevisionFilesComponent {
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<EventState> {
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());
}
}

View File

@ -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()},

View File

@ -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)

View File

@ -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,),),