mirror of
https://github.com/gitui-org/gitui
synced 2026-05-23 00:48:35 +00:00
Switch to a tree view for changed files list (#37)
This commit is contained in:
parent
2311098a17
commit
b0fdf5049c
15 changed files with 1183 additions and 113 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
/target
|
||||
/release
|
||||
.DS_Store
|
||||
|
|
|
|||
7
Cargo.lock
generated
7
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
26
src/app.rs
26
src/app.rs
|
|
@ -2,7 +2,8 @@ use crate::{
|
|||
components::{
|
||||
ChangesComponent, CommandBlocking, CommandInfo,
|
||||
CommitComponent, Component, DiffComponent, DrawableComponent,
|
||||
HelpComponent, MsgComponent, ResetComponent,
|
||||
FileTreeItemKind, HelpComponent, MsgComponent,
|
||||
ResetComponent,
|
||||
},
|
||||
keys,
|
||||
queue::{InternalEvent, NeedsUpdate, Queue},
|
||||
|
|
@ -177,7 +178,9 @@ impl App {
|
|||
self.switch_focus(Focus::WorkDir)
|
||||
}
|
||||
keys::FOCUS_STAGE => self.switch_focus(Focus::Stage),
|
||||
keys::FOCUS_RIGHT => self.switch_focus(Focus::Diff),
|
||||
keys::FOCUS_RIGHT if self.can_focus_diff() => {
|
||||
self.switch_focus(Focus::Diff)
|
||||
}
|
||||
keys::FOCUS_LEFT => {
|
||||
self.switch_focus(match self.diff_target {
|
||||
DiffTarget::Stage => Focus::Stage,
|
||||
|
|
@ -225,6 +228,14 @@ impl App {
|
|||
pub fn is_quit(&self) -> bool {
|
||||
self.do_quit
|
||||
}
|
||||
|
||||
fn can_focus_diff(&self) -> bool {
|
||||
match self.focus {
|
||||
Focus::WorkDir => self.index_wd.is_file_seleted(),
|
||||
Focus::Stage => self.index.is_file_seleted(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// private impls
|
||||
|
|
@ -259,11 +270,12 @@ impl App {
|
|||
DiffTarget::WorkingDir => (&self.index_wd, false),
|
||||
};
|
||||
|
||||
if let Some(i) = idx.selection() {
|
||||
Some((i.path, is_stage))
|
||||
} else {
|
||||
None
|
||||
if let Some(item) = idx.selection() {
|
||||
if let FileTreeItemKind::File(i) = item.kind {
|
||||
return Some((i.path, is_stage));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn update_commands(&mut self) {
|
||||
|
|
@ -381,7 +393,7 @@ impl App {
|
|||
));
|
||||
res.push(CommandInfo::new(
|
||||
commands::STATUS_FOCUS_RIGHT,
|
||||
true,
|
||||
self.can_focus_diff(),
|
||||
main_cmds_available && !focus_on_diff,
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
295
src/components/filetree.rs
Normal 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()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
642
src/components/statustree.rs
Normal file
642
src/components/statustree.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue