stashing support (#70)

This commit is contained in:
Stephan Dilly 2020-05-21 15:46:38 +02:00 committed by GitHub
parent 5936757538
commit 3c5e86eee9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 1297 additions and 560 deletions

1
assets/stashing.drawio Normal file

File diff suppressed because one or more lines are too long

View file

@ -8,12 +8,14 @@ mod diff;
mod error;
mod revlog;
mod status;
mod status2;
pub mod sync;
pub use crate::{
diff::{AsyncDiff, DiffParams},
revlog::AsyncLog,
status::AsyncStatus,
status2::{AsyncStatus2, StatusParams},
sync::{
diff::{DiffLine, DiffLineType, FileDiff},
status::{StatusItem, StatusItemType},

160
asyncgit/src/status2.rs Normal file
View 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(&params);
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,
)?,
})
}
}

View file

@ -6,6 +6,7 @@ mod hooks;
mod hunks;
mod logwalker;
mod reset;
mod stash;
pub mod status;
mod tags;
pub mod utils;
@ -17,6 +18,7 @@ pub use logwalker::LogWalker;
pub use reset::{
reset_stage, reset_workdir_file, reset_workdir_folder,
};
pub use stash::stash_save;
pub use tags::{get_tags, Tags};
pub use utils::{
commit, stage_add_all, stage_add_file, stage_addremoved,

124
asyncgit/src/sync/stash.rs Normal file
View 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(())
}
}

View file

@ -46,12 +46,20 @@ pub struct StatusItem {
}
///
#[derive(Copy, Clone)]
#[derive(Copy, Clone, Hash, PartialEq)]
pub enum StatusType {
///
WorkingDir,
///
Stage,
///
Both,
}
impl Default for StatusType {
fn default() -> Self {
StatusType::WorkingDir
}
}
impl Into<StatusShow> for StatusType {
@ -59,23 +67,33 @@ impl Into<StatusShow> for StatusType {
match self {
StatusType::WorkingDir => StatusShow::Workdir,
StatusType::Stage => StatusShow::Index,
StatusType::Both => StatusShow::IndexAndWorkdir,
}
}
}
///
/// TODO: migrate
pub fn get_status(
repo_path: &str,
status_type: StatusType,
) -> 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 statuses = repo.statuses(Some(
StatusOptions::default()
.show(status_type.into())
.include_untracked(true)
.include_untracked(include_untracked)
.renames_head_to_index(true)
.recurse_untracked_dirs(true),
))?;

View file

@ -8,7 +8,7 @@ use crate::{
keys,
queue::{InternalEvent, NeedsUpdate, Queue},
strings,
tabs::{Revlog, Status},
tabs::{Revlog, Stashing, Status},
ui::style::Theme,
};
use asyncgit::{sync, AsyncNotification, CWD};
@ -38,6 +38,7 @@ pub struct App {
tab: usize,
revlog: Revlog,
status_tab: Status,
stashing_tab: Stashing,
queue: Queue,
theme: Theme,
}
@ -60,6 +61,7 @@ impl App {
tab: 0,
revlog: Revlog::new(&sender, &theme),
status_tab: Status::new(&sender, &queue, &theme),
stashing_tab: Stashing::new(&sender, &queue, &theme),
queue,
theme,
}
@ -81,11 +83,13 @@ impl App {
self.draw_tabs(f, chunks_main[0]);
if self.tab == 0 {
self.status_tab.draw(f, chunks_main[1]);
} else {
self.revlog.draw(f, chunks_main[1]);
}
//TODO: macro because of generic draw call
match self.tab {
0 => self.status_tab.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(
f,
@ -142,6 +146,7 @@ impl App {
trace!("update");
self.status_tab.update();
self.revlog.update();
self.stashing_tab.update();
}
///
@ -149,6 +154,7 @@ impl App {
trace!("update_git: {:?}", ev);
self.status_tab.update_git(ev);
self.stashing_tab.update_git(ev);
match ev {
AsyncNotification::Diff => (),
@ -167,12 +173,16 @@ impl App {
pub fn any_work_pending(&self) -> bool {
self.status_tab.anything_pending()
|| self.revlog.any_work_pending()
|| self.stashing_tab.anything_pending()
}
}
// private impls
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 {
if let Event::Key(e) = ev {
@ -184,17 +194,29 @@ impl App {
false
}
fn toggle_tabs(&mut self) {
self.tab += 1;
self.tab %= 2;
fn get_tabs(&mut self) -> Vec<&mut dyn Component> {
vec![
&mut self.status_tab,
&mut self.revlog,
&mut self.stashing_tab,
]
}
if self.tab == 1 {
self.status_tab.hide();
self.revlog.show();
} else {
self.status_tab.show();
self.revlog.hide();
fn toggle_tabs(&mut self) {
let mut new_tab = self.tab + 1;
{
let tabs = self.get_tabs();
new_tab %= tabs.len();
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) {
@ -328,7 +350,11 @@ impl App {
f.render_widget(
Tabs::default()
.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())
.highlight_style(
self.theme

View file

@ -1,31 +1,26 @@
use super::{
filetree::{FileTreeItem, FileTreeItemKind},
statustree::{MoveSelection, StatusTree},
filetree::FileTreeComponent,
utils::filetree::{FileTreeItem, FileTreeItemKind},
CommandBlocking, DrawableComponent,
};
use crate::{
components::{CommandInfo, Component},
keys,
queue::{InternalEvent, NeedsUpdate, Queue, ResetItem},
strings, ui,
strings,
ui::style::Theme,
};
use asyncgit::{hash, sync, StatusItem, StatusItemType, CWD};
use asyncgit::{sync, StatusItem, StatusItemType, CWD};
use crossterm::event::Event;
use std::{borrow::Cow, convert::From, path::Path};
use std::path::Path;
use strings::commands;
use tui::{backend::Backend, layout::Rect, widgets::Text, Frame};
use tui::{backend::Backend, layout::Rect, Frame};
///
pub struct ChangesComponent {
title: String,
tree: StatusTree,
current_hash: u64,
focused: bool,
show_selection: bool,
files: FileTreeComponent,
is_working_dir: bool,
queue: Queue,
theme: Theme,
}
impl ChangesComponent {
@ -38,64 +33,40 @@ impl ChangesComponent {
theme: &Theme,
) -> Self {
Self {
title: title.to_string(),
tree: StatusTree::default(),
current_hash: 0,
focused: focus,
show_selection: focus,
files: FileTreeComponent::new(
title,
focus,
queue.clone(),
theme,
),
is_working_dir,
queue,
theme: *theme,
}
}
///
pub fn update(&mut self, list: &[StatusItem]) {
let new_hash = hash(list);
if self.current_hash != new_hash {
self.tree.update(list);
self.current_hash = new_hash;
}
self.files.update(list)
}
///
pub fn selection(&self) -> Option<FileTreeItem> {
self.tree.selected_item()
self.files.selection()
}
///
pub fn focus_select(&mut self, focus: bool) {
self.focus(focus);
self.show_selection = focus;
self.files.focus_select(focus)
}
/// returns true if list is empty
pub fn is_empty(&self) -> bool {
self.tree.is_empty()
self.files.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
}
}
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
self.files.is_file_seleted()
}
fn index_add_remove(&mut self) -> bool {
@ -147,140 +118,11 @@ impl ChangesComponent {
}
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 {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, r: Rect) {
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
}
},
);
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,
);
self.files.draw(f, r)
}
}
@ -290,46 +132,46 @@ impl Component for ChangesComponent {
out: &mut Vec<CommandInfo>,
force_all: bool,
) -> CommandBlocking {
self.files.commands(out, force_all);
let some_selection = self.selection().is_some();
if self.is_working_dir {
out.push(CommandInfo::new(
commands::STAGE_ITEM,
some_selection,
self.focused,
self.focused(),
));
out.push(CommandInfo::new(
commands::RESET_ITEM,
some_selection,
self.focused,
self.focused(),
));
} else {
out.push(CommandInfo::new(
commands::UNSTAGE_ITEM,
some_selection,
self.focused,
self.focused(),
));
out.push(
CommandInfo::new(
commands::COMMIT_OPEN,
!self.is_empty(),
self.focused || force_all,
self.focused() || force_all,
)
.order(-1),
);
}
out.push(CommandInfo::new(
commands::NAVIGATE_TREE,
!self.is_empty(),
self.focused,
));
CommandBlocking::PassingOn
}
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 {
return match e {
keys::OPEN_COMMIT
@ -356,24 +198,6 @@ impl Component for ChangesComponent {
{
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,
};
}
@ -383,9 +207,9 @@ impl Component for ChangesComponent {
}
fn focused(&self) -> bool {
self.focused
self.files.focused()
}
fn focus(&mut self, focus: bool) {
self.focused = focus
self.files.focus(focus)
}
}

View file

@ -1,360 +1,287 @@
use asyncgit::StatusItem;
use std::{
collections::BTreeSet,
convert::TryFrom,
ops::{Index, IndexMut},
path::Path,
use super::{
utils::{
filetree::{FileTreeItem, FileTreeItemKind},
statustree::{MoveSelection, StatusTree},
},
CommandBlocking, DrawableComponent,
};
/// 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)
}
}
use crate::{
components::{CommandInfo, Component},
keys,
queue::{InternalEvent, NeedsUpdate, Queue},
strings, ui,
ui::style::Theme,
};
use asyncgit::{hash, StatusItem, StatusItemType};
use crossterm::event::Event;
use std::{borrow::Cow, convert::From, path::Path};
use strings::commands;
use tui::{backend::Backend, layout::Rect, widgets::Text, Frame};
///
#[derive(Default)]
pub struct FileTreeItems(Vec<FileTreeItem>);
pub struct FileTreeComponent {
title: String,
tree: StatusTree,
current_hash: u64,
focused: bool,
show_selection: bool,
queue: Queue,
theme: Theme,
}
impl FileTreeItems {
impl FileTreeComponent {
///
pub(crate) fn new(
list: &[StatusItem],
collapsed: &BTreeSet<&String>,
pub fn new(
title: &str,
focus: bool,
queue: Queue,
theme: &Theme,
) -> Self {
let mut nodes = Vec::with_capacity(list.len());
let mut paths_added = BTreeSet::new();
Self {
title: title.to_string(),
tree: StatusTree::default(),
current_hash: 0,
focused: focus,
show_selection: focus,
queue,
theme: *theme,
}
}
for e in list {
{
let item_path = Path::new(&e.path);
///
pub fn update(&mut self, list: &[StatusItem]) {
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,
&mut nodes,
&mut paths_added,
&collapsed,
);
///
pub fn selection(&self) -> Option<FileTreeItem> {
self.tree.selected_item()
}
///
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
}
///
pub(crate) fn items(&self) -> &Vec<FileTreeItem> {
&self.0
}
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)
};
///
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;
}
}
if !item.info.visible {
return None;
}
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>(
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,
));
}
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 IndexMut<usize> for FileTreeItems {
fn index_mut(&mut self, idx: usize) -> &mut Self::Output {
&mut self.0[idx]
}
}
impl DrawableComponent for FileTreeComponent {
fn draw<B: Backend>(&mut self, f: &mut Frame<B>, r: Rect) {
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 {
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,
if !visible && index_above_select {
acc + 1
} else {
acc
}
},
kind: FileTreeItemKind::File(items[0].clone())
}]
);
);
let items = string_vec_to_status(&[
"file.txt", //
"file2.txt", //
]);
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,
)
},
);
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
ui::draw_list(
f,
r,
&self.title.to_string(),
items,
self.tree.selection.map(|idx| idx - selection_offset),
self.focused,
self.theme,
);
}
}
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
}
}

View file

@ -6,15 +6,16 @@ mod filetree;
mod help;
mod msg;
mod reset;
mod statustree;
mod utils;
pub use changes::ChangesComponent;
pub use command::{CommandInfo, CommandText};
pub use commit::CommitComponent;
pub use diff::DiffComponent;
pub use filetree::FileTreeItemKind;
pub use filetree::FileTreeComponent;
pub use help::HelpComponent;
pub use msg::MsgComponent;
pub use reset::ResetComponent;
pub use utils::filetree::FileTreeItemKind;
use crossterm::event::Event;
use tui::{

View 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
);
}
}

View file

@ -0,0 +1,2 @@
pub mod filetree;
pub mod statustree;

View file

@ -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_RESET_FILE: KeyEvent =
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'));

View file

@ -3,6 +3,7 @@ pub static TITLE_DIFF: &str = "Diff: ";
pub static TITLE_INDEX: &str = "Staged Changes [2]";
pub static TAB_STATUS: &str = "Status";
pub static TAB_STASHING: &str = "Stashing";
pub static TAB_LOG: &str = "Log";
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 STASHING_FILES_TITLE: &str = "Files to Stash";
pub static STASHING_OPTIONS_TITLE: &str = "Options";
pub mod commands {
use crate::components::CommandText;
@ -23,6 +27,7 @@ pub mod commands {
static CMD_GROUP_DIFF: &str = "Diff";
static CMD_GROUP_CHANGES: &str = "Changes";
static CMD_GROUP_COMMIT: &str = "Commit";
static CMD_GROUP_STASHING: &str = "Stashing";
///
pub static TOGGLE_TABS: CommandText = CommandText::new(
@ -152,4 +157,25 @@ pub mod commands {
"resets the file in question",
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,
);
}

View file

@ -1,5 +1,7 @@
mod revlog;
mod stashing;
mod status;
pub use revlog::Revlog;
pub use stashing::Stashing;
pub use status::Status;

249
src/tabs/stashing.rs Normal file
View 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;
}
}

View file

@ -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 {
if selected {
Style::default().bg(self.selection_bg)