mirror of
https://github.com/gitui-org/gitui
synced 2026-05-24 09:28:21 +00:00
stashing support (#70)
This commit is contained in:
parent
5936757538
commit
3c5e86eee9
18 changed files with 1297 additions and 560 deletions
1
assets/stashing.drawio
Normal file
1
assets/stashing.drawio
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -8,12 +8,14 @@ mod diff;
|
||||||
mod error;
|
mod error;
|
||||||
mod revlog;
|
mod revlog;
|
||||||
mod status;
|
mod status;
|
||||||
|
mod status2;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
|
||||||
pub use crate::{
|
pub use crate::{
|
||||||
diff::{AsyncDiff, DiffParams},
|
diff::{AsyncDiff, DiffParams},
|
||||||
revlog::AsyncLog,
|
revlog::AsyncLog,
|
||||||
status::AsyncStatus,
|
status::AsyncStatus,
|
||||||
|
status2::{AsyncStatus2, StatusParams},
|
||||||
sync::{
|
sync::{
|
||||||
diff::{DiffLine, DiffLineType, FileDiff},
|
diff::{DiffLine, DiffLineType, FileDiff},
|
||||||
status::{StatusItem, StatusItemType},
|
status::{StatusItem, StatusItemType},
|
||||||
|
|
|
||||||
160
asyncgit/src/status2.rs
Normal file
160
asyncgit/src/status2.rs
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
use crate::{
|
||||||
|
current_tick, error::Result, hash, sync, AsyncNotification,
|
||||||
|
StatusItem, CWD,
|
||||||
|
};
|
||||||
|
use crossbeam_channel::Sender;
|
||||||
|
use log::trace;
|
||||||
|
use std::{
|
||||||
|
hash::Hash,
|
||||||
|
sync::{
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
Arc, Mutex,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use sync::status::StatusType;
|
||||||
|
|
||||||
|
#[derive(Default, Hash, Clone)]
|
||||||
|
pub struct Status2 {
|
||||||
|
pub items: Vec<StatusItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
#[derive(Default, Hash, Clone, PartialEq)]
|
||||||
|
pub struct StatusParams {
|
||||||
|
tick: u64,
|
||||||
|
status_type: StatusType,
|
||||||
|
include_untracked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatusParams {
|
||||||
|
///
|
||||||
|
pub fn new(
|
||||||
|
status_type: StatusType,
|
||||||
|
include_untracked: bool,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
tick: current_tick(),
|
||||||
|
status_type,
|
||||||
|
include_untracked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Request<R, A>(R, Option<A>);
|
||||||
|
|
||||||
|
///TODO: merge functionality with AsyncStatus
|
||||||
|
pub struct AsyncStatus2 {
|
||||||
|
current: Arc<Mutex<Request<u64, Status2>>>,
|
||||||
|
last: Arc<Mutex<Status2>>,
|
||||||
|
sender: Sender<AsyncNotification>,
|
||||||
|
pending: Arc<AtomicUsize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncStatus2 {
|
||||||
|
///
|
||||||
|
pub fn new(sender: Sender<AsyncNotification>) -> Self {
|
||||||
|
Self {
|
||||||
|
current: Arc::new(Mutex::new(Request(0, None))),
|
||||||
|
last: Arc::new(Mutex::new(Status2::default())),
|
||||||
|
sender,
|
||||||
|
pending: Arc::new(AtomicUsize::new(0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
pub fn last(&mut self) -> Result<Status2> {
|
||||||
|
let last = self.last.lock()?;
|
||||||
|
Ok(last.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
pub fn is_pending(&self) -> bool {
|
||||||
|
self.pending.load(Ordering::Relaxed) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
pub fn fetch(
|
||||||
|
&mut self,
|
||||||
|
params: StatusParams,
|
||||||
|
) -> Result<Option<Status2>> {
|
||||||
|
let hash_request = hash(¶ms);
|
||||||
|
|
||||||
|
trace!("request: [hash: {}]", hash_request);
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut current = self.current.lock()?;
|
||||||
|
|
||||||
|
if current.0 == hash_request {
|
||||||
|
return Ok(current.1.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
current.0 = hash_request;
|
||||||
|
current.1 = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let arc_current = Arc::clone(&self.current);
|
||||||
|
let arc_last = Arc::clone(&self.last);
|
||||||
|
let sender = self.sender.clone();
|
||||||
|
let arc_pending = Arc::clone(&self.pending);
|
||||||
|
let status_type = params.status_type;
|
||||||
|
let include_untracked = params.include_untracked;
|
||||||
|
rayon_core::spawn(move || {
|
||||||
|
arc_pending.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
||||||
|
Self::fetch_helper(
|
||||||
|
status_type,
|
||||||
|
include_untracked,
|
||||||
|
hash_request,
|
||||||
|
arc_current,
|
||||||
|
arc_last,
|
||||||
|
)
|
||||||
|
.expect("failed to fetch status");
|
||||||
|
|
||||||
|
arc_pending.fetch_sub(1, Ordering::Relaxed);
|
||||||
|
|
||||||
|
sender
|
||||||
|
.send(AsyncNotification::Status)
|
||||||
|
.expect("error sending status");
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_helper(
|
||||||
|
status_type: StatusType,
|
||||||
|
include_untracked: bool,
|
||||||
|
hash_request: u64,
|
||||||
|
arc_current: Arc<Mutex<Request<u64, Status2>>>,
|
||||||
|
arc_last: Arc<Mutex<Status2>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let res = Self::get_status(status_type, include_untracked)?;
|
||||||
|
trace!("status fetched: {}", hash(&res));
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut current = arc_current.lock()?;
|
||||||
|
if current.0 == hash_request {
|
||||||
|
current.1 = Some(res.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut last = arc_last.lock()?;
|
||||||
|
*last = res;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_status(
|
||||||
|
status_type: StatusType,
|
||||||
|
include_untracked: bool,
|
||||||
|
) -> Result<Status2> {
|
||||||
|
Ok(Status2 {
|
||||||
|
items: sync::status::get_status_new(
|
||||||
|
CWD,
|
||||||
|
status_type,
|
||||||
|
include_untracked,
|
||||||
|
)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ mod hooks;
|
||||||
mod hunks;
|
mod hunks;
|
||||||
mod logwalker;
|
mod logwalker;
|
||||||
mod reset;
|
mod reset;
|
||||||
|
mod stash;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
mod tags;
|
mod tags;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
@ -17,6 +18,7 @@ pub use logwalker::LogWalker;
|
||||||
pub use reset::{
|
pub use reset::{
|
||||||
reset_stage, reset_workdir_file, reset_workdir_folder,
|
reset_stage, reset_workdir_file, reset_workdir_folder,
|
||||||
};
|
};
|
||||||
|
pub use stash::stash_save;
|
||||||
pub use tags::{get_tags, Tags};
|
pub use tags::{get_tags, Tags};
|
||||||
pub use utils::{
|
pub use utils::{
|
||||||
commit, stage_add_all, stage_add_file, stage_addremoved,
|
commit, stage_add_all, stage_add_file, stage_addremoved,
|
||||||
|
|
|
||||||
124
asyncgit/src/sync/stash.rs
Normal file
124
asyncgit/src/sync/stash.rs
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
#![allow(dead_code)]
|
||||||
|
use super::utils::repo;
|
||||||
|
use crate::error::Result;
|
||||||
|
use git2::{Oid, StashFlags};
|
||||||
|
use scopetime::scope_time;
|
||||||
|
|
||||||
|
///
|
||||||
|
pub struct StashItem {
|
||||||
|
pub msg: String,
|
||||||
|
index: usize,
|
||||||
|
id: Oid,
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
pub struct StashItems(Vec<StashItem>);
|
||||||
|
|
||||||
|
///
|
||||||
|
pub fn get_stashes(repo_path: &str) -> Result<StashItems> {
|
||||||
|
scope_time!("get_stashes");
|
||||||
|
|
||||||
|
let mut repo = repo(repo_path)?;
|
||||||
|
|
||||||
|
let mut list = Vec::new();
|
||||||
|
|
||||||
|
repo.stash_foreach(|index, msg, id| {
|
||||||
|
list.push(StashItem {
|
||||||
|
msg: msg.to_string(),
|
||||||
|
index,
|
||||||
|
id: *id,
|
||||||
|
});
|
||||||
|
true
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(StashItems(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
pub fn stash_save(
|
||||||
|
repo_path: &str,
|
||||||
|
message: Option<&str>,
|
||||||
|
include_untracked: bool,
|
||||||
|
keep_index: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
scope_time!("stash_save");
|
||||||
|
|
||||||
|
let mut repo = repo(repo_path)?;
|
||||||
|
|
||||||
|
let sig = repo.signature()?;
|
||||||
|
|
||||||
|
let mut options = StashFlags::DEFAULT;
|
||||||
|
|
||||||
|
if include_untracked {
|
||||||
|
options.insert(StashFlags::INCLUDE_UNTRACKED);
|
||||||
|
}
|
||||||
|
if keep_index {
|
||||||
|
options.insert(StashFlags::KEEP_INDEX)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo.stash_save2(&sig, message, Some(options))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::sync::tests::{get_statuses, repo_init};
|
||||||
|
use std::{fs::File, io::Write};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_smoke() {
|
||||||
|
let (_td, repo) = repo_init().unwrap();
|
||||||
|
let root = repo.path().parent().unwrap();
|
||||||
|
let repo_path = root.as_os_str().to_str().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
stash_save(repo_path, None, true, false).is_ok(),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
get_stashes(repo_path).unwrap().0.is_empty(),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stashing() -> Result<()> {
|
||||||
|
let (_td, repo) = repo_init().unwrap();
|
||||||
|
let root = repo.path().parent().unwrap();
|
||||||
|
let repo_path = root.as_os_str().to_str().unwrap();
|
||||||
|
|
||||||
|
File::create(&root.join("foo.txt"))?
|
||||||
|
.write_all(b"test\nfoo")?;
|
||||||
|
|
||||||
|
assert_eq!(get_statuses(repo_path), (1, 0));
|
||||||
|
|
||||||
|
stash_save(repo_path, None, true, false)?;
|
||||||
|
|
||||||
|
assert_eq!(get_statuses(repo_path), (0, 0));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_stashes() -> Result<()> {
|
||||||
|
let (_td, repo) = repo_init().unwrap();
|
||||||
|
let root = repo.path().parent().unwrap();
|
||||||
|
let repo_path = root.as_os_str().to_str().unwrap();
|
||||||
|
|
||||||
|
File::create(&root.join("foo.txt"))?
|
||||||
|
.write_all(b"test\nfoo")?;
|
||||||
|
|
||||||
|
stash_save(repo_path, Some("foo"), true, false)?;
|
||||||
|
|
||||||
|
let res = get_stashes(repo_path)?;
|
||||||
|
|
||||||
|
assert_eq!(res.0.len(), 1);
|
||||||
|
assert_eq!(res.0[0].msg, "On master: foo");
|
||||||
|
assert_eq!(res.0[0].index, 0);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,12 +46,20 @@ pub struct StatusItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
///
|
///
|
||||||
#[derive(Copy, Clone)]
|
#[derive(Copy, Clone, Hash, PartialEq)]
|
||||||
pub enum StatusType {
|
pub enum StatusType {
|
||||||
///
|
///
|
||||||
WorkingDir,
|
WorkingDir,
|
||||||
///
|
///
|
||||||
Stage,
|
Stage,
|
||||||
|
///
|
||||||
|
Both,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StatusType {
|
||||||
|
fn default() -> Self {
|
||||||
|
StatusType::WorkingDir
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Into<StatusShow> for StatusType {
|
impl Into<StatusShow> for StatusType {
|
||||||
|
|
@ -59,23 +67,33 @@ impl Into<StatusShow> for StatusType {
|
||||||
match self {
|
match self {
|
||||||
StatusType::WorkingDir => StatusShow::Workdir,
|
StatusType::WorkingDir => StatusShow::Workdir,
|
||||||
StatusType::Stage => StatusShow::Index,
|
StatusType::Stage => StatusShow::Index,
|
||||||
|
StatusType::Both => StatusShow::IndexAndWorkdir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
///
|
/// TODO: migrate
|
||||||
pub fn get_status(
|
pub fn get_status(
|
||||||
repo_path: &str,
|
repo_path: &str,
|
||||||
status_type: StatusType,
|
status_type: StatusType,
|
||||||
) -> Result<Vec<StatusItem>> {
|
) -> Result<Vec<StatusItem>> {
|
||||||
scope_time!("get_index");
|
get_status_new(repo_path, status_type, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TODO: migrate
|
||||||
|
pub fn get_status_new(
|
||||||
|
repo_path: &str,
|
||||||
|
status_type: StatusType,
|
||||||
|
include_untracked: bool,
|
||||||
|
) -> Result<Vec<StatusItem>> {
|
||||||
|
scope_time!("get_status");
|
||||||
|
|
||||||
let repo = utils::repo(repo_path)?;
|
let repo = utils::repo(repo_path)?;
|
||||||
|
|
||||||
let statuses = repo.statuses(Some(
|
let statuses = repo.statuses(Some(
|
||||||
StatusOptions::default()
|
StatusOptions::default()
|
||||||
.show(status_type.into())
|
.show(status_type.into())
|
||||||
.include_untracked(true)
|
.include_untracked(include_untracked)
|
||||||
.renames_head_to_index(true)
|
.renames_head_to_index(true)
|
||||||
.recurse_untracked_dirs(true),
|
.recurse_untracked_dirs(true),
|
||||||
))?;
|
))?;
|
||||||
|
|
|
||||||
60
src/app.rs
60
src/app.rs
|
|
@ -8,7 +8,7 @@ use crate::{
|
||||||
keys,
|
keys,
|
||||||
queue::{InternalEvent, NeedsUpdate, Queue},
|
queue::{InternalEvent, NeedsUpdate, Queue},
|
||||||
strings,
|
strings,
|
||||||
tabs::{Revlog, Status},
|
tabs::{Revlog, Stashing, Status},
|
||||||
ui::style::Theme,
|
ui::style::Theme,
|
||||||
};
|
};
|
||||||
use asyncgit::{sync, AsyncNotification, CWD};
|
use asyncgit::{sync, AsyncNotification, CWD};
|
||||||
|
|
@ -38,6 +38,7 @@ pub struct App {
|
||||||
tab: usize,
|
tab: usize,
|
||||||
revlog: Revlog,
|
revlog: Revlog,
|
||||||
status_tab: Status,
|
status_tab: Status,
|
||||||
|
stashing_tab: Stashing,
|
||||||
queue: Queue,
|
queue: Queue,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
}
|
}
|
||||||
|
|
@ -60,6 +61,7 @@ impl App {
|
||||||
tab: 0,
|
tab: 0,
|
||||||
revlog: Revlog::new(&sender, &theme),
|
revlog: Revlog::new(&sender, &theme),
|
||||||
status_tab: Status::new(&sender, &queue, &theme),
|
status_tab: Status::new(&sender, &queue, &theme),
|
||||||
|
stashing_tab: Stashing::new(&sender, &queue, &theme),
|
||||||
queue,
|
queue,
|
||||||
theme,
|
theme,
|
||||||
}
|
}
|
||||||
|
|
@ -81,11 +83,13 @@ impl App {
|
||||||
|
|
||||||
self.draw_tabs(f, chunks_main[0]);
|
self.draw_tabs(f, chunks_main[0]);
|
||||||
|
|
||||||
if self.tab == 0 {
|
//TODO: macro because of generic draw call
|
||||||
self.status_tab.draw(f, chunks_main[1]);
|
match self.tab {
|
||||||
} else {
|
0 => self.status_tab.draw(f, chunks_main[1]),
|
||||||
self.revlog.draw(f, chunks_main[1]);
|
1 => self.revlog.draw(f, chunks_main[1]),
|
||||||
}
|
2 => self.stashing_tab.draw(f, chunks_main[1]),
|
||||||
|
_ => panic!("unknown tab"),
|
||||||
|
};
|
||||||
|
|
||||||
Self::draw_commands(
|
Self::draw_commands(
|
||||||
f,
|
f,
|
||||||
|
|
@ -142,6 +146,7 @@ impl App {
|
||||||
trace!("update");
|
trace!("update");
|
||||||
self.status_tab.update();
|
self.status_tab.update();
|
||||||
self.revlog.update();
|
self.revlog.update();
|
||||||
|
self.stashing_tab.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
///
|
///
|
||||||
|
|
@ -149,6 +154,7 @@ impl App {
|
||||||
trace!("update_git: {:?}", ev);
|
trace!("update_git: {:?}", ev);
|
||||||
|
|
||||||
self.status_tab.update_git(ev);
|
self.status_tab.update_git(ev);
|
||||||
|
self.stashing_tab.update_git(ev);
|
||||||
|
|
||||||
match ev {
|
match ev {
|
||||||
AsyncNotification::Diff => (),
|
AsyncNotification::Diff => (),
|
||||||
|
|
@ -167,12 +173,16 @@ impl App {
|
||||||
pub fn any_work_pending(&self) -> bool {
|
pub fn any_work_pending(&self) -> bool {
|
||||||
self.status_tab.anything_pending()
|
self.status_tab.anything_pending()
|
||||||
|| self.revlog.any_work_pending()
|
|| self.revlog.any_work_pending()
|
||||||
|
|| self.stashing_tab.anything_pending()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// private impls
|
// private impls
|
||||||
impl App {
|
impl App {
|
||||||
accessors!(self, [msg, reset, commit, help, revlog, status_tab]);
|
accessors!(
|
||||||
|
self,
|
||||||
|
[msg, reset, commit, help, revlog, status_tab, stashing_tab]
|
||||||
|
);
|
||||||
|
|
||||||
fn check_quit(&mut self, ev: Event) -> bool {
|
fn check_quit(&mut self, ev: Event) -> bool {
|
||||||
if let Event::Key(e) = ev {
|
if let Event::Key(e) = ev {
|
||||||
|
|
@ -184,17 +194,29 @@ impl App {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn toggle_tabs(&mut self) {
|
fn get_tabs(&mut self) -> Vec<&mut dyn Component> {
|
||||||
self.tab += 1;
|
vec![
|
||||||
self.tab %= 2;
|
&mut self.status_tab,
|
||||||
|
&mut self.revlog,
|
||||||
|
&mut self.stashing_tab,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
if self.tab == 1 {
|
fn toggle_tabs(&mut self) {
|
||||||
self.status_tab.hide();
|
let mut new_tab = self.tab + 1;
|
||||||
self.revlog.show();
|
{
|
||||||
} else {
|
let tabs = self.get_tabs();
|
||||||
self.status_tab.show();
|
new_tab %= tabs.len();
|
||||||
self.revlog.hide();
|
|
||||||
|
for (i, t) in tabs.into_iter().enumerate() {
|
||||||
|
if new_tab == i {
|
||||||
|
t.show();
|
||||||
|
} else {
|
||||||
|
t.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
self.tab = new_tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_commands(&mut self) {
|
fn update_commands(&mut self) {
|
||||||
|
|
@ -328,7 +350,11 @@ impl App {
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Tabs::default()
|
Tabs::default()
|
||||||
.block(Block::default().borders(Borders::BOTTOM))
|
.block(Block::default().borders(Borders::BOTTOM))
|
||||||
.titles(&[strings::TAB_STATUS, strings::TAB_LOG])
|
.titles(&[
|
||||||
|
strings::TAB_STATUS,
|
||||||
|
strings::TAB_LOG,
|
||||||
|
strings::TAB_STASHING,
|
||||||
|
])
|
||||||
.style(Style::default())
|
.style(Style::default())
|
||||||
.highlight_style(
|
.highlight_style(
|
||||||
self.theme
|
self.theme
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,26 @@
|
||||||
use super::{
|
use super::{
|
||||||
filetree::{FileTreeItem, FileTreeItemKind},
|
filetree::FileTreeComponent,
|
||||||
statustree::{MoveSelection, StatusTree},
|
utils::filetree::{FileTreeItem, FileTreeItemKind},
|
||||||
CommandBlocking, DrawableComponent,
|
CommandBlocking, DrawableComponent,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
components::{CommandInfo, Component},
|
components::{CommandInfo, Component},
|
||||||
keys,
|
keys,
|
||||||
queue::{InternalEvent, NeedsUpdate, Queue, ResetItem},
|
queue::{InternalEvent, NeedsUpdate, Queue, ResetItem},
|
||||||
strings, ui,
|
strings,
|
||||||
ui::style::Theme,
|
ui::style::Theme,
|
||||||
};
|
};
|
||||||
use asyncgit::{hash, sync, StatusItem, StatusItemType, CWD};
|
use asyncgit::{sync, StatusItem, StatusItemType, CWD};
|
||||||
use crossterm::event::Event;
|
use crossterm::event::Event;
|
||||||
use std::{borrow::Cow, convert::From, path::Path};
|
use std::path::Path;
|
||||||
use strings::commands;
|
use strings::commands;
|
||||||
use tui::{backend::Backend, layout::Rect, widgets::Text, Frame};
|
use tui::{backend::Backend, layout::Rect, Frame};
|
||||||
|
|
||||||
///
|
///
|
||||||
pub struct ChangesComponent {
|
pub struct ChangesComponent {
|
||||||
title: String,
|
files: FileTreeComponent,
|
||||||
tree: StatusTree,
|
|
||||||
current_hash: u64,
|
|
||||||
focused: bool,
|
|
||||||
show_selection: bool,
|
|
||||||
is_working_dir: bool,
|
is_working_dir: bool,
|
||||||
queue: Queue,
|
queue: Queue,
|
||||||
theme: Theme,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChangesComponent {
|
impl ChangesComponent {
|
||||||
|
|
@ -38,64 +33,40 @@ impl ChangesComponent {
|
||||||
theme: &Theme,
|
theme: &Theme,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
title: title.to_string(),
|
files: FileTreeComponent::new(
|
||||||
tree: StatusTree::default(),
|
title,
|
||||||
current_hash: 0,
|
focus,
|
||||||
focused: focus,
|
queue.clone(),
|
||||||
show_selection: focus,
|
theme,
|
||||||
|
),
|
||||||
is_working_dir,
|
is_working_dir,
|
||||||
queue,
|
queue,
|
||||||
theme: *theme,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
///
|
///
|
||||||
pub fn update(&mut self, list: &[StatusItem]) {
|
pub fn update(&mut self, list: &[StatusItem]) {
|
||||||
let new_hash = hash(list);
|
self.files.update(list)
|
||||||
if self.current_hash != new_hash {
|
|
||||||
self.tree.update(list);
|
|
||||||
self.current_hash = new_hash;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
///
|
///
|
||||||
pub fn selection(&self) -> Option<FileTreeItem> {
|
pub fn selection(&self) -> Option<FileTreeItem> {
|
||||||
self.tree.selected_item()
|
self.files.selection()
|
||||||
}
|
}
|
||||||
|
|
||||||
///
|
///
|
||||||
pub fn focus_select(&mut self, focus: bool) {
|
pub fn focus_select(&mut self, focus: bool) {
|
||||||
self.focus(focus);
|
self.files.focus_select(focus)
|
||||||
self.show_selection = focus;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// returns true if list is empty
|
/// returns true if list is empty
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.tree.is_empty()
|
self.files.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
///
|
///
|
||||||
pub fn is_file_seleted(&self) -> bool {
|
pub fn is_file_seleted(&self) -> bool {
|
||||||
if let Some(item) = self.tree.selected_item() {
|
self.files.is_file_seleted()
|
||||||
match item.kind {
|
|
||||||
FileTreeItemKind::File(_) => true,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
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 {
|
fn index_add_remove(&mut self) -> bool {
|
||||||
|
|
@ -147,140 +118,11 @@ impl ChangesComponent {
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn item_to_text(
|
|
||||||
item: &FileTreeItem,
|
|
||||||
width: u16,
|
|
||||||
selected: bool,
|
|
||||||
theme: Theme,
|
|
||||||
) -> Option<Text> {
|
|
||||||
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 status_char =
|
|
||||||
Self::item_status_char(status_item.status);
|
|
||||||
let file = Path::new(&status_item.path)
|
|
||||||
.file_name()
|
|
||||||
.unwrap()
|
|
||||||
.to_str()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let txt = if selected {
|
|
||||||
format!(
|
|
||||||
"{} {}{:w$}",
|
|
||||||
status_char,
|
|
||||||
indent_str,
|
|
||||||
file,
|
|
||||||
w = width as usize
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
format!("{} {}{}", status_char, indent_str, file)
|
|
||||||
};
|
|
||||||
|
|
||||||
let status = status_item
|
|
||||||
.status
|
|
||||||
.unwrap_or(StatusItemType::Modified);
|
|
||||||
|
|
||||||
Some(Text::Styled(
|
|
||||||
Cow::from(txt),
|
|
||||||
theme.item(status, selected),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
Some(Text::Styled(
|
|
||||||
Cow::from(txt),
|
|
||||||
theme.text(true, selected),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn item_status_char(item_type: Option<StatusItemType>) -> char {
|
|
||||||
if let Some(item_type) = item_type {
|
|
||||||
match item_type {
|
|
||||||
StatusItemType::Modified => 'M',
|
|
||||||
StatusItemType::New => '+',
|
|
||||||
StatusItemType::Deleted => '-',
|
|
||||||
StatusItemType::Renamed => 'R',
|
|
||||||
_ => ' ',
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
' '
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DrawableComponent for ChangesComponent {
|
impl DrawableComponent for ChangesComponent {
|
||||||
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, r: Rect) {
|
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, r: Rect) {
|
||||||
let selection_offset =
|
self.files.draw(f, r)
|
||||||
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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
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),
|
|
||||||
self.theme,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ui::draw_list(
|
|
||||||
f,
|
|
||||||
r,
|
|
||||||
&self.title.to_string(),
|
|
||||||
items,
|
|
||||||
self.tree.selection.map(|idx| idx - selection_offset),
|
|
||||||
self.focused,
|
|
||||||
self.theme,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -290,46 +132,46 @@ impl Component for ChangesComponent {
|
||||||
out: &mut Vec<CommandInfo>,
|
out: &mut Vec<CommandInfo>,
|
||||||
force_all: bool,
|
force_all: bool,
|
||||||
) -> CommandBlocking {
|
) -> CommandBlocking {
|
||||||
|
self.files.commands(out, force_all);
|
||||||
|
|
||||||
let some_selection = self.selection().is_some();
|
let some_selection = self.selection().is_some();
|
||||||
|
|
||||||
if self.is_working_dir {
|
if self.is_working_dir {
|
||||||
out.push(CommandInfo::new(
|
out.push(CommandInfo::new(
|
||||||
commands::STAGE_ITEM,
|
commands::STAGE_ITEM,
|
||||||
some_selection,
|
some_selection,
|
||||||
self.focused,
|
self.focused(),
|
||||||
));
|
));
|
||||||
out.push(CommandInfo::new(
|
out.push(CommandInfo::new(
|
||||||
commands::RESET_ITEM,
|
commands::RESET_ITEM,
|
||||||
some_selection,
|
some_selection,
|
||||||
self.focused,
|
self.focused(),
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
out.push(CommandInfo::new(
|
out.push(CommandInfo::new(
|
||||||
commands::UNSTAGE_ITEM,
|
commands::UNSTAGE_ITEM,
|
||||||
some_selection,
|
some_selection,
|
||||||
self.focused,
|
self.focused(),
|
||||||
));
|
));
|
||||||
out.push(
|
out.push(
|
||||||
CommandInfo::new(
|
CommandInfo::new(
|
||||||
commands::COMMIT_OPEN,
|
commands::COMMIT_OPEN,
|
||||||
!self.is_empty(),
|
!self.is_empty(),
|
||||||
self.focused || force_all,
|
self.focused() || force_all,
|
||||||
)
|
)
|
||||||
.order(-1),
|
.order(-1),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
out.push(CommandInfo::new(
|
|
||||||
commands::NAVIGATE_TREE,
|
|
||||||
!self.is_empty(),
|
|
||||||
self.focused,
|
|
||||||
));
|
|
||||||
|
|
||||||
CommandBlocking::PassingOn
|
CommandBlocking::PassingOn
|
||||||
}
|
}
|
||||||
|
|
||||||
fn event(&mut self, ev: Event) -> bool {
|
fn event(&mut self, ev: Event) -> bool {
|
||||||
if self.focused {
|
if self.files.event(ev) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.focused() {
|
||||||
if let Event::Key(e) = ev {
|
if let Event::Key(e) = ev {
|
||||||
return match e {
|
return match e {
|
||||||
keys::OPEN_COMMIT
|
keys::OPEN_COMMIT
|
||||||
|
|
@ -356,24 +198,6 @@ impl Component for ChangesComponent {
|
||||||
{
|
{
|
||||||
self.dispatch_reset_workdir()
|
self.dispatch_reset_workdir()
|
||||||
}
|
}
|
||||||
keys::MOVE_DOWN => {
|
|
||||||
self.move_selection(MoveSelection::Down)
|
|
||||||
}
|
|
||||||
keys::MOVE_UP => {
|
|
||||||
self.move_selection(MoveSelection::Up)
|
|
||||||
}
|
|
||||||
keys::HOME | keys::SHIFT_UP => {
|
|
||||||
self.move_selection(MoveSelection::Home)
|
|
||||||
}
|
|
||||||
keys::END | keys::SHIFT_DOWN => {
|
|
||||||
self.move_selection(MoveSelection::End)
|
|
||||||
}
|
|
||||||
keys::MOVE_LEFT => {
|
|
||||||
self.move_selection(MoveSelection::Left)
|
|
||||||
}
|
|
||||||
keys::MOVE_RIGHT => {
|
|
||||||
self.move_selection(MoveSelection::Right)
|
|
||||||
}
|
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -383,9 +207,9 @@ impl Component for ChangesComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn focused(&self) -> bool {
|
fn focused(&self) -> bool {
|
||||||
self.focused
|
self.files.focused()
|
||||||
}
|
}
|
||||||
fn focus(&mut self, focus: bool) {
|
fn focus(&mut self, focus: bool) {
|
||||||
self.focused = focus
|
self.files.focus(focus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,360 +1,287 @@
|
||||||
use asyncgit::StatusItem;
|
use super::{
|
||||||
use std::{
|
utils::{
|
||||||
collections::BTreeSet,
|
filetree::{FileTreeItem, FileTreeItemKind},
|
||||||
convert::TryFrom,
|
statustree::{MoveSelection, StatusTree},
|
||||||
ops::{Index, IndexMut},
|
},
|
||||||
path::Path,
|
CommandBlocking, DrawableComponent,
|
||||||
};
|
};
|
||||||
|
use crate::{
|
||||||
/// holds the information shared among all `FileTreeItem` in a `FileTree`
|
components::{CommandInfo, Component},
|
||||||
#[derive(Debug, Clone)]
|
keys,
|
||||||
pub struct TreeItemInfo {
|
queue::{InternalEvent, NeedsUpdate, Queue},
|
||||||
/// indent level
|
strings, ui,
|
||||||
pub indent: u8,
|
ui::style::Theme,
|
||||||
/// currently visible depending on the folder collapse states
|
};
|
||||||
pub visible: bool,
|
use asyncgit::{hash, StatusItem, StatusItemType};
|
||||||
/// just the last path element
|
use crossterm::event::Event;
|
||||||
pub path: String,
|
use std::{borrow::Cow, convert::From, path::Path};
|
||||||
/// the full path
|
use strings::commands;
|
||||||
pub full_path: String,
|
use tui::{backend::Backend, layout::Rect, widgets::Text, Frame};
|
||||||
}
|
|
||||||
|
|
||||||
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 FileTreeComponent {
|
||||||
pub struct FileTreeItems(Vec<FileTreeItem>);
|
title: String,
|
||||||
|
tree: StatusTree,
|
||||||
|
current_hash: u64,
|
||||||
|
focused: bool,
|
||||||
|
show_selection: bool,
|
||||||
|
queue: Queue,
|
||||||
|
theme: Theme,
|
||||||
|
}
|
||||||
|
|
||||||
impl FileTreeItems {
|
impl FileTreeComponent {
|
||||||
///
|
///
|
||||||
pub(crate) fn new(
|
pub fn new(
|
||||||
list: &[StatusItem],
|
title: &str,
|
||||||
collapsed: &BTreeSet<&String>,
|
focus: bool,
|
||||||
|
queue: Queue,
|
||||||
|
theme: &Theme,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut nodes = Vec::with_capacity(list.len());
|
Self {
|
||||||
let mut paths_added = BTreeSet::new();
|
title: title.to_string(),
|
||||||
|
tree: StatusTree::default(),
|
||||||
|
current_hash: 0,
|
||||||
|
focused: focus,
|
||||||
|
show_selection: focus,
|
||||||
|
queue,
|
||||||
|
theme: *theme,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for e in list {
|
///
|
||||||
{
|
pub fn update(&mut self, list: &[StatusItem]) {
|
||||||
let item_path = Path::new(&e.path);
|
let new_hash = hash(list);
|
||||||
|
if self.current_hash != new_hash {
|
||||||
|
self.tree.update(list);
|
||||||
|
self.current_hash = new_hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
FileTreeItems::push_dirs(
|
///
|
||||||
item_path,
|
pub fn selection(&self) -> Option<FileTreeItem> {
|
||||||
&mut nodes,
|
self.tree.selected_item()
|
||||||
&mut paths_added,
|
}
|
||||||
&collapsed,
|
|
||||||
);
|
///
|
||||||
|
pub fn focus_select(&mut self, focus: bool) {
|
||||||
|
self.focus(focus);
|
||||||
|
self.show_selection = focus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// returns true if list is empty
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.tree.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
pub fn is_file_seleted(&self) -> bool {
|
||||||
|
if let Some(item) = self.tree.selected_item() {
|
||||||
|
match item.kind {
|
||||||
|
FileTreeItemKind::File(_) => true,
|
||||||
|
_ => false,
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nodes.push(FileTreeItem::new_file(&e));
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
Self(nodes)
|
changed
|
||||||
}
|
}
|
||||||
|
|
||||||
///
|
fn item_to_text(
|
||||||
pub(crate) fn items(&self) -> &Vec<FileTreeItem> {
|
item: &FileTreeItem,
|
||||||
&self.0
|
width: u16,
|
||||||
}
|
selected: bool,
|
||||||
|
theme: Theme,
|
||||||
|
) -> Option<Text> {
|
||||||
|
let indent_str = if item.info.indent == 0 {
|
||||||
|
String::from("")
|
||||||
|
} else {
|
||||||
|
format!("{:w$}", " ", w = (item.info.indent as usize) * 2)
|
||||||
|
};
|
||||||
|
|
||||||
///
|
if !item.info.visible {
|
||||||
pub(crate) fn len(&self) -> usize {
|
return None;
|
||||||
self.0.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
///
|
|
||||||
pub(crate) fn find_parent_index(
|
|
||||||
&self,
|
|
||||||
path: &str,
|
|
||||||
index: usize,
|
|
||||||
) -> usize {
|
|
||||||
if let Some(parent_path) = Path::new(path).parent() {
|
|
||||||
let parent_path = parent_path.to_str().unwrap();
|
|
||||||
for i in (0..=index).rev() {
|
|
||||||
let item = &self.0[i];
|
|
||||||
let item_path = &item.info.full_path;
|
|
||||||
if item_path == parent_path {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
0
|
match &item.kind {
|
||||||
|
FileTreeItemKind::File(status_item) => {
|
||||||
|
let status_char =
|
||||||
|
Self::item_status_char(status_item.status);
|
||||||
|
let file = Path::new(&status_item.path)
|
||||||
|
.file_name()
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let txt = if selected {
|
||||||
|
format!(
|
||||||
|
"{} {}{:w$}",
|
||||||
|
status_char,
|
||||||
|
indent_str,
|
||||||
|
file,
|
||||||
|
w = width as usize
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("{} {}{}", status_char, indent_str, file)
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = status_item
|
||||||
|
.status
|
||||||
|
.unwrap_or(StatusItemType::Modified);
|
||||||
|
|
||||||
|
Some(Text::Styled(
|
||||||
|
Cow::from(txt),
|
||||||
|
theme.item(status, selected),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(Text::Styled(
|
||||||
|
Cow::from(txt),
|
||||||
|
theme.text(true, selected),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_dirs<'a>(
|
fn item_status_char(item_type: Option<StatusItemType>) -> char {
|
||||||
item_path: &'a Path,
|
if let Some(item_type) = item_type {
|
||||||
nodes: &mut Vec<FileTreeItem>,
|
match item_type {
|
||||||
paths_added: &mut BTreeSet<&'a Path>,
|
StatusItemType::Modified => 'M',
|
||||||
collapsed: &BTreeSet<&String>,
|
StatusItemType::New => '+',
|
||||||
) {
|
StatusItemType::Deleted => '-',
|
||||||
let mut ancestors =
|
StatusItemType::Renamed => 'R',
|
||||||
{ item_path.ancestors().skip(1).collect::<Vec<_>>() };
|
_ => ' ',
|
||||||
ancestors.reverse();
|
|
||||||
|
|
||||||
for c in &ancestors {
|
|
||||||
if c.parent().is_some() {
|
|
||||||
let path_string = String::from(c.to_str().unwrap());
|
|
||||||
if !paths_added.contains(c) {
|
|
||||||
paths_added.insert(c);
|
|
||||||
let is_collapsed =
|
|
||||||
collapsed.contains(&path_string);
|
|
||||||
nodes.push(FileTreeItem::new_path(
|
|
||||||
c,
|
|
||||||
path_string,
|
|
||||||
is_collapsed,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
' '
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IndexMut<usize> for FileTreeItems {
|
impl DrawableComponent for FileTreeComponent {
|
||||||
fn index_mut(&mut self, idx: usize) -> &mut Self::Output {
|
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, r: Rect) {
|
||||||
&mut self.0[idx]
|
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);
|
||||||
|
|
||||||
impl Index<usize> for FileTreeItems {
|
if !visible && index_above_select {
|
||||||
type Output = FileTreeItem;
|
acc + 1
|
||||||
|
} else {
|
||||||
fn index(&self, idx: usize) -> &Self::Output {
|
acc
|
||||||
&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(&[
|
let items =
|
||||||
"file.txt", //
|
self.tree.tree.items().iter().enumerate().filter_map(
|
||||||
"file2.txt", //
|
|(idx, e)| {
|
||||||
]);
|
Self::item_to_text(
|
||||||
|
e,
|
||||||
|
r.width,
|
||||||
|
self.show_selection
|
||||||
|
&& self
|
||||||
|
.tree
|
||||||
|
.selection
|
||||||
|
.map_or(false, |e| e == idx),
|
||||||
|
self.theme,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
let res = FileTreeItems::new(&items, &BTreeSet::new());
|
ui::draw_list(
|
||||||
|
f,
|
||||||
assert_eq!(res.0.len(), 2);
|
r,
|
||||||
assert_eq!(res.0[1].info.path, items[1].path);
|
&self.title.to_string(),
|
||||||
}
|
items,
|
||||||
|
self.tree.selection.map(|idx| idx - selection_offset),
|
||||||
#[test]
|
self.focused,
|
||||||
fn test_folder() {
|
self.theme,
|
||||||
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_indent_folder_file_name() {
|
|
||||||
let items = string_vec_to_status(&[
|
|
||||||
"a/b", //
|
|
||||||
"a.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((0, "a.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()
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_find_parent() {
|
|
||||||
//0 a/
|
|
||||||
//1 b/
|
|
||||||
//2 c
|
|
||||||
//3 d
|
|
||||||
|
|
||||||
let res = FileTreeItems::new(
|
|
||||||
&string_vec_to_status(&[
|
|
||||||
"a/b/c", //
|
|
||||||
"a/b/d", //
|
|
||||||
]),
|
|
||||||
&BTreeSet::new(),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
res.find_parent_index(&String::from("a/b/c"), 3),
|
|
||||||
1
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Component for FileTreeComponent {
|
||||||
|
fn commands(
|
||||||
|
&self,
|
||||||
|
out: &mut Vec<CommandInfo>,
|
||||||
|
force_all: bool,
|
||||||
|
) -> CommandBlocking {
|
||||||
|
out.push(CommandInfo::new(
|
||||||
|
commands::NAVIGATE_TREE,
|
||||||
|
!self.is_empty(),
|
||||||
|
self.focused || force_all,
|
||||||
|
));
|
||||||
|
|
||||||
|
CommandBlocking::PassingOn
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event(&mut self, ev: Event) -> bool {
|
||||||
|
if self.focused {
|
||||||
|
if let Event::Key(e) = ev {
|
||||||
|
return match e {
|
||||||
|
keys::MOVE_DOWN => {
|
||||||
|
self.move_selection(MoveSelection::Down)
|
||||||
|
}
|
||||||
|
keys::MOVE_UP => {
|
||||||
|
self.move_selection(MoveSelection::Up)
|
||||||
|
}
|
||||||
|
keys::HOME | keys::SHIFT_UP => {
|
||||||
|
self.move_selection(MoveSelection::Home)
|
||||||
|
}
|
||||||
|
keys::END | keys::SHIFT_DOWN => {
|
||||||
|
self.move_selection(MoveSelection::End)
|
||||||
|
}
|
||||||
|
keys::MOVE_LEFT => {
|
||||||
|
self.move_selection(MoveSelection::Left)
|
||||||
|
}
|
||||||
|
keys::MOVE_RIGHT => {
|
||||||
|
self.move_selection(MoveSelection::Right)
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn focused(&self) -> bool {
|
||||||
|
self.focused
|
||||||
|
}
|
||||||
|
fn focus(&mut self, focus: bool) {
|
||||||
|
self.focused = focus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,16 @@ mod filetree;
|
||||||
mod help;
|
mod help;
|
||||||
mod msg;
|
mod msg;
|
||||||
mod reset;
|
mod reset;
|
||||||
mod statustree;
|
mod utils;
|
||||||
pub use changes::ChangesComponent;
|
pub use changes::ChangesComponent;
|
||||||
pub use command::{CommandInfo, CommandText};
|
pub use command::{CommandInfo, CommandText};
|
||||||
pub use commit::CommitComponent;
|
pub use commit::CommitComponent;
|
||||||
pub use diff::DiffComponent;
|
pub use diff::DiffComponent;
|
||||||
pub use filetree::FileTreeItemKind;
|
pub use filetree::FileTreeComponent;
|
||||||
pub use help::HelpComponent;
|
pub use help::HelpComponent;
|
||||||
pub use msg::MsgComponent;
|
pub use msg::MsgComponent;
|
||||||
pub use reset::ResetComponent;
|
pub use reset::ResetComponent;
|
||||||
|
pub use utils::filetree::FileTreeItemKind;
|
||||||
|
|
||||||
use crossterm::event::Event;
|
use crossterm::event::Event;
|
||||||
use tui::{
|
use tui::{
|
||||||
|
|
|
||||||
360
src/components/utils/filetree.rs
Normal file
360
src/components/utils/filetree.rs
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
use asyncgit::StatusItem;
|
||||||
|
use std::{
|
||||||
|
collections::BTreeSet,
|
||||||
|
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 = Vec::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)
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
pub(crate) fn items(&self) -> &Vec<FileTreeItem> {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
pub(crate) fn len(&self) -> usize {
|
||||||
|
self.0.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
pub(crate) fn find_parent_index(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
index: usize,
|
||||||
|
) -> usize {
|
||||||
|
if let Some(parent_path) = Path::new(path).parent() {
|
||||||
|
let parent_path = parent_path.to_str().unwrap();
|
||||||
|
for i in (0..=index).rev() {
|
||||||
|
let item = &self.0[i];
|
||||||
|
let item_path = &item.info.full_path;
|
||||||
|
if item_path == parent_path {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_dirs<'a>(
|
||||||
|
item_path: &'a Path,
|
||||||
|
nodes: &mut Vec<FileTreeItem>,
|
||||||
|
paths_added: &mut BTreeSet<&'a Path>,
|
||||||
|
collapsed: &BTreeSet<&String>,
|
||||||
|
) {
|
||||||
|
let mut ancestors =
|
||||||
|
{ item_path.ancestors().skip(1).collect::<Vec<_>>() };
|
||||||
|
ancestors.reverse();
|
||||||
|
|
||||||
|
for c in &ancestors {
|
||||||
|
if c.parent().is_some() {
|
||||||
|
let path_string = String::from(c.to_str().unwrap());
|
||||||
|
if !paths_added.contains(c) {
|
||||||
|
paths_added.insert(c);
|
||||||
|
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_indent_folder_file_name() {
|
||||||
|
let items = string_vec_to_status(&[
|
||||||
|
"a/b", //
|
||||||
|
"a.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((0, "a.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()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_parent() {
|
||||||
|
//0 a/
|
||||||
|
//1 b/
|
||||||
|
//2 c
|
||||||
|
//3 d
|
||||||
|
|
||||||
|
let res = FileTreeItems::new(
|
||||||
|
&string_vec_to_status(&[
|
||||||
|
"a/b/c", //
|
||||||
|
"a/b/d", //
|
||||||
|
]),
|
||||||
|
&BTreeSet::new(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
res.find_parent_index(&String::from("a/b/c"), 3),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/components/utils/mod.rs
Normal file
2
src/components/utils/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod filetree;
|
||||||
|
pub mod statustree;
|
||||||
|
|
@ -41,3 +41,8 @@ pub const ENTER: KeyEvent = no_mod(KeyCode::Enter);
|
||||||
pub const STATUS_STAGE_FILE: KeyEvent = no_mod(KeyCode::Enter);
|
pub const STATUS_STAGE_FILE: KeyEvent = no_mod(KeyCode::Enter);
|
||||||
pub const STATUS_RESET_FILE: KeyEvent =
|
pub const STATUS_RESET_FILE: KeyEvent =
|
||||||
with_mod(KeyCode::Char('D'), KeyModifiers::SHIFT);
|
with_mod(KeyCode::Char('D'), KeyModifiers::SHIFT);
|
||||||
|
pub const STASHING_SAVE: KeyEvent = no_mod(KeyCode::Char('s'));
|
||||||
|
pub const STASHING_TOGGLE_UNTRACKED: KeyEvent =
|
||||||
|
no_mod(KeyCode::Char('u'));
|
||||||
|
pub const STASHING_TOGGLE_INDEX: KeyEvent =
|
||||||
|
no_mod(KeyCode::Char('i'));
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ pub static TITLE_DIFF: &str = "Diff: ";
|
||||||
pub static TITLE_INDEX: &str = "Staged Changes [2]";
|
pub static TITLE_INDEX: &str = "Staged Changes [2]";
|
||||||
|
|
||||||
pub static TAB_STATUS: &str = "Status";
|
pub static TAB_STATUS: &str = "Status";
|
||||||
|
pub static TAB_STASHING: &str = "Stashing";
|
||||||
pub static TAB_LOG: &str = "Log";
|
pub static TAB_LOG: &str = "Log";
|
||||||
pub static TAB_DIVIDER: &str = " | ";
|
pub static TAB_DIVIDER: &str = " | ";
|
||||||
|
|
||||||
|
|
@ -16,6 +17,9 @@ pub static RESET_MSG: &str = "confirm file reset?";
|
||||||
|
|
||||||
pub static HELP_TITLE: &str = "Help: all commands";
|
pub static HELP_TITLE: &str = "Help: all commands";
|
||||||
|
|
||||||
|
pub static STASHING_FILES_TITLE: &str = "Files to Stash";
|
||||||
|
pub static STASHING_OPTIONS_TITLE: &str = "Options";
|
||||||
|
|
||||||
pub mod commands {
|
pub mod commands {
|
||||||
use crate::components::CommandText;
|
use crate::components::CommandText;
|
||||||
|
|
||||||
|
|
@ -23,6 +27,7 @@ pub mod commands {
|
||||||
static CMD_GROUP_DIFF: &str = "Diff";
|
static CMD_GROUP_DIFF: &str = "Diff";
|
||||||
static CMD_GROUP_CHANGES: &str = "Changes";
|
static CMD_GROUP_CHANGES: &str = "Changes";
|
||||||
static CMD_GROUP_COMMIT: &str = "Commit";
|
static CMD_GROUP_COMMIT: &str = "Commit";
|
||||||
|
static CMD_GROUP_STASHING: &str = "Stashing";
|
||||||
|
|
||||||
///
|
///
|
||||||
pub static TOGGLE_TABS: CommandText = CommandText::new(
|
pub static TOGGLE_TABS: CommandText = CommandText::new(
|
||||||
|
|
@ -152,4 +157,25 @@ pub mod commands {
|
||||||
"resets the file in question",
|
"resets the file in question",
|
||||||
CMD_GROUP_GENERAL,
|
CMD_GROUP_GENERAL,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
///
|
||||||
|
pub static STASHING_SAVE: CommandText = CommandText::new(
|
||||||
|
"Save [s]",
|
||||||
|
"creates a new stash",
|
||||||
|
CMD_GROUP_STASHING,
|
||||||
|
);
|
||||||
|
///
|
||||||
|
pub static STASHING_TOGGLE_INDEXED: CommandText =
|
||||||
|
CommandText::new(
|
||||||
|
"Toggle Staged [i]",
|
||||||
|
"toggle including staged files into stash",
|
||||||
|
CMD_GROUP_STASHING,
|
||||||
|
);
|
||||||
|
///
|
||||||
|
pub static STASHING_TOGGLE_UNTRACKED: CommandText =
|
||||||
|
CommandText::new(
|
||||||
|
"Toggle Untracked [u]",
|
||||||
|
"toggle including untracked files into stash",
|
||||||
|
CMD_GROUP_STASHING,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
mod revlog;
|
mod revlog;
|
||||||
|
mod stashing;
|
||||||
mod status;
|
mod status;
|
||||||
|
|
||||||
pub use revlog::Revlog;
|
pub use revlog::Revlog;
|
||||||
|
pub use stashing::Stashing;
|
||||||
pub use status::Status;
|
pub use status::Status;
|
||||||
|
|
|
||||||
249
src/tabs/stashing.rs
Normal file
249
src/tabs/stashing.rs
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
use crate::{
|
||||||
|
components::{
|
||||||
|
CommandBlocking, CommandInfo, Component, DrawableComponent,
|
||||||
|
FileTreeComponent,
|
||||||
|
},
|
||||||
|
keys,
|
||||||
|
queue::{InternalEvent, NeedsUpdate, Queue},
|
||||||
|
strings,
|
||||||
|
ui::style::Theme,
|
||||||
|
};
|
||||||
|
use asyncgit::{
|
||||||
|
sync::{self, status::StatusType},
|
||||||
|
AsyncNotification, AsyncStatus2, StatusParams, CWD,
|
||||||
|
};
|
||||||
|
use crossbeam_channel::Sender;
|
||||||
|
use crossterm::event::Event;
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use strings::commands;
|
||||||
|
use tui::{
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout},
|
||||||
|
widgets::{Block, Borders, Paragraph, Text},
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Options {
|
||||||
|
stash_untracked: bool,
|
||||||
|
stash_indexed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Stashing {
|
||||||
|
visible: bool,
|
||||||
|
options: Options,
|
||||||
|
index: FileTreeComponent,
|
||||||
|
theme: Theme,
|
||||||
|
git_status: AsyncStatus2,
|
||||||
|
queue: Queue,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stashing {
|
||||||
|
///
|
||||||
|
pub fn new(
|
||||||
|
sender: &Sender<AsyncNotification>,
|
||||||
|
queue: &Queue,
|
||||||
|
theme: &Theme,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
visible: false,
|
||||||
|
options: Options {
|
||||||
|
stash_indexed: true,
|
||||||
|
stash_untracked: true,
|
||||||
|
},
|
||||||
|
index: FileTreeComponent::new(
|
||||||
|
strings::STASHING_FILES_TITLE,
|
||||||
|
true,
|
||||||
|
queue.clone(),
|
||||||
|
theme,
|
||||||
|
),
|
||||||
|
theme: *theme,
|
||||||
|
git_status: AsyncStatus2::new(sender.clone()),
|
||||||
|
queue: queue.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
pub fn update(&mut self) {
|
||||||
|
let status_type = if self.options.stash_indexed {
|
||||||
|
StatusType::Both
|
||||||
|
} else {
|
||||||
|
StatusType::WorkingDir
|
||||||
|
};
|
||||||
|
|
||||||
|
self.git_status
|
||||||
|
.fetch(StatusParams::new(
|
||||||
|
status_type,
|
||||||
|
self.options.stash_untracked,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
pub fn anything_pending(&self) -> bool {
|
||||||
|
self.git_status.is_pending()
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
pub fn update_git(&mut self, ev: AsyncNotification) {
|
||||||
|
if self.visible {
|
||||||
|
if let AsyncNotification::Status = ev {
|
||||||
|
let status = self.git_status.last().unwrap();
|
||||||
|
self.index.update(&status.items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_option_text(&self) -> Vec<Text> {
|
||||||
|
let bracket_open = Text::Raw(Cow::from("["));
|
||||||
|
let bracket_close = Text::Raw(Cow::from("]"));
|
||||||
|
let option_on =
|
||||||
|
Text::Styled(Cow::from("x"), self.theme.option(true));
|
||||||
|
|
||||||
|
let option_off =
|
||||||
|
Text::Styled(Cow::from("_"), self.theme.option(false));
|
||||||
|
|
||||||
|
vec![
|
||||||
|
bracket_open.clone(),
|
||||||
|
if self.options.stash_untracked {
|
||||||
|
option_on.clone()
|
||||||
|
} else {
|
||||||
|
option_off.clone()
|
||||||
|
},
|
||||||
|
bracket_close.clone(),
|
||||||
|
Text::Raw(Cow::from(" stash untracked\n")),
|
||||||
|
bracket_open,
|
||||||
|
if self.options.stash_indexed {
|
||||||
|
option_on.clone()
|
||||||
|
} else {
|
||||||
|
option_off.clone()
|
||||||
|
},
|
||||||
|
bracket_close,
|
||||||
|
Text::Raw(Cow::from(" stash staged")),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DrawableComponent for Stashing {
|
||||||
|
fn draw<B: tui::backend::Backend>(
|
||||||
|
&mut self,
|
||||||
|
f: &mut tui::Frame<B>,
|
||||||
|
rect: tui::layout::Rect,
|
||||||
|
) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints(
|
||||||
|
[Constraint::Min(1), Constraint::Length(22)].as_ref(),
|
||||||
|
)
|
||||||
|
.split(rect);
|
||||||
|
|
||||||
|
let right_chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints(
|
||||||
|
[Constraint::Length(4), Constraint::Min(1)].as_ref(),
|
||||||
|
)
|
||||||
|
.split(chunks[1]);
|
||||||
|
|
||||||
|
self.index.draw(f, chunks[0]);
|
||||||
|
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(self.get_option_text().iter())
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(strings::STASHING_OPTIONS_TITLE),
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Left),
|
||||||
|
right_chunks[0],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for Stashing {
|
||||||
|
fn commands(
|
||||||
|
&self,
|
||||||
|
out: &mut Vec<CommandInfo>,
|
||||||
|
force_all: bool,
|
||||||
|
) -> CommandBlocking {
|
||||||
|
self.index.commands(out, force_all);
|
||||||
|
|
||||||
|
out.push(CommandInfo::new(
|
||||||
|
commands::STASHING_SAVE,
|
||||||
|
self.visible && !self.index.is_empty(),
|
||||||
|
self.visible || force_all,
|
||||||
|
));
|
||||||
|
out.push(CommandInfo::new(
|
||||||
|
commands::STASHING_TOGGLE_INDEXED,
|
||||||
|
self.visible,
|
||||||
|
self.visible || force_all,
|
||||||
|
));
|
||||||
|
out.push(CommandInfo::new(
|
||||||
|
commands::STASHING_TOGGLE_UNTRACKED,
|
||||||
|
self.visible,
|
||||||
|
self.visible || force_all,
|
||||||
|
));
|
||||||
|
|
||||||
|
if self.visible {
|
||||||
|
CommandBlocking::Blocking
|
||||||
|
} else {
|
||||||
|
CommandBlocking::PassingOn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event(&mut self, ev: crossterm::event::Event) -> bool {
|
||||||
|
if self.visible {
|
||||||
|
let conusmed = self.index.event(ev);
|
||||||
|
|
||||||
|
if conusmed {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Event::Key(k) = ev {
|
||||||
|
return match k {
|
||||||
|
keys::STASHING_SAVE if !self.index.is_empty() => {
|
||||||
|
if sync::stash_save(
|
||||||
|
CWD,
|
||||||
|
None,
|
||||||
|
self.options.stash_untracked,
|
||||||
|
!self.options.stash_indexed,
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
self.queue.borrow_mut().push_back(
|
||||||
|
InternalEvent::Update(
|
||||||
|
NeedsUpdate::ALL,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
keys::STASHING_TOGGLE_INDEX => {
|
||||||
|
self.options.stash_indexed =
|
||||||
|
!self.options.stash_indexed;
|
||||||
|
self.update();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
keys::STASHING_TOGGLE_UNTRACKED => {
|
||||||
|
self.options.stash_untracked =
|
||||||
|
!self.options.stash_untracked;
|
||||||
|
self.update();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_visible(&self) -> bool {
|
||||||
|
self.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hide(&mut self) {
|
||||||
|
self.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show(&mut self) {
|
||||||
|
self.update();
|
||||||
|
self.visible = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -107,6 +107,14 @@ impl Theme {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn option(&self, on: bool) -> Style {
|
||||||
|
if on {
|
||||||
|
Style::default().fg(self.diff_line_add)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(self.diff_line_delete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn diff_hunk_marker(&self, selected: bool) -> Style {
|
pub fn diff_hunk_marker(&self, selected: bool) -> Style {
|
||||||
if selected {
|
if selected {
|
||||||
Style::default().bg(self.selection_bg)
|
Style::default().bg(self.selection_bg)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue