diff --git a/CHANGELOG.md b/CHANGELOG.md index f96c35f0..7ec662cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +**submodules view** + +![submodules](assets/submodules.png) + +### Added +* submodules support ([#1087](https://github.com/extrawurst/gitui/issues/1087)) + ## [0.21.0] - 2021-08-17 **popup stacking** diff --git a/Makefile b/Makefile index b097d503..7e373099 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ .PHONY: debug build-release release-linux-musl test clippy clippy-pedantic install install-debug ARGS=-l -# ARGS=-l -d ~/code/git-bare-test.git +# ARGS=-l -d ~/code/extern/pbrt-v4 # ARGS=-l -d ~/code/git-bare-test.git -w ~/code/git-bare-test profile: diff --git a/assets/submodules.png b/assets/submodules.png new file mode 100644 index 00000000..5d4c559b Binary files /dev/null and b/assets/submodules.png differ diff --git a/asyncgit/src/sync/commits_info.rs b/asyncgit/src/sync/commits_info.rs index 52e715b0..14b24376 100644 --- a/asyncgit/src/sync/commits_info.rs +++ b/asyncgit/src/sync/commits_info.rs @@ -10,6 +10,12 @@ use unicode_truncate::UnicodeTruncateStr; )] pub struct CommitId(Oid); +impl Default for CommitId { + fn default() -> Self { + Self(Oid::zero()) + } +} + impl CommitId { /// create new `CommitId` pub const fn new(id: Oid) -> Self { diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 3a331b74..53778a8e 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -27,6 +27,7 @@ mod staging; mod stash; mod state; pub mod status; +mod submodules; mod tags; mod tree; pub mod utils; @@ -80,6 +81,9 @@ pub use stash::{ }; pub use state::{repo_state, RepoState}; pub use status::is_workdir_clean; +pub use submodules::{ + get_submodules, update_submodule, SubmoduleInfo, SubmoduleStatus, +}; pub use tags::{ delete_tag, get_tags, get_tags_with_metadata, CommitTags, Tag, TagWithMetadata, Tags, diff --git a/asyncgit/src/sync/submodules.rs b/asyncgit/src/sync/submodules.rs new file mode 100644 index 00000000..f81f9573 --- /dev/null +++ b/asyncgit/src/sync/submodules.rs @@ -0,0 +1,84 @@ +use std::path::PathBuf; + +use git2::SubmoduleUpdateOptions; +use scopetime::scope_time; + +use super::{repo, CommitId, RepoPath}; +use crate::{error::Result, Error}; + +pub use git2::SubmoduleStatus; + +/// +pub struct SubmoduleInfo { + /// + pub path: PathBuf, + /// + pub url: Option, + /// + pub id: Option, + /// + pub head_id: Option, + /// + pub status: SubmoduleStatus, +} + +impl SubmoduleInfo { + /// + pub fn get_repo_path( + &self, + repo_path: &RepoPath, + ) -> Result { + let repo = repo(repo_path)?; + let wd = repo.workdir().ok_or(Error::NoWorkDir)?; + + Ok(RepoPath::Path(wd.join(self.path.clone()))) + } +} + +/// +pub fn get_submodules( + repo_path: &RepoPath, +) -> Result> { + scope_time!("get_submodules"); + + let (r, repo2) = (repo(repo_path)?, repo(repo_path)?); + + let res = r + .submodules()? + .iter() + .map(|s| { + let status = repo2 + .submodule_status( + s.name().unwrap_or_default(), + git2::SubmoduleIgnore::None, + ) + .unwrap_or(SubmoduleStatus::empty()); + + SubmoduleInfo { + path: s.path().to_path_buf(), + id: s.workdir_id().map(CommitId::from), + head_id: s.head_id().map(CommitId::from), + url: s.url().map(String::from), + status, + } + }) + .collect(); + + Ok(res) +} + +/// +pub fn update_submodule( + repo_path: &RepoPath, + path: &str, +) -> Result<()> { + let repo = repo(repo_path)?; + + let mut submodule = repo.find_submodule(path)?; + + let mut options = SubmoduleUpdateOptions::new(); + + submodule.update(true, Some(&mut options))?; + + Ok(()) +} diff --git a/src/app.rs b/src/app.rs index c00c8351..c24b04b0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,7 +11,8 @@ use crate::{ MsgComponent, OptionsPopupComponent, PullComponent, PushComponent, PushTagsComponent, RenameBranchComponent, RevisionFilesPopup, SharedOptions, StashMsgComponent, - TagCommitComponent, TagListComponent, + SubmodulesListComponent, TagCommitComponent, + TagListComponent, }, input::{Input, InputEvent, InputState}, keys::{key_match, KeyConfig, SharedKeyConfig}, @@ -70,6 +71,7 @@ pub struct App { rename_branch_popup: RenameBranchComponent, select_branch_popup: BranchListComponent, options_popup: OptionsPopupComponent, + submodule_popup: SubmodulesListComponent, tags_popup: TagListComponent, cmdbar: RefCell, tab: usize, @@ -231,6 +233,11 @@ impl App { key_config.clone(), options.clone(), ), + submodule_popup: SubmodulesListComponent::new( + repo.clone(), + theme.clone(), + key_config.clone(), + ), find_file_popup: FileFindPopup::new( &queue, theme.clone(), @@ -543,6 +550,7 @@ impl App { rename_branch_popup, select_branch_popup, revision_files_popup, + submodule_popup, tags_popup, options_popup, help, @@ -567,6 +575,7 @@ impl App { external_editor_popup, tag_commit_popup, select_branch_popup, + submodule_popup, tags_popup, create_branch_popup, rename_branch_popup, @@ -775,6 +784,9 @@ impl App { InternalEvent::SelectBranch => { self.select_branch_popup.open()?; } + InternalEvent::ViewSubmodules => { + self.submodule_popup.open()?; + } InternalEvent::Tags => { self.tags_popup.open()?; } diff --git a/src/components/branchlist.rs b/src/components/branchlist.rs index b5336ebf..a23933d1 100644 --- a/src/components/branchlist.rs +++ b/src/components/branchlist.rs @@ -541,9 +541,9 @@ impl BranchListComponent { const HEAD_SYMBOL: char = '*'; const EMPTY_SYMBOL: char = ' '; const THREE_DOTS: &str = "..."; + const THREE_DOTS_LENGTH: usize = THREE_DOTS.len(); // "..." const COMMIT_HASH_LENGTH: usize = 8; const IS_HEAD_STAR_LENGTH: usize = 3; // "* " - const THREE_DOTS_LENGTH: usize = THREE_DOTS.len(); // "..." let branch_name_length: usize = width_available as usize * 40 / 100; diff --git a/src/components/mod.rs b/src/components/mod.rs index b2f1f2b3..9628684d 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -26,6 +26,7 @@ mod revision_files; mod revision_files_popup; mod stashmsg; mod status_tree; +mod submodules; mod syntax_text; mod tag_commit; mod taglist; @@ -61,6 +62,7 @@ pub use reset::ConfirmComponent; pub use revision_files::RevisionFilesComponent; pub use revision_files_popup::{FileTreeOpen, RevisionFilesPopup}; pub use stashmsg::StashMsgComponent; +pub use submodules::SubmodulesListComponent; pub use syntax_text::SyntaxTextComponent; pub use tag_commit::TagCommitComponent; pub use taglist::TagListComponent; diff --git a/src/components/submodules.rs b/src/components/submodules.rs new file mode 100644 index 00000000..017028da --- /dev/null +++ b/src/components/submodules.rs @@ -0,0 +1,402 @@ +use super::{ + utils::scroll_vertical::VerticalScroll, visibility_blocking, + CommandBlocking, CommandInfo, Component, DrawableComponent, + EventState, ScrollType, +}; +use crate::{ + keys::{key_match, SharedKeyConfig}, + strings, + ui::{self, Size}, +}; +use anyhow::Result; +use asyncgit::sync::{get_submodules, RepoPathRef, SubmoduleInfo}; +use crossterm::event::Event; +use std::{cell::Cell, convert::TryInto}; +use tui::{ + backend::Backend, + layout::{ + Alignment, Constraint, Direction, Layout, Margin, Rect, + }, + text::{Span, Spans, Text}, + widgets::{Block, BorderType, Borders, Clear, Paragraph}, + Frame, +}; +use ui::style::SharedTheme; +use unicode_truncate::UnicodeTruncateStr; + +/// +pub struct SubmodulesListComponent { + repo: RepoPathRef, + submodules: Vec, + visible: bool, + current_height: Cell, + selection: u16, + scroll: VerticalScroll, + theme: SharedTheme, + key_config: SharedKeyConfig, +} + +impl DrawableComponent for SubmodulesListComponent { + fn draw( + &self, + f: &mut Frame, + rect: Rect, + ) -> Result<()> { + if self.is_visible() { + const PERCENT_SIZE: Size = Size::new(80, 80); + const MIN_SIZE: Size = Size::new(60, 30); + + let area = ui::centered_rect( + PERCENT_SIZE.width, + PERCENT_SIZE.height, + rect, + ); + let area = ui::rect_inside(MIN_SIZE, rect.into(), area); + let area = area.intersection(rect); + + f.render_widget(Clear, area); + + f.render_widget( + Block::default() + .title(strings::POPUP_TITLE_SUBMODULES) + .border_type(BorderType::Thick) + .borders(Borders::ALL), + area, + ); + + let area = area.inner(&Margin { + vertical: 1, + horizontal: 1, + }); + + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints( + [Constraint::Min(40), Constraint::Length(40)] + .as_ref(), + ) + .split(area); + + self.draw_list(f, chunks[0])?; + self.draw_info(f, chunks[1]); + } + + Ok(()) + } +} + +impl Component for SubmodulesListComponent { + fn commands( + &self, + out: &mut Vec, + force_all: bool, + ) -> CommandBlocking { + if self.visible || force_all { + if !force_all { + out.clear(); + } + + out.push(CommandInfo::new( + strings::commands::scroll(&self.key_config), + true, + true, + )); + + out.push(CommandInfo::new( + strings::commands::close_popup(&self.key_config), + true, + true, + )); + } + visibility_blocking(self) + } + + fn event(&mut self, ev: &Event) -> Result { + if !self.visible { + return Ok(EventState::NotConsumed); + } + + if let Event::Key(e) = ev { + if key_match(e, self.key_config.keys.exit_popup) { + self.hide(); + } else if key_match(e, self.key_config.keys.move_down) { + return self + .move_selection(ScrollType::Up) + .map(Into::into); + } else if key_match(e, self.key_config.keys.move_up) { + return self + .move_selection(ScrollType::Down) + .map(Into::into); + } else if key_match(e, self.key_config.keys.page_down) { + return self + .move_selection(ScrollType::PageDown) + .map(Into::into); + } else if key_match(e, self.key_config.keys.page_up) { + return self + .move_selection(ScrollType::PageUp) + .map(Into::into); + } else if key_match(e, self.key_config.keys.home) { + return self + .move_selection(ScrollType::Home) + .map(Into::into); + } else if key_match(e, self.key_config.keys.end) { + return self + .move_selection(ScrollType::End) + .map(Into::into); + } else if key_match( + e, + self.key_config.keys.cmd_bar_toggle, + ) { + //do not consume if its the more key + return Ok(EventState::NotConsumed); + } + } + + Ok(EventState::Consumed) + } + + fn is_visible(&self) -> bool { + self.visible + } + + fn hide(&mut self) { + self.visible = false; + } + + fn show(&mut self) -> Result<()> { + self.visible = true; + + Ok(()) + } +} + +impl SubmodulesListComponent { + pub fn new( + repo: RepoPathRef, + theme: SharedTheme, + key_config: SharedKeyConfig, + ) -> Self { + Self { + submodules: Vec::new(), + scroll: VerticalScroll::new(), + selection: 0, + visible: false, + theme, + key_config, + current_height: Cell::new(0), + repo, + } + } + + /// + pub fn open(&mut self) -> Result<()> { + self.show()?; + self.update_submodules()?; + + Ok(()) + } + + /// + pub fn update_submodules(&mut self) -> Result<()> { + if self.is_visible() { + self.submodules = get_submodules(&self.repo.borrow())?; + + self.set_selection(self.selection)?; + } + Ok(()) + } + + fn selected_entry(&self) -> Option<&SubmoduleInfo> { + self.submodules.get(self.selection as usize) + } + + //TODO: dedup this almost identical with BranchListComponent + fn move_selection(&mut self, scroll: ScrollType) -> Result { + let new_selection = match scroll { + ScrollType::Up => self.selection.saturating_add(1), + ScrollType::Down => self.selection.saturating_sub(1), + ScrollType::PageDown => self + .selection + .saturating_add(self.current_height.get()), + ScrollType::PageUp => self + .selection + .saturating_sub(self.current_height.get()), + ScrollType::Home => 0, + ScrollType::End => { + let count: u16 = self.submodules.len().try_into()?; + count.saturating_sub(1) + } + }; + + self.set_selection(new_selection)?; + + Ok(true) + } + + fn set_selection(&mut self, selection: u16) -> Result<()> { + let num_branches: u16 = self.submodules.len().try_into()?; + let num_branches = num_branches.saturating_sub(1); + + let selection = if selection > num_branches { + num_branches + } else { + selection + }; + + self.selection = selection; + + Ok(()) + } + + fn get_text( + &self, + theme: &SharedTheme, + width_available: u16, + height: usize, + ) -> Text { + const THREE_DOTS: &str = "..."; + const THREE_DOTS_LENGTH: usize = THREE_DOTS.len(); // "..." + const COMMIT_HASH_LENGTH: usize = 8; + + let mut txt = Vec::with_capacity(3); + + let name_length: usize = (width_available as usize) + .saturating_sub(COMMIT_HASH_LENGTH) + .saturating_sub(THREE_DOTS_LENGTH); + + for (i, submodule) in self + .submodules + .iter() + .skip(self.scroll.get_top()) + .take(height) + .enumerate() + { + let mut module_path = submodule + .path + .as_os_str() + .to_string_lossy() + .to_string(); + + if module_path.len() > name_length { + module_path.unicode_truncate( + name_length.saturating_sub(THREE_DOTS_LENGTH), + ); + module_path += THREE_DOTS; + } + + let selected = (self.selection as usize + - self.scroll.get_top()) + == i; + + let span_hash = Span::styled( + format!( + "{} ", + submodule + .head_id + .unwrap_or_default() + .get_short_string() + ), + theme.commit_hash(selected), + ); + + let span_name = Span::styled( + format!("{:w$} ", module_path, w = name_length), + theme.text(true, selected), + ); + + txt.push(Spans::from(vec![span_name, span_hash])); + } + + Text::from(txt) + } + + fn get_info_text(&self, theme: &SharedTheme) -> Text { + self.selected_entry().map_or_else( + Text::default, + |submodule| { + let span_title_path = + Span::styled("Path:", theme.text(false, false)); + let span_path = Span::styled( + submodule.path.to_string_lossy(), + theme.text(true, false), + ); + + let span_title_commit = + Span::styled("Commit:", theme.text(false, false)); + let span_commit = Span::styled( + submodule.id.unwrap_or_default().to_string(), + theme.commit_hash(false), + ); + + let span_title_url = + Span::styled("Url:", theme.text(false, false)); + let span_url = Span::styled( + submodule.url.clone().unwrap_or_default(), + theme.text(true, false), + ); + + let span_title_status = + Span::styled("Status:", theme.text(false, false)); + let span_status = Span::styled( + format!("{:?}", submodule.status), + theme.text(true, false), + ); + + Text::from(vec![ + Spans::from(vec![span_title_path]), + Spans::from(vec![span_path]), + Spans::from(vec![]), + Spans::from(vec![span_title_commit]), + Spans::from(vec![span_commit]), + Spans::from(vec![]), + Spans::from(vec![span_title_url]), + Spans::from(vec![span_url]), + Spans::from(vec![]), + Spans::from(vec![span_title_status]), + Spans::from(vec![span_status]), + ]) + }, + ) + } + + fn draw_list( + &self, + f: &mut Frame, + r: Rect, + ) -> Result<()> { + let height_in_lines = r.height as usize; + self.current_height.set(height_in_lines.try_into()?); + + self.scroll.update( + self.selection as usize, + self.submodules.len(), + height_in_lines, + ); + + f.render_widget( + Paragraph::new(self.get_text( + &self.theme, + r.width, + height_in_lines, + )) + .alignment(Alignment::Left), + r, + ); + + let mut r = r; + r.height += 2; + r.y = r.y.saturating_sub(1); + + self.scroll.draw(f, r, &self.theme); + + Ok(()) + } + + fn draw_info(&self, f: &mut Frame, r: Rect) { + f.render_widget( + Paragraph::new(self.get_info_text(&self.theme)) + .alignment(Alignment::Left), + r, + ); + } +} diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index 0af9cf7c..cc0f8282 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -107,6 +107,7 @@ pub struct KeysList { pub undo_commit: GituiKeyEvent, pub stage_unstage_item: GituiKeyEvent, pub tag_annotate: GituiKeyEvent, + pub view_submodules: GituiKeyEvent, } #[rustfmt::skip] @@ -185,6 +186,8 @@ impl Default for KeysList { file_find: GituiKeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()), stage_unstage_item: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), tag_annotate: GituiKeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), + view_submodules: GituiKeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT), + } } } diff --git a/src/keys/key_list_file.rs b/src/keys/key_list_file.rs index 7b2a3bbb..0213f7ad 100644 --- a/src/keys/key_list_file.rs +++ b/src/keys/key_list_file.rs @@ -79,6 +79,7 @@ pub struct KeysListFile { pub undo_commit: Option, pub stage_unstage_item: Option, pub tag_annotate: Option, + pub view_submodules: Option, } impl KeysListFile { @@ -166,6 +167,7 @@ impl KeysListFile { undo_commit: self.undo_commit.unwrap_or(default.undo_commit), stage_unstage_item: self.stage_unstage_item.unwrap_or(default.stage_unstage_item), tag_annotate: self.tag_annotate.unwrap_or(default.tag_annotate), + view_submodules: self.view_submodules.unwrap_or(default.view_submodules), } } } diff --git a/src/queue.rs b/src/queue.rs index 3c404de8..13952d5c 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -122,6 +122,8 @@ pub enum InternalEvent { PopupStackPop, /// PopupStackPush(StackablePopupOpen), + /// + ViewSubmodules, } /// single threaded simple queue for components to communicate with each other diff --git a/src/strings.rs b/src/strings.rs index 7d9d59a6..9d92a655 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -23,6 +23,8 @@ pub static PUSH_TAGS_STATES_FETCHING: &str = "fetching"; pub static PUSH_TAGS_STATES_PUSHING: &str = "pushing"; pub static PUSH_TAGS_STATES_DONE: &str = "done"; +pub static POPUP_TITLE_SUBMODULES: &str = "Submodules"; + pub mod symbol { pub const WHITESPACE: &str = "\u{00B7}"; //· pub const CHECKMARK: &str = "\u{2713}"; //✓ @@ -700,6 +702,19 @@ pub mod commands { ) } + pub fn view_submodules( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Submodules [{}]", + key_config.get_hint(key_config.keys.view_submodules), + ), + "open submodule view", + CMD_GROUP_GENERAL, + ) + } + pub fn continue_rebase( key_config: &SharedKeyConfig, ) -> CommandText { diff --git a/src/tabs/status.rs b/src/tabs/status.rs index 00cea28d..622912c4 100644 --- a/src/tabs/status.rs +++ b/src/tabs/status.rs @@ -781,6 +781,12 @@ impl Component for Status { true, self.pending_revert() || force_all, )); + + out.push(CommandInfo::new( + strings::commands::view_submodules(&self.key_config), + true, + true, + )); } { @@ -936,6 +942,12 @@ impl Component for Status { NeedsUpdate::ALL, )); Ok(EventState::Consumed) + } else if key_match( + k, + self.key_config.keys.view_submodules, + ) { + self.queue.push(InternalEvent::ViewSubmodules); + Ok(EventState::Consumed) } else { Ok(EventState::NotConsumed) };