Switch to a tree view for changed files list (#37)

This commit is contained in:
Stephan Dilly 2020-04-28 11:15:05 +02:00 committed by GitHub
parent 2311098a17
commit b0fdf5049c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1183 additions and 113 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
/target
/release
.DS_Store

7
Cargo.lock generated
View file

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

View file

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

View file

@ -4,7 +4,8 @@ version = "0.1.8"
authors = ["Stephan Dilly <dilly.stephan@gmail.com>"]
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"]

View file

@ -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<Status> for StatusItemType {
}
///
#[derive(Default, Clone, Hash)]
#[derive(Default, Clone, Hash, PartialEq, Debug)]
pub struct StatusItem {
///
pub path: String,

View file

@ -4,7 +4,8 @@ version = "0.1.1"
authors = ["Stephan Dilly <dilly.stephan@gmail.com>"]
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"]

View file

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

View file

@ -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,13 @@ use crate::{
};
use asyncgit::{hash, sync, StatusItem, StatusItemType, CWD};
use crossterm::event::Event;
use std::{
borrow::Cow,
cmp,
convert::{From, TryFrom},
path::Path,
};
use log::trace;
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 +25,8 @@ use tui::{
///
pub struct ChangesComponent {
title: String,
items: Vec<StatusItem>,
selection: Option<usize>,
tree: StatusTree,
current_hash: u64,
focused: bool,
show_selection: bool,
is_working_dir: bool,
@ -43,9 +43,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 +54,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<StatusItem> {
match self.selection {
None => None,
Some(i) => Some(self.items[i].clone()),
}
pub fn selection(&self) -> Option<FileTreeItem> {
self.tree.selected_item()
}
///
@ -81,52 +72,56 @@ 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:
trace!("tbd");
}
}
@ -134,55 +129,148 @@ 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:
trace!("tbd");
}
}
false
}
fn item_to_text(
item: &FileTreeItem,
width: u16,
selected: bool,
) -> Option<Text> {
let select_color = Color::Rgb(0, 0, 100);
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(select_color);
}
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(select_color);
}
Some(Text::Styled(Cow::from(txt), style))
}
}
}
}
impl DrawableComponent for ChangesComponent {
fn draw<B: Backend>(&self, f: &mut Frame<B>, 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,
);
}
@ -194,7 +282,8 @@ impl Component for ChangesComponent {
out: &mut Vec<CommandInfo>,
_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,
@ -215,8 +304,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 +331,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,
};
}

295
src/components/filetree.rs Normal file
View file

@ -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<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)
}
}
///
#[derive(Default)]
pub struct FileTreeItems(Vec<FileTreeItem>);
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<FileTreeItem> {
&self.0
}
///
pub(crate) fn len(&self) -> usize {
self.0.len()
}
fn push_dirs(
item_path: &Path,
nodes: &mut BinaryHeap<FileTreeItem>,
paths_added: &mut BTreeSet<String>, //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<usize> for FileTreeItems {
fn index_mut(&mut self, idx: usize) -> &mut Self::Output {
&mut self.0[idx]
}
}
impl Index<usize> 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<StatusItem> {
items
.iter()
.map(|a| StatusItem {
path: String::from(*a),
status: None,
})
.collect::<Vec<_>>()
}
#[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::<Vec<_>>();
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::<Vec<_>>();
assert_eq!(
res,
vec![
String::from("a"),
items[0].path.clone(),
items[1].path.clone()
]
);
}
}

View file

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

View file

@ -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<usize>,
}
///
#[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<FileTreeItem> {
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<usize> {
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<String> = 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<StatusItem> {
items
.iter()
.map(|a| StatusItem {
path: String::from(*a),
status: None,
})
.collect::<Vec<_>>()
}
fn get_visibles(tree: &StatusTree) -> Vec<bool> {
tree.tree
.items()
.iter()
.map(|e| e.info.visible)
.collect::<Vec<_>>()
}
#[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<_>>(),
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<_>>(),
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));
}
}

View file

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

View file

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

View file

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

View file

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