From 3c5e86eee9e282bfb058adb89435ec5763ca89de Mon Sep 17 00:00:00 2001 From: Stephan Dilly Date: Thu, 21 May 2020 15:46:38 +0200 Subject: [PATCH] stashing support (#70) --- assets/stashing.drawio | 1 + asyncgit/src/lib.rs | 2 + asyncgit/src/status2.rs | 160 +++++++ asyncgit/src/sync/mod.rs | 2 + asyncgit/src/sync/stash.rs | 124 +++++ asyncgit/src/sync/status.rs | 26 +- src/app.rs | 60 ++- src/components/changes.rs | 240 ++-------- src/components/filetree.rs | 585 ++++++++++------------- src/components/mod.rs | 5 +- src/components/utils/filetree.rs | 360 ++++++++++++++ src/components/utils/mod.rs | 2 + src/components/{ => utils}/statustree.rs | 0 src/keys.rs | 5 + src/strings.rs | 26 + src/tabs/mod.rs | 2 + src/tabs/stashing.rs | 249 ++++++++++ src/ui/style.rs | 8 + 18 files changed, 1297 insertions(+), 560 deletions(-) create mode 100644 assets/stashing.drawio create mode 100644 asyncgit/src/status2.rs create mode 100644 asyncgit/src/sync/stash.rs create mode 100644 src/components/utils/filetree.rs create mode 100644 src/components/utils/mod.rs rename src/components/{ => utils}/statustree.rs (100%) create mode 100644 src/tabs/stashing.rs diff --git a/assets/stashing.drawio b/assets/stashing.drawio new file mode 100644 index 00000000..f6bea89e --- /dev/null +++ b/assets/stashing.drawio @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/asyncgit/src/lib.rs b/asyncgit/src/lib.rs index 96214f5b..f4da2ff2 100644 --- a/asyncgit/src/lib.rs +++ b/asyncgit/src/lib.rs @@ -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}, diff --git a/asyncgit/src/status2.rs b/asyncgit/src/status2.rs new file mode 100644 index 00000000..3f4ae5e8 --- /dev/null +++ b/asyncgit/src/status2.rs @@ -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, +} + +/// +#[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, Option); + +///TODO: merge functionality with AsyncStatus +pub struct AsyncStatus2 { + current: Arc>>, + last: Arc>, + sender: Sender, + pending: Arc, +} + +impl AsyncStatus2 { + /// + pub fn new(sender: Sender) -> 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 { + 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> { + 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>>, + arc_last: Arc>, + ) -> 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 { + Ok(Status2 { + items: sync::status::get_status_new( + CWD, + status_type, + include_untracked, + )?, + }) + } +} diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index ab9ce899..52c5ab9c 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -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, diff --git a/asyncgit/src/sync/stash.rs b/asyncgit/src/sync/stash.rs new file mode 100644 index 00000000..0fde76a7 --- /dev/null +++ b/asyncgit/src/sync/stash.rs @@ -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); + +/// +pub fn get_stashes(repo_path: &str) -> Result { + 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(()) + } +} diff --git a/asyncgit/src/sync/status.rs b/asyncgit/src/sync/status.rs index ba3f50b0..250491c4 100644 --- a/asyncgit/src/sync/status.rs +++ b/asyncgit/src/sync/status.rs @@ -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 for StatusType { @@ -59,23 +67,33 @@ impl Into 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> { - 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> { + 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), ))?; diff --git a/src/app.rs b/src/app.rs index 5c18c64f..8ba3f1ea 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 diff --git a/src/components/changes.rs b/src/components/changes.rs index ee27c07d..7a0c6a9a 100644 --- a/src/components/changes.rs +++ b/src/components/changes.rs @@ -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 { - 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 { - 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) -> 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(&mut self, f: &mut Frame, 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, 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) } } diff --git a/src/components/filetree.rs b/src/components/filetree.rs index 46923f2d..d2ffd6c2 100644 --- a/src/components/filetree.rs +++ b/src/components/filetree.rs @@ -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 { - 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); +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 { + 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 { - &self.0 - } + fn item_to_text( + item: &FileTreeItem, + width: u16, + selected: bool, + theme: Theme, + ) -> Option { + 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, - paths_added: &mut BTreeSet<&'a Path>, - collapsed: &BTreeSet<&String>, - ) { - let mut ancestors = - { item_path.ancestors().skip(1).collect::>() }; - 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) -> char { + if let Some(item_type) = item_type { + match item_type { + StatusItemType::Modified => 'M', + StatusItemType::New => '+', + StatusItemType::Deleted => '-', + StatusItemType::Renamed => 'R', + _ => ' ', } + } else { + ' ' } } } -impl IndexMut for FileTreeItems { - fn index_mut(&mut self, idx: usize) -> &mut Self::Output { - &mut self.0[idx] - } -} +impl DrawableComponent for FileTreeComponent { + fn draw(&mut self, f: &mut Frame, 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 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 { - items - .iter() - .map(|a| StatusItem { - path: String::from(*a), - status: None, - }) - .collect::>() - } - - #[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::>(); - - 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::>(); - - 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, + 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 + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 5af5e525..6a066571 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -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::{ diff --git a/src/components/utils/filetree.rs b/src/components/utils/filetree.rs new file mode 100644 index 00000000..46923f2d --- /dev/null +++ b/src/components/utils/filetree.rs @@ -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 { + 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); + +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 { + &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, + paths_added: &mut BTreeSet<&'a Path>, + collapsed: &BTreeSet<&String>, + ) { + let mut ancestors = + { item_path.ancestors().skip(1).collect::>() }; + 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 for FileTreeItems { + fn index_mut(&mut self, idx: usize) -> &mut Self::Output { + &mut self.0[idx] + } +} + +impl Index 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 { + items + .iter() + .map(|a| StatusItem { + path: String::from(*a), + status: None, + }) + .collect::>() + } + + #[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::>(); + + 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::>(); + + 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 + ); + } +} diff --git a/src/components/utils/mod.rs b/src/components/utils/mod.rs new file mode 100644 index 00000000..606de183 --- /dev/null +++ b/src/components/utils/mod.rs @@ -0,0 +1,2 @@ +pub mod filetree; +pub mod statustree; diff --git a/src/components/statustree.rs b/src/components/utils/statustree.rs similarity index 100% rename from src/components/statustree.rs rename to src/components/utils/statustree.rs diff --git a/src/keys.rs b/src/keys.rs index 914a5e8c..6aa589ed 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -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')); diff --git a/src/strings.rs b/src/strings.rs index a94db3d5..8cca32c4 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -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, + ); } diff --git a/src/tabs/mod.rs b/src/tabs/mod.rs index c2054113..991fbb88 100644 --- a/src/tabs/mod.rs +++ b/src/tabs/mod.rs @@ -1,5 +1,7 @@ mod revlog; +mod stashing; mod status; pub use revlog::Revlog; +pub use stashing::Stashing; pub use status::Status; diff --git a/src/tabs/stashing.rs b/src/tabs/stashing.rs new file mode 100644 index 00000000..914dc43f --- /dev/null +++ b/src/tabs/stashing.rs @@ -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, + 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 { + 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( + &mut self, + f: &mut tui::Frame, + 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, + 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; + } +} diff --git a/src/ui/style.rs b/src/ui/style.rs index 30f00ebc..85969e98 100644 --- a/src/ui/style.rs +++ b/src/ui/style.rs @@ -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)