diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 07a955a885..266a9b1b5a 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -31,6 +31,7 @@ mod submodules; mod tags; mod tree; pub mod utils; +mod worktree; pub use blame::{blame_file, BlameHunk, FileBlame}; pub use branch::{ @@ -96,6 +97,10 @@ pub use utils::{ stage_add_file, stage_addremoved, Head, }; +pub use worktree::{ + create_worktree, find_worktree, prune_worktree, + toggle_worktree_lock, worktrees, WorkTree, +}; pub use git2::ResetType; #[cfg(test)] diff --git a/asyncgit/src/sync/utils.rs b/asyncgit/src/sync/utils.rs index fff6feccc9..c7ded93e3a 100644 --- a/asyncgit/src/sync/utils.rs +++ b/asyncgit/src/sync/utils.rs @@ -14,7 +14,6 @@ use std::{ io::Write, path::{Path, PathBuf}, }; - /// #[derive(PartialEq, Eq, Debug, Clone)] pub struct Head { @@ -48,6 +47,13 @@ pub fn repo_dir(repo_path: &RepoPath) -> Result { /// pub fn repo_work_dir(repo_path: &RepoPath) -> Result { let repo = repo(repo_path)?; + + // TODO: Is this safe? + // Allow Bare repositories + if repo.is_bare() { + return Ok(repo_path.gitpath().to_str().unwrap().to_string()); + }; + work_dir(&repo)?.to_str().map_or_else( || Err(Error::Generic("invalid workdir".to_string())), |workdir| Ok(workdir.to_string()), diff --git a/asyncgit/src/sync/worktree.rs b/asyncgit/src/sync/worktree.rs new file mode 100644 index 0000000000..7431384876 --- /dev/null +++ b/asyncgit/src/sync/worktree.rs @@ -0,0 +1,150 @@ +use crate::error::Result; +use crate::sync::repository::repo; +use git2::{WorktreeLockStatus, WorktreePruneOptions}; +use scopetime::scope_time; +use std::path::{Path, PathBuf}; + +use super::RepoPath; + +/// Represents a worktree +#[derive(Debug)] +pub struct WorkTree { + /// Worktree name (wich is also the folder i think) + pub name: String, + // Worktree branch name + // pub branch: String, + /// Is the worktree valid + pub is_valid: bool, + /// Worktree path + pub path: PathBuf, + /// Is worktree locked + pub is_locked: bool, + /// Can worktree be pruned + pub is_prunable: bool, + /// Is worktree the current worktree + pub is_current: bool, +} + +/// Get all worktrees +pub fn worktrees(repo_path: &RepoPath) -> Result> { + scope_time!("worktrees"); + + let repo_obj = repo(repo_path)?; + + Ok(repo_obj + .worktrees()? + .iter() + .filter_map(|s| { + if s.is_none() { + log::error!("Error getting worktree: {:?}", s); + }; + s + }) + .map(|s| { + let wt = repo_obj.find_worktree(s)?; + Ok(WorkTree { + name: s.to_string(), + // branch: worktree_branch(s.unwrap(), &repo_obj).unwrap(), + is_valid: wt.validate().is_ok(), + path: wt.path().to_path_buf(), + is_locked: match wt.is_locked()? { + WorktreeLockStatus::Unlocked => false, + WorktreeLockStatus::Locked(_) => true, + }, + is_prunable: wt.is_prunable(None)?, + is_current: wt.path().canonicalize()? + == repo_path.gitpath().canonicalize()?, + }) + }) + .filter_map(|s: Result| { + if s.is_err() { + log::error!("Error getting worktree: {:?}", s); + } + s.ok() + }) + .collect()) +} + +/// Find a worktree path +pub fn find_worktree( + repo_path: &RepoPath, + name: &str, +) -> Result { + scope_time!("find_worktree"); + + let repo_obj = repo(repo_path)?; + + let wt = repo_obj.find_worktree(name)?; + wt.validate()?; + + Ok(RepoPath::Path(wt.path().to_path_buf())) +} + +/// Create worktree +pub fn create_worktree( + repo_path: &RepoPath, + name: &str, +) -> Result<()> { + scope_time!("create_worktree"); + + let repo_obj = repo(repo_path)?; + let path_str = repo_path.gitpath().to_str().unwrap(); + + // WARNING: + // if we are in a worktree assume we want to create a worktree in the parent directory + // This is not always accurate but it should work in most cases + let real_path = match repo_obj.is_worktree() { + true => format!("{}/../{}", path_str, &name), + false => format!("{}{}", path_str, &name), + }; + + log::trace!("creating worktree in {:?}", real_path); + repo_obj.worktree(name, &Path::new(&real_path), None)?; + + Ok(()) +} + +/// Prune a worktree +pub fn prune_worktree( + repo_path: &RepoPath, + name: &str, + force: bool, +) -> Result<()> { + scope_time!("prune_worktree"); + + let repo_obj = repo(repo_path)?; + + let wt = repo_obj.find_worktree(name)?; + wt.is_prunable(None)?; + + wt.prune(Some( + WorktreePruneOptions::new() + .valid(force) + .locked(force) + .working_tree(force), + ))?; + + Ok(()) +} + +/// Toggle lock on a worktree +pub fn toggle_worktree_lock( + repo_path: &RepoPath, + name: &str, +) -> Result<()> { + scope_time!("toggle_lock_worktree"); + + let repo_obj = repo(repo_path)?; + + let wt = repo_obj.find_worktree(name)?; + + // fails to create is branch already exists + wt.validate()?; + + match wt.is_locked().unwrap() { + WorktreeLockStatus::Unlocked => wt.lock(None)?, + WorktreeLockStatus::Locked(_) => wt.unlock()?, + } + + Ok(()) +} diff --git a/src/app.rs b/src/app.rs index 98ebd348dd..e55c3e34d6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,8 @@ use crate::{ event_pump, AppOption, BlameFileComponent, BranchListComponent, CommandBlocking, CommandInfo, CommitComponent, CompareCommitsComponent, Component, - ConfirmComponent, CreateBranchComponent, DrawableComponent, + ConfirmComponent, CreateBranchComponent, + CreateWorktreeComponent, DrawableComponent, ExternalEditorComponent, FetchComponent, FileFindPopup, FileRevlogComponent, HelpComponent, InspectCommitComponent, MsgComponent, OptionsPopupComponent, PullComponent, @@ -23,13 +24,18 @@ use crate::{ }, setup_popups, strings::{self, ellipsis_trim_start, order}, - tabs::{FilesTab, Revlog, StashList, Stashing, Status}, + tabs::{ + FilesTab, Revlog, StashList, Stashing, Status, WorkTreesTab, + }, ui::style::{SharedTheme, Theme}, AsyncAppNotification, AsyncNotification, }; use anyhow::{bail, Result}; use asyncgit::{ - sync::{self, utils::repo_work_dir, RepoPath, RepoPathRef}, + sync::{ + self, find_worktree, prune_worktree, toggle_worktree_lock, + utils::repo_work_dir, RepoPath, RepoPathRef, + }, AsyncGitNotification, PushType, }; use crossbeam_channel::Sender; @@ -79,6 +85,7 @@ pub struct App { fetch_popup: FetchComponent, tag_commit_popup: TagCommitComponent, create_branch_popup: CreateBranchComponent, + create_worktree_popup: CreateWorktreeComponent, rename_branch_popup: RenameBranchComponent, select_branch_popup: BranchListComponent, options_popup: OptionsPopupComponent, @@ -92,6 +99,7 @@ pub struct App { stashing_tab: Stashing, stashlist_tab: StashList, files_tab: FilesTab, + worktrees_tab: WorkTreesTab, queue: Queue, theme: SharedTheme, key_config: SharedKeyConfig, @@ -122,6 +130,7 @@ impl App { let repo_path_text = repo_work_dir(&repo.borrow()).unwrap_or_default(); + log::trace!("repo path: {}", repo_path_text); let queue = Queue::new(); let theme = Rc::new(theme); let key_config = Rc::new(key_config); @@ -237,6 +246,12 @@ impl App { theme.clone(), key_config.clone(), ), + create_worktree_popup: CreateWorktreeComponent::new( + repo.clone(), + queue.clone(), + theme.clone(), + key_config.clone(), + ), rename_branch_popup: RenameBranchComponent::new( repo.clone(), queue.clone(), @@ -319,6 +334,12 @@ impl App { theme.clone(), key_config.clone(), ), + worktrees_tab: WorkTreesTab::new( + repo.clone(), + theme.clone(), + key_config.clone(), + &queue, + ), tab: 0, queue, theme, @@ -375,6 +396,7 @@ impl App { 2 => self.files_tab.draw(f, chunks_main[1])?, 3 => self.stashing_tab.draw(f, chunks_main[1])?, 4 => self.stashlist_tab.draw(f, chunks_main[1])?, + 5 => self.worktrees_tab.draw(f, chunks_main[1])?, _ => bail!("unknown tab"), }; } @@ -427,6 +449,9 @@ impl App { ) || key_match( k, self.key_config.keys.tab_stashes, + ) || key_match( + k, + self.key_config.keys.tab_worktrees, ) { self.switch_tab(k)?; NeedsUpdate::COMMANDS @@ -491,6 +516,7 @@ impl App { self.files_tab.update()?; self.stashing_tab.update()?; self.stashlist_tab.update()?; + self.worktrees_tab.update()?; self.reset_popup.update()?; self.update_commands(); @@ -593,6 +619,7 @@ impl App { fetch_popup, tag_commit_popup, create_branch_popup, + create_worktree_popup, rename_branch_popup, select_branch_popup, revision_files_popup, @@ -605,7 +632,8 @@ impl App { status_tab, files_tab, stashing_tab, - stashlist_tab + stashlist_tab, + worktrees_tab ] ); @@ -626,6 +654,7 @@ impl App { tags_popup, reset_popup, create_branch_popup, + create_worktree_popup, rename_branch_popup, revision_files_popup, find_file_popup, @@ -669,6 +698,7 @@ impl App { &mut self.files_tab, &mut self.stashing_tab, &mut self.stashlist_tab, + &mut self.worktrees_tab, ] } @@ -694,6 +724,8 @@ impl App { self.set_tab(3)?; } else if key_match(k, self.key_config.keys.tab_stashes) { self.set_tab(4)?; + } else if key_match(k, self.key_config.keys.tab_worktrees) { + self.set_tab(5)?; } Ok(()) @@ -744,6 +776,9 @@ impl App { if flags.contains(NeedsUpdate::BRANCHES) { self.select_branch_popup.update_branches()?; } + if flags.contains(NeedsUpdate::WORKTREES) { + self.worktrees_tab.update_worktrees()?; + } Ok(()) } @@ -826,6 +861,9 @@ impl App { InternalEvent::CreateBranch => { self.create_branch_popup.open()?; } + InternalEvent::CreateWorktree => { + self.create_worktree_popup.open()?; + } InternalEvent::RenameBranch(branch_ref, cur_name) => { self.rename_branch_popup .open(branch_ref, cur_name)?; @@ -936,6 +974,40 @@ impl App { self.do_quit = QuitState::OpenSubmodule(submodule_repo_path); } + InternalEvent::OpenWorktree(name) => { + let wt = find_worktree(&self.repo.borrow(), &name); + + match wt { + Ok(wt) => { + self.do_quit = QuitState::OpenSubmodule(wt); + } + Err(e) => { + self.queue.push(InternalEvent::ShowErrorMsg( + e.to_string(), + )); + } + } + } + InternalEvent::PruneWorktree(name) => { + if let Err(e) = + prune_worktree(&self.repo.borrow(), &name, false) + { + self.queue.push(InternalEvent::ShowErrorMsg( + e.to_string(), + )); + } + self.worktrees_tab.update()?; + } + InternalEvent::ToggleWorktreeLock(name) => { + if let Err(e) = + toggle_worktree_lock(&self.repo.borrow(), &name) + { + self.queue.push(InternalEvent::ShowErrorMsg( + e.to_string(), + )); + } + self.worktrees_tab.update()?; + } InternalEvent::OpenResetPopup(id) => { self.reset_popup.open(id)?; } @@ -1062,6 +1134,16 @@ impl App { self.status_tab.abort_rebase(); flags.insert(NeedsUpdate::ALL); } + Action::ForcePruneWorktree(name) => { + if let Err(e) = + prune_worktree(&self.repo.borrow(), &name, true) + { + self.queue.push(InternalEvent::ShowErrorMsg( + e.to_string(), + )); + } + self.worktrees_tab.update()?; + } }; Ok(()) @@ -1144,6 +1226,7 @@ impl App { Span::raw(strings::tab_files(&self.key_config)), Span::raw(strings::tab_stashing(&self.key_config)), Span::raw(strings::tab_stashes(&self.key_config)), + Span::raw(strings::tab_worktrees(&self.key_config)), ]; let divider = strings::tab_divider(&self.key_config); diff --git a/src/components/create_worktree.rs b/src/components/create_worktree.rs new file mode 100644 index 0000000000..abced02cda --- /dev/null +++ b/src/components/create_worktree.rs @@ -0,0 +1,144 @@ +use super::{ + textinput::TextInputComponent, visibility_blocking, + CommandBlocking, CommandInfo, Component, DrawableComponent, + EventState, +}; +use crate::{ + keys::{key_match, SharedKeyConfig}, + queue::{InternalEvent, NeedsUpdate, Queue}, + strings, + ui::style::SharedTheme, +}; +use anyhow::Result; +use asyncgit::sync::{self, RepoPathRef}; +use crossterm::event::Event; +use tui::{backend::Backend, layout::Rect, Frame}; + +pub struct CreateWorktreeComponent { + repo: RepoPathRef, + queue: Queue, + input: TextInputComponent, + key_config: SharedKeyConfig, +} + +impl DrawableComponent for CreateWorktreeComponent { + fn draw( + &self, + f: &mut Frame, + rect: Rect, + ) -> Result<()> { + if self.is_visible() { + self.input.draw(f, rect)?; + } + + Ok(()) + } +} +impl Component for CreateWorktreeComponent { + fn commands( + &self, + out: &mut Vec, + force_all: bool, + ) -> CommandBlocking { + if self.is_visible() || force_all { + self.input.commands(out, force_all); + + out.push(CommandInfo::new( + strings::commands::create_worktree_confirm_msg( + &self.key_config, + ), + true, + true, + )); + } + + visibility_blocking(self) + } + + fn event(&mut self, ev: &Event) -> Result { + if self.is_visible() { + if self.input.event(ev)?.is_consumed() { + return Ok(EventState::Consumed); + } + + if let Event::Key(e) = ev { + if key_match(e, self.key_config.keys.enter) { + self.create_worktree(); + } + + return Ok(EventState::Consumed); + } + } + Ok(EventState::NotConsumed) + } + + fn is_visible(&self) -> bool { + self.input.is_visible() + } + + fn hide(&mut self) { + self.input.hide(); + } + + fn show(&mut self) -> Result<()> { + self.input.show()?; + + Ok(()) + } +} + +impl CreateWorktreeComponent { + /// + pub fn new( + repo: RepoPathRef, + queue: Queue, + theme: SharedTheme, + key_config: SharedKeyConfig, + ) -> Self { + Self { + input: TextInputComponent::new( + theme.clone(), + key_config.clone(), + &strings::create_worktree_popup_title(&key_config), + &strings::create_worktree_popup_msg(&key_config), + true, + ), + queue, + key_config, + repo, + } + } + + /// + pub fn open(&mut self) -> Result<()> { + self.show()?; + + Ok(()) + } + + /// + pub fn create_worktree(&mut self) { + let res = sync::create_worktree( + &self.repo.borrow(), + self.input.get_text(), + ); + + self.input.clear(); + self.hide(); + + match res { + Ok(_) => { + self.queue.push(InternalEvent::Update( + NeedsUpdate::WORKTREES, + )); + log::trace!("Worktree created"); + } + Err(e) => { + log::trace!("Worktree creation failed: {}", e); + self.queue.push(InternalEvent::ShowErrorMsg( + format!("create worktree error:\n{e}",), + )); + } + } + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 77824de80d..84924c258a 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -7,6 +7,7 @@ mod commit_details; mod commitlist; mod compare_commits; mod create_branch; +mod create_worktree; mod cred; mod diff; mod externaleditor; @@ -33,6 +34,7 @@ mod tag_commit; mod taglist; mod textinput; mod utils; +mod worktrees; pub use self::status_tree::StatusTreeComponent; pub use blame_file::{BlameFileComponent, BlameFileOpen}; @@ -44,6 +46,7 @@ pub use commit_details::CommitDetailsComponent; pub use commitlist::CommitList; pub use compare_commits::CompareCommitsComponent; pub use create_branch::CreateBranchComponent; +pub use create_worktree::CreateWorktreeComponent; pub use diff::DiffComponent; pub use externaleditor::ExternalEditorComponent; pub use fetch::FetchComponent; @@ -68,6 +71,7 @@ pub use tag_commit::TagCommitComponent; pub use taglist::TagListComponent; pub use textinput::{InputType, TextInputComponent}; pub use utils::filetree::FileTreeItemKind; +pub use worktrees::WorkTreesComponent; use crate::ui::style::Theme; use anyhow::Result; @@ -177,7 +181,7 @@ pub fn command_pump( } } -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug)] pub enum ScrollType { Up, Down, diff --git a/src/components/reset.rs b/src/components/reset.rs index c5e9f0cde3..d9b4daeac3 100644 --- a/src/components/reset.rs +++ b/src/components/reset.rs @@ -213,6 +213,10 @@ impl ConfirmComponent { strings::confirm_title_abortrevert(), strings::confirm_msg_revertchanges(), ), + Action::ForcePruneWorktree(name) => ( + strings::confirm_title_force_prune_worktree(name), + strings::confirm_msg_force_prune_worktree(), + ), }; } diff --git a/src/components/worktrees.rs b/src/components/worktrees.rs new file mode 100644 index 0000000000..111fa658ff --- /dev/null +++ b/src/components/worktrees.rs @@ -0,0 +1,324 @@ +use anyhow::Result; +use asyncgit::sync::WorkTree; +use crossterm::event::Event; +use std::{cell::Cell, cmp, time::Instant}; +use tui::{ + backend::Backend, + layout::{Alignment, Rect}, + text::{Span, Spans}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +use crate::{ + components::{utils::string_width_align, ScrollType}, + keys::{key_match, SharedKeyConfig}, + strings::{self, symbol}, + ui::{calc_scroll_top, draw_scrollbar, style::SharedTheme}, +}; + +use super::{ + textinput::TextInputComponent, CommandBlocking, CommandInfo, + Component, DrawableComponent, EventState, +}; + +pub struct WorkTreesComponent { + title: Box, + theme: SharedTheme, + worktrees: Vec, + current_size: Cell<(u16, u16)>, + scroll_top: Cell, + selection: usize, + count_total: usize, + key_config: SharedKeyConfig, + scroll_state: (Instant, f32), + input: TextInputComponent, +} + +impl WorkTreesComponent { + /// + pub fn new( + title: &str, + theme: SharedTheme, + key_config: SharedKeyConfig, + ) -> Self { + Self { + title: title.into(), + theme: theme.clone(), + worktrees: Vec::new(), + current_size: Cell::new((0, 0)), + scroll_top: Cell::new(0), + selection: 0, + count_total: 0, + key_config: key_config.clone(), + scroll_state: (Instant::now(), 0_f32), + input: TextInputComponent::new( + theme, + key_config, + &strings::tag_popup_name_title(), + &strings::tag_popup_name_msg(), + true, + ), + } + } + + pub fn set_worktrees( + &mut self, + worktrees: Vec, + ) -> Result<()> { + self.worktrees = worktrees; + self.set_count_total(self.worktrees.len()); + Ok(()) + } + + fn get_entry_to_add( + &self, + wt: &WorkTree, + selected: bool, + width: usize, + ) -> Spans { + let mut txt = Vec::new(); + txt.push(Span::styled( + string_width_align( + match wt.is_locked { + true => symbol::LOCK, + false => "", + }, + 2, + ), + self.theme.worktree(wt.is_valid, selected), + )); + txt.push(Span::styled( + string_width_align( + match wt.is_current { + true => symbol::CHECKMARK, + false => "", + }, + 2, + ), + self.theme.worktree(wt.is_valid, selected), + )); + txt.push(Span::styled( + string_width_align(&wt.name.clone(), width), + self.theme.worktree(wt.is_valid, selected), + )); + Spans(txt) + } + + fn get_text(&self, height: usize, width: usize) -> Vec { + let mut txt: Vec = Vec::with_capacity(height); + for (idx, e) in self + .worktrees + .iter() + .skip(self.scroll_top.get()) + .take(height) + .enumerate() + { + txt.push( + self.get_entry_to_add( + e, + idx == self + .selection + .saturating_sub(self.scroll_top.get()), + width, + ), + ); + } + txt + } + + fn move_selection(&mut self, scroll: ScrollType) -> Result { + self.update_scroll_speed(); + + //#[allow(clippy::cast_possible_truncation)] + let speed_int = + usize::try_from(self.scroll_state.1 as i64)?.max(1); + + let page_offset = + usize::from(self.current_size.get().1).saturating_sub(1); + + let new_selection = match scroll { + ScrollType::Up => { + self.selection.saturating_sub(speed_int) + } + ScrollType::Down => { + self.selection.saturating_add(speed_int) + } + ScrollType::PageUp => { + self.selection.saturating_sub(page_offset) + } + ScrollType::PageDown => { + self.selection.saturating_add(page_offset) + } + ScrollType::Home => 0, + ScrollType::End => self.selection_max(), + }; + + let new_selection = + cmp::min(new_selection, self.selection_max()); + + let needs_update = new_selection != self.selection; + + self.selection = new_selection; + + Ok(needs_update) + } + + pub fn selection_max(&self) -> usize { + self.count_total.saturating_sub(1) + } + + pub fn set_count_total(&mut self, total: usize) { + self.count_total = total; + self.selection = + cmp::min(self.selection, self.selection_max()); + } + + pub fn selected_worktree(&self) -> Option<&WorkTree> { + self.worktrees.get(self.selection) + } + + fn update_scroll_speed(&mut self) { + const REPEATED_SCROLL_THRESHOLD_MILLIS: u128 = 300; + const SCROLL_SPEED_START: f32 = 0.1_f32; + const SCROLL_SPEED_MAX: f32 = 10_f32; + const SCROLL_SPEED_MULTIPLIER: f32 = 1.05_f32; + + let now = Instant::now(); + + let since_last_scroll = + now.duration_since(self.scroll_state.0); + + self.scroll_state.0 = now; + + let speed = if since_last_scroll.as_millis() + < REPEATED_SCROLL_THRESHOLD_MILLIS + { + self.scroll_state.1 * SCROLL_SPEED_MULTIPLIER + } else { + SCROLL_SPEED_START + }; + + self.scroll_state.1 = speed.min(SCROLL_SPEED_MAX); + } +} + +impl DrawableComponent for WorkTreesComponent { + fn draw( + &self, + f: &mut Frame, + area: Rect, + ) -> Result<()> { + let current_size = ( + area.width.saturating_sub(2), + area.height.saturating_sub(2), + ); + self.current_size.set(current_size); + + let height_in_lines = self.current_size.get().1 as usize; + + self.scroll_top.set(calc_scroll_top( + self.scroll_top.get(), + height_in_lines, + self.selection, + )); + + // Not sure if the count is really nessesary + let title = format!( + "{} {}/{}", + self.title, + self.selection.saturating_add(1), + self.count_total, + ); + + f.render_widget( + Paragraph::new( + self.get_text( + height_in_lines, + current_size.0 as usize, + ), + ) + .block( + Block::default() + .borders(Borders::ALL) + .title(Span::styled( + title.as_str(), + self.theme.title(true), + )) + .border_style(self.theme.block(true)), + ) + .alignment(Alignment::Left), + area, + ); + + draw_scrollbar( + f, + area, + &self.theme, + self.count_total, + self.selection, + crate::ui::Orientation::Vertical, + ); + + Ok(()) + } +} + +impl Component for WorkTreesComponent { + fn event(&mut self, ev: &Event) -> Result { + if let Event::Key(k) = ev { + let selection_changed = + if key_match(k, self.key_config.keys.move_up) { + self.move_selection(ScrollType::Up)? + } else if key_match(k, self.key_config.keys.move_down) + { + self.move_selection(ScrollType::Down)? + } else if key_match(k, self.key_config.keys.shift_up) + || key_match(k, self.key_config.keys.home) + { + self.move_selection(ScrollType::Home)? + } else if key_match( + k, + self.key_config.keys.shift_down, + ) || key_match(k, self.key_config.keys.end) + { + self.move_selection(ScrollType::End)? + } else if key_match(k, self.key_config.keys.page_up) { + self.move_selection(ScrollType::PageUp)? + } else if key_match(k, self.key_config.keys.page_down) + { + self.move_selection(ScrollType::PageDown)? + } else if key_match(k, self.key_config.keys.edit_file) + { + self.show()?; + true + } else { + false + }; + return Ok(selection_changed.into()); + } + + Ok(EventState::NotConsumed) + } + + fn commands( + &self, + out: &mut Vec, + _force_all: bool, + ) -> CommandBlocking { + out.push(CommandInfo::new( + strings::commands::scroll(&self.key_config), + true, + true, + )); + CommandBlocking::PassingOn + } + + fn show(&mut self) -> Result<()> { + self.input.set_title(strings::tag_popup_name_title()); + self.input.set_default_msg(strings::tag_popup_name_msg()); + self.input.show()?; + + Ok(()) + } +} diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index 05cbba9902..7ff2f0e746 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -41,6 +41,7 @@ pub struct KeysList { pub tab_files: GituiKeyEvent, pub tab_stashing: GituiKeyEvent, pub tab_stashes: GituiKeyEvent, + pub tab_worktrees: GituiKeyEvent, pub tab_toggle: GituiKeyEvent, pub tab_toggle_reverse: GituiKeyEvent, pub toggle_workarea: GituiKeyEvent, @@ -93,6 +94,10 @@ pub struct KeysList { pub toggle_verify: GituiKeyEvent, pub copy: GituiKeyEvent, pub create_branch: GituiKeyEvent, + pub create_worktree: GituiKeyEvent, + pub prune_worktree: GituiKeyEvent, + pub force_prune_worktree: GituiKeyEvent, + pub toggle_worktree_lock: GituiKeyEvent, pub rename_branch: GituiKeyEvent, pub select_branch: GituiKeyEvent, pub delete_branch: GituiKeyEvent, @@ -116,6 +121,7 @@ pub struct KeysList { pub view_submodule_parent: GituiKeyEvent, pub update_submodule: GituiKeyEvent, pub commit_history_next: GituiKeyEvent, + pub select_worktree: GituiKeyEvent, } #[rustfmt::skip] @@ -127,6 +133,7 @@ impl Default for KeysList { tab_files: GituiKeyEvent::new(KeyCode::Char('3'), KeyModifiers::empty()), tab_stashing: GituiKeyEvent::new(KeyCode::Char('4'), KeyModifiers::empty()), tab_stashes: GituiKeyEvent::new(KeyCode::Char('5'), KeyModifiers::empty()), + tab_worktrees: GituiKeyEvent::new(KeyCode::Char('6'), KeyModifiers::empty()), tab_toggle: GituiKeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), tab_toggle_reverse: GituiKeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT), toggle_workarea: GituiKeyEvent::new(KeyCode::Char('w'), KeyModifiers::empty()), @@ -179,6 +186,10 @@ impl Default for KeysList { toggle_verify: GituiKeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), copy: GituiKeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()), create_branch: GituiKeyEvent::new(KeyCode::Char('c'), KeyModifiers::empty()), + create_worktree: GituiKeyEvent::new(KeyCode::Char('c'), KeyModifiers::empty()), + prune_worktree: GituiKeyEvent::new(KeyCode::Char('d'), KeyModifiers::empty()), + force_prune_worktree: GituiKeyEvent::new(KeyCode::Char('D'), KeyModifiers::SHIFT), + toggle_worktree_lock: GituiKeyEvent::new(KeyCode::Char(' '), KeyModifiers::empty()), rename_branch: GituiKeyEvent::new(KeyCode::Char('r'), KeyModifiers::empty()), select_branch: GituiKeyEvent::new(KeyCode::Char('b'), KeyModifiers::empty()), delete_branch: GituiKeyEvent::new(KeyCode::Char('D'), KeyModifiers::SHIFT), @@ -202,6 +213,7 @@ impl Default for KeysList { view_submodule_parent: GituiKeyEvent::new(KeyCode::Char('p'), KeyModifiers::empty()), update_submodule: GituiKeyEvent::new(KeyCode::Char('u'), KeyModifiers::empty()), commit_history_next: GituiKeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + select_worktree: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), } } } diff --git a/src/keys/key_list_file.rs b/src/keys/key_list_file.rs index 7832a3d94a..ddafb635af 100644 --- a/src/keys/key_list_file.rs +++ b/src/keys/key_list_file.rs @@ -12,6 +12,7 @@ pub struct KeysListFile { pub tab_files: Option, pub tab_stashing: Option, pub tab_stashes: Option, + pub tab_worktrees: Option, pub tab_toggle: Option, pub tab_toggle_reverse: Option, pub toggle_workarea: Option, @@ -64,6 +65,10 @@ pub struct KeysListFile { pub toggle_verify: Option, pub copy: Option, pub create_branch: Option, + pub create_worktree: Option, + pub prune_worktree: Option, + pub force_prune_worktree: Option, + pub toggle_worktree_lock: Option, pub rename_branch: Option, pub select_branch: Option, pub delete_branch: Option, @@ -87,6 +92,7 @@ pub struct KeysListFile { pub view_submodule_parent: Option, pub update_dubmodule: Option, pub commit_history_next: Option, + pub select_worktree: Option, } impl KeysListFile { @@ -107,6 +113,7 @@ impl KeysListFile { tab_files: self.tab_files.unwrap_or(default.tab_files), tab_stashing: self.tab_stashing.unwrap_or(default.tab_stashing), tab_stashes: self.tab_stashes.unwrap_or(default.tab_stashes), + tab_worktrees: self.tab_worktrees.unwrap_or(default.tab_worktrees), tab_toggle: self.tab_toggle.unwrap_or(default.tab_toggle), tab_toggle_reverse: self.tab_toggle_reverse.unwrap_or(default.tab_toggle_reverse), toggle_workarea: self.toggle_workarea.unwrap_or(default.toggle_workarea), @@ -159,6 +166,10 @@ impl KeysListFile { toggle_verify: self.toggle_verify.unwrap_or(default.toggle_verify), copy: self.copy.unwrap_or(default.copy), create_branch: self.create_branch.unwrap_or(default.create_branch), + create_worktree: self.create_worktree.unwrap_or(default.create_worktree), + prune_worktree: self.prune_worktree.unwrap_or(default.prune_worktree), + force_prune_worktree: self.force_prune_worktree.unwrap_or(default.force_prune_worktree), + toggle_worktree_lock: self.toggle_worktree_lock.unwrap_or(default.toggle_worktree_lock), rename_branch: self.rename_branch.unwrap_or(default.rename_branch), select_branch: self.select_branch.unwrap_or(default.select_branch), delete_branch: self.delete_branch.unwrap_or(default.delete_branch), @@ -182,6 +193,7 @@ impl KeysListFile { view_submodule_parent: self.view_submodule_parent.unwrap_or(default.view_submodule_parent), update_submodule: self.update_dubmodule.unwrap_or(default.update_submodule), commit_history_next: self.commit_history_next.unwrap_or(default.commit_history_next), + select_worktree: self.select_worktree.unwrap_or(default.select_worktree), } } } diff --git a/src/queue.rs b/src/queue.rs index 6f958410e5..b1ffac39de 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -25,6 +25,8 @@ bitflags! { const COMMANDS = 0b100; /// branches have changed const BRANCHES = 0b1000; + /// worktrees have changed + const WORKTREES = 0b10000; } } @@ -52,6 +54,7 @@ pub enum Action { AbortMerge, AbortRebase, AbortRevert, + ForcePruneWorktree(String), } #[derive(Debug)] @@ -97,6 +100,12 @@ pub enum InternalEvent { /// CreateBranch, /// + CreateWorktree, + /// + PruneWorktree(String), + /// + ToggleWorktreeLock(String), + /// RenameBranch(String, String), /// SelectBranch, @@ -127,6 +136,8 @@ pub enum InternalEvent { /// OpenRepo { path: PathBuf }, /// + OpenWorktree(String), + /// OpenResetPopup(CommitId), } diff --git a/src/strings.rs b/src/strings.rs index a27a1407dd..f6c35a7662 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -36,6 +36,7 @@ pub static POPUP_SUCCESS_COPY: &str = "Copied Text"; pub mod symbol { pub const WHITESPACE: &str = "\u{00B7}"; //· pub const CHECKMARK: &str = "\u{2713}"; //✓ + pub const LOCK: &str = "\u{1F512}"; //🔒 pub const SPACE: &str = "\u{02FD}"; //˽ pub const EMPTY_SPACE: &str = " "; pub const FOLDER_ICON_COLLAPSED: &str = "\u{25b8}"; //▸ @@ -86,6 +87,12 @@ pub fn tab_stashes(key_config: &SharedKeyConfig) -> String { key_config.get_hint(key_config.keys.tab_stashes) ) } +pub fn tab_worktrees(key_config: &SharedKeyConfig) -> String { + format!( + "Worktrees [{}]", + key_config.get_hint(key_config.keys.tab_worktrees) + ) +} pub fn tab_divider(_key_config: &SharedKeyConfig) -> String { " | ".to_string() } @@ -177,6 +184,13 @@ pub fn confirm_title_abortmerge() -> String { pub fn confirm_title_abortrevert() -> String { "Abort revert?".to_string() } +pub fn confirm_title_force_prune_worktree(name: &str) -> String { + format!("Force prune worktree `{name}`?") +} +pub fn confirm_msg_force_prune_worktree() -> String { + "This will delete all uncommitted changes. Are you sure?" + .to_string() +} pub fn confirm_msg_revertchanges() -> String { "This will revert all uncommitted changes. Are you sure?" .to_string() @@ -351,6 +365,18 @@ pub fn rename_branch_popup_msg( "new branch name".to_string() } +pub fn create_worktree_popup_title( + _key_config: &SharedKeyConfig, +) -> String { + "Worktree".to_string() +} + +pub fn create_worktree_popup_msg( + _key_config: &SharedKeyConfig, +) -> String { + "type worktree name".to_string() +} + pub fn copy_success(s: &str) -> String { format!("{POPUP_SUCCESS_COPY} \"{s}\"") } @@ -426,6 +452,7 @@ pub mod commands { static CMD_GROUP_STASHES: &str = "-- Stashes --"; static CMD_GROUP_LOG: &str = "-- Log --"; static CMD_GROUP_BRANCHES: &str = "-- Branches --"; + static CMD_GROUP_WORKTREES: &str = "-- Worktrees --"; pub fn toggle_tabs(key_config: &SharedKeyConfig) -> CommandText { CommandText::new( @@ -1536,4 +1563,70 @@ pub mod commands { CMD_GROUP_BRANCHES, ) } + + pub fn open_worktree_create_popup( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Create [{}]", + key_config.get_hint(key_config.keys.create_worktree), + ), + "open create worktree popup", + CMD_GROUP_WORKTREES, + ) + } + + pub fn create_worktree_confirm_msg( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Create Worktree [{}]", + key_config.get_hint(key_config.keys.enter), + ), + "create worktree", + CMD_GROUP_WORKTREES, + ) + .hide_help() + } + + pub fn prune_worktree( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Prune [{}]", + key_config.get_hint(key_config.keys.prune_worktree), + ), + "prune worktree", + CMD_GROUP_WORKTREES, + ) + } + pub fn force_prune_worktree( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Force Prune [{}]", + key_config + .get_hint(key_config.keys.force_prune_worktree), + ), + "force prune worktree", + CMD_GROUP_WORKTREES, + ) + } + pub fn toggle_worktree_lock( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Lock/Unlock [{}]", + key_config + .get_hint(key_config.keys.toggle_worktree_lock), + ), + "toggle worktree lock", + CMD_GROUP_WORKTREES, + ) + } } diff --git a/src/tabs/mod.rs b/src/tabs/mod.rs index 6d8100f63c..8abe1a9729 100644 --- a/src/tabs/mod.rs +++ b/src/tabs/mod.rs @@ -3,9 +3,11 @@ mod revlog; mod stashing; mod stashlist; mod status; +mod worktrees; pub use files::FilesTab; pub use revlog::Revlog; pub use stashing::{Stashing, StashingOptions}; pub use stashlist::StashList; pub use status::Status; +pub use worktrees::WorkTreesTab; diff --git a/src/tabs/worktrees.rs b/src/tabs/worktrees.rs new file mode 100644 index 0000000000..aa047c7d08 --- /dev/null +++ b/src/tabs/worktrees.rs @@ -0,0 +1,180 @@ +use crate::{ + components::{ + visibility_blocking, CommandBlocking, CommandInfo, Component, + DrawableComponent, EventState, WorkTreesComponent, + }, + keys::{key_match, SharedKeyConfig}, + queue::{Action, InternalEvent, Queue}, + strings, + ui::style::SharedTheme, +}; +use anyhow::Result; +use asyncgit::sync::{worktrees, RepoPathRef, WorkTree}; +use crossterm::event::Event; + +pub struct WorkTreesTab { + repo: RepoPathRef, + visible: bool, + worktrees: WorkTreesComponent, + key_config: SharedKeyConfig, + queue: Queue, +} + +impl WorkTreesTab { + /// + pub fn new( + repo: RepoPathRef, + theme: SharedTheme, + key_config: SharedKeyConfig, + queue: &Queue, + ) -> Self { + Self { + visible: false, + worktrees: WorkTreesComponent::new( + "Worktree", + theme, + key_config.clone(), + ), + repo, + key_config, + queue: queue.clone(), + } + } + + pub fn update(&mut self) -> Result<()> { + if self.is_visible() { + self.update_worktrees()?; + } + + Ok(()) + } + + pub fn update_worktrees(&mut self) -> Result<()> { + if let Ok(worktrees) = worktrees(&self.repo.borrow()) { + self.worktrees.set_worktrees(worktrees)?; + } + + Ok(()) + } + + pub fn selected_worktree(&self) -> &WorkTree { + self.worktrees.selected_worktree().unwrap() + } +} + +impl DrawableComponent for WorkTreesTab { + fn draw( + &self, + f: &mut tui::Frame, + rect: tui::layout::Rect, + ) -> Result<()> { + if self.is_visible() { + self.worktrees.draw(f, rect)?; + } + Ok(()) + } +} + +impl Component for WorkTreesTab { + fn commands( + &self, + out: &mut Vec, + force_all: bool, + ) -> CommandBlocking { + if self.is_visible() || force_all { + out.push(CommandInfo::new( + strings::commands::open_worktree_create_popup( + &self.key_config, + ), + true, + true, + )); + out.push(CommandInfo::new( + strings::commands::prune_worktree(&self.key_config), + true, + true, + )); + out.push(CommandInfo::new( + strings::commands::force_prune_worktree( + &self.key_config, + ), + true, + true, + )); + out.push(CommandInfo::new( + strings::commands::toggle_worktree_lock( + &self.key_config, + ), + true, + true, + )); + } + visibility_blocking(self) + } + + fn event( + &mut self, + ev: &crossterm::event::Event, + ) -> Result { + if !self.visible { + return Ok(EventState::NotConsumed); + } + let event_used = self.worktrees.event(ev)?; + + if event_used.is_consumed() { + self.update()?; + return Ok(EventState::Consumed); + } else if let Event::Key(e) = ev { + if key_match(e, self.key_config.keys.select_worktree) { + self.queue.push(InternalEvent::OpenWorktree( + self.selected_worktree().name.clone(), + )); + return Ok(EventState::Consumed); + } else if key_match( + e, + self.key_config.keys.create_worktree, + ) { + self.queue.push(InternalEvent::CreateWorktree); + } else if key_match( + e, + self.key_config.keys.prune_worktree, + ) { + self.queue.push(InternalEvent::PruneWorktree( + self.selected_worktree().name.clone(), + )); + } else if key_match( + e, + self.key_config.keys.force_prune_worktree, + ) { + self.queue.push(InternalEvent::ConfirmAction( + Action::ForcePruneWorktree( + self.selected_worktree().name.clone(), + ), + )); + } else if key_match( + e, + self.key_config.keys.toggle_worktree_lock, + ) { + self.queue.push(InternalEvent::ToggleWorktreeLock( + self.selected_worktree().name.clone(), + )); + } + } + + Ok(EventState::NotConsumed) + } + + fn is_visible(&self) -> bool { + self.visible + } + + fn hide(&mut self) { + self.visible = false; + } + + fn show(&mut self) -> Result<()> { + self.visible = true; + self.update()?; + Ok(()) + } +} diff --git a/src/ui/style.rs b/src/ui/style.rs index 8fca7e7760..73a810ad7c 100644 --- a/src/ui/style.rs +++ b/src/ui/style.rs @@ -108,6 +108,16 @@ impl Theme { } } + pub fn worktree(&self, valid: bool, selected: bool) -> Style { + let style = if valid { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::Red) + }; + + self.apply_select(style, selected) + } + pub fn item(&self, typ: StatusItemType, selected: bool) -> Style { let style = match typ { StatusItemType::New => { diff --git a/src/watcher.rs b/src/watcher.rs index 01f99278a1..996f3a0f6e 100644 --- a/src/watcher.rs +++ b/src/watcher.rs @@ -66,7 +66,10 @@ impl RepoWatcher { } if !ev.is_empty() { - sender.send(()).expect("send error"); + sender.send(()).map_err(|_| { + log::debug!("notify send error"); + RecvError + })?; } } }