Add popup for tags

This closes #483.
This commit is contained in:
Christoph Rüßler 2021-05-13 14:11:51 +02:00 committed by Stephan Dilly
parent bea7edf90e
commit 2ed6f53dcf
13 changed files with 599 additions and 7 deletions

View file

@ -68,6 +68,14 @@ impl AsyncLog {
Ok(list[min..max].to_vec())
}
///
pub fn position(&self, id: CommitId) -> Result<Option<usize>> {
let list = self.current.lock()?;
let position = list.iter().position(|&x| x == id);
Ok(position)
}
///
pub fn is_pending(&self) -> bool {
self.pending.load(Ordering::Relaxed)

View file

@ -64,7 +64,10 @@ pub use stash::{
get_stashes, stash_apply, stash_drop, stash_pop, stash_save,
};
pub use state::{repo_state, RepoState};
pub use tags::{get_tags, CommitTags, Tags};
pub use tags::{
delete_tag, get_tags, get_tags_with_metadata, CommitTags,
TagWithMetadata, Tags,
};
pub use tree::{tree_file_content, tree_files, TreeFile};
pub use utils::{
get_head, get_head_tuple, is_bare_repo, is_repo, repo_dir,

View file

@ -1,13 +1,29 @@
use super::{utils::repo, CommitId};
use super::{get_commits_info, utils::repo, CommitId};
use crate::error::Result;
use scopetime::scope_time;
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashMap, HashSet};
/// all tags pointing to a single commit
pub type CommitTags = Vec<String>;
/// hashmap of tag target commit hash to tag names
pub type Tags = BTreeMap<CommitId, CommitTags>;
///
pub struct TagWithMetadata {
///
pub name: String,
///
pub author: String,
///
pub time: i64,
///
pub message: String,
///
pub commit_id: CommitId,
}
static MAX_MESSAGE_WIDTH: usize = 100;
/// returns `Tags` type filled with all tags found in repo
pub fn get_tags(repo_path: &str) -> Result<Tags> {
scope_time!("get_tags");
@ -31,8 +47,12 @@ pub fn get_tags(repo_path: &str) -> Result<Tags> {
//NOTE: find_tag (git_tag_lookup) only works on annotated tags
// lightweight tags `id` already points to the target commit
// see https://github.com/libgit2/libgit2/issues/5586
if let Ok(tag) = repo.find_tag(id) {
adder(CommitId::new(tag.target_id()), name);
if let Ok(commit) = repo
.find_tag(id)
.and_then(|tag| tag.target())
.and_then(|target| target.peel_to_commit())
{
adder(CommitId::new(commit.id()), name);
} else if repo.find_commit(id).is_ok() {
adder(CommitId::new(id), name);
}
@ -45,6 +65,69 @@ pub fn get_tags(repo_path: &str) -> Result<Tags> {
Ok(res)
}
///
pub fn get_tags_with_metadata(
repo_path: &str,
) -> Result<Vec<TagWithMetadata>> {
scope_time!("get_tags_with_metadata");
let tags_grouped_by_commit_id = get_tags(repo_path)?;
let tags_with_commit_id: Vec<(&str, &CommitId)> =
tags_grouped_by_commit_id
.iter()
.flat_map(|(commit_id, tags)| {
tags.iter()
.map(|tag| (tag.as_ref(), commit_id))
.collect::<Vec<(&str, &CommitId)>>()
})
.collect();
let unique_commit_ids: HashSet<_> = tags_with_commit_id
.iter()
.copied()
.map(|(_, &commit_id)| commit_id)
.collect();
let mut commit_ids = Vec::with_capacity(unique_commit_ids.len());
commit_ids.extend(unique_commit_ids);
let commit_infos =
get_commits_info(repo_path, &commit_ids, MAX_MESSAGE_WIDTH)?;
let unique_commit_infos: HashMap<_, _> = commit_infos
.iter()
.map(|commit_info| (commit_info.id, commit_info))
.collect();
let mut tags: Vec<TagWithMetadata> = tags_with_commit_id
.into_iter()
.filter_map(|(tag, commit_id)| {
unique_commit_infos.get(commit_id).map(|commit_info| {
TagWithMetadata {
name: String::from(tag),
author: commit_info.author.clone(),
time: commit_info.time,
message: commit_info.message.clone(),
commit_id: *commit_id,
}
})
})
.collect();
tags.sort_unstable_by(|a, b| b.time.cmp(&a.time));
Ok(tags)
}
///
pub fn delete_tag(repo_path: &str, tag_name: &str) -> Result<()> {
scope_time!("delete_tag");
let repo = repo(repo_path)?;
repo.tag_delete(tag_name)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@ -82,5 +165,26 @@ mod tests {
get_tags(repo_path).unwrap()[&CommitId::new(head_id)],
vec!["a", "b"]
);
let tags = get_tags_with_metadata(repo_path).unwrap();
assert_eq!(tags.len(), 2);
assert_eq!(tags[0].name, "a");
assert_eq!(tags[0].message, "initial");
assert_eq!(tags[1].name, "b");
assert_eq!(tags[1].message, "initial");
assert_eq!(tags[0].commit_id, tags[1].commit_id);
delete_tag(repo_path, "a").unwrap();
let tags = get_tags(repo_path).unwrap();
assert_eq!(tags.len(), 1);
delete_tag(repo_path, "b").unwrap();
let tags = get_tags(repo_path).unwrap();
assert_eq!(tags.len(), 0);
}
}

View file

@ -9,7 +9,7 @@ use crate::{
InspectCommitComponent, MsgComponent, PullComponent,
PushComponent, PushTagsComponent, RenameBranchComponent,
ResetComponent, RevisionFilesComponent, StashMsgComponent,
TagCommitComponent,
TagCommitComponent, TagListComponent,
},
input::{Input, InputEvent, InputState},
keys::{KeyConfig, SharedKeyConfig},
@ -54,6 +54,7 @@ pub struct App {
create_branch_popup: CreateBranchComponent,
rename_branch_popup: RenameBranchComponent,
select_branch_popup: BranchListComponent,
tags_popup: TagListComponent,
cmdbar: RefCell<CommandBar>,
tab: usize,
revlog: Revlog,
@ -162,6 +163,11 @@ impl App {
theme.clone(),
key_config.clone(),
),
tags_popup: TagListComponent::new(
&queue,
theme.clone(),
key_config.clone(),
),
do_quit: false,
cmdbar: RefCell::new(CommandBar::new(
theme.clone(),
@ -397,6 +403,7 @@ impl App {
rename_branch_popup,
select_branch_popup,
revision_files_popup,
tags_popup,
help,
revlog,
status_tab,
@ -550,11 +557,26 @@ impl App {
InternalEvent::SelectBranch => {
self.select_branch_popup.open()?;
}
InternalEvent::Tags => {
self.tags_popup.open()?;
}
InternalEvent::TabSwitch => self.set_tab(0)?,
InternalEvent::InspectCommit(id, tags) => {
self.inspect_commit_popup.open(id, tags)?;
flags.insert(NeedsUpdate::ALL | NeedsUpdate::COMMANDS)
}
InternalEvent::SelectCommitInRevlog(id) => {
if let Err(error) = self.revlog.select_commit(id) {
self.queue.borrow_mut().push_back(
InternalEvent::ShowErrorMsg(
error.to_string(),
),
)
} else {
self.tags_popup.hide();
flags.insert(NeedsUpdate::ALL)
}
}
InternalEvent::OpenExternalEditor(path) => {
self.input.set_polling(false);
self.external_editor_popup.show()?;
@ -620,6 +642,18 @@ impl App {
self.select_branch_popup.update_branches()?;
}
}
Action::DeleteTag(tag_name) => {
if let Err(error) = sync::delete_tag(CWD, &tag_name) {
self.queue.borrow_mut().push_back(
InternalEvent::ShowErrorMsg(
error.to_string(),
),
)
} else {
flags.insert(NeedsUpdate::ALL);
self.tags_popup.update_tags()?;
}
}
Action::ForcePush(branch, force) => self
.queue
.borrow_mut()
@ -696,6 +730,7 @@ impl App {
|| self.push_tags_popup.is_visible()
|| self.pull_popup.is_visible()
|| self.select_branch_popup.is_visible()
|| self.tags_popup.is_visible()
|| self.rename_branch_popup.is_visible()
|| self.revision_files_popup.is_visible()
}
@ -723,6 +758,7 @@ impl App {
self.external_editor_popup.draw(f, size)?;
self.tag_commit_popup.draw(f, size)?;
self.select_branch_popup.draw(f, size)?;
self.tags_popup.draw(f, size)?;
self.create_branch_popup.draw(f, size)?;
self.rename_branch_popup.draw(f, size)?;
self.revision_files_popup.draw(f, size)?;

View file

@ -287,6 +287,10 @@ impl CommitList {
fn relative_selection(&self) -> usize {
self.selection.saturating_sub(self.items.index_offset())
}
pub fn select_entry(&mut self, position: usize) {
self.selection = position;
}
}
impl DrawableComponent for CommitList {

View file

@ -22,6 +22,7 @@ mod revision_files;
mod stashmsg;
mod syntax_text;
mod tag_commit;
mod taglist;
mod textinput;
mod utils;
@ -48,6 +49,7 @@ pub use revision_files::RevisionFilesComponent;
pub use stashmsg::StashMsgComponent;
pub use syntax_text::SyntaxTextComponent;
pub use tag_commit::TagCommitComponent;
pub use taglist::TagListComponent;
pub use textinput::{InputType, TextInputComponent};
pub use utils::filetree::FileTreeItemKind;

View file

@ -168,6 +168,15 @@ impl ResetComponent {
branch_ref,
),
),
Action::DeleteTag(tag_name) => (
strings::confirm_title_delete_tag(
&self.key_config,
),
strings::confirm_msg_delete_tag(
&self.key_config,
tag_name,
),
),
Action::ForcePush(branch, _force) => (
strings::confirm_title_force_push(
&self.key_config,

339
src/components/taglist.rs Normal file
View file

@ -0,0 +1,339 @@
use super::{
utils, visibility_blocking, CommandBlocking, CommandInfo,
Component, DrawableComponent, EventState,
};
use crate::{
components::ScrollType,
keys::SharedKeyConfig,
queue::{Action, InternalEvent, Queue},
strings,
ui::{self, Size},
};
use anyhow::Result;
use asyncgit::{
sync::{get_tags_with_metadata, TagWithMetadata},
CWD,
};
use crossterm::event::Event;
use std::convert::TryInto;
use tui::{
backend::Backend,
layout::{Constraint, Margin, Rect},
text::Span,
widgets::{
Block, BorderType, Borders, Cell, Clear, Row, Table,
TableState,
},
Frame,
};
use ui::style::SharedTheme;
///
pub struct TagListComponent {
theme: SharedTheme,
queue: Queue,
tags: Option<Vec<TagWithMetadata>>,
visible: bool,
table_state: std::cell::Cell<TableState>,
current_height: std::cell::Cell<usize>,
key_config: SharedKeyConfig,
}
impl DrawableComponent for TagListComponent {
fn draw<B: Backend>(
&self,
f: &mut Frame<B>,
rect: Rect,
) -> Result<()> {
if self.visible {
const PERCENT_SIZE: Size = Size::new(80, 50);
const MIN_SIZE: Size = Size::new(60, 20);
let area = ui::centered_rect(
PERCENT_SIZE.width,
PERCENT_SIZE.height,
f.size(),
);
let area =
ui::rect_inside(MIN_SIZE, f.size().into(), area);
let area = area.intersection(rect);
let tag_name_width =
self.tags.as_ref().map_or(0, |tags| {
tags.iter()
.fold(0, |acc, tag| acc.max(tag.name.len()))
});
let constraints = [
// tag name
Constraint::Length(tag_name_width.try_into()?),
// commit date
Constraint::Length(10),
// author width
Constraint::Length(19),
// commit id
Constraint::Min(0),
];
let rows = self.get_rows();
let number_of_rows = rows.len();
let table = Table::new(rows)
.widths(&constraints)
.column_spacing(1)
.highlight_style(self.theme.text(true, true))
.block(
Block::default()
.borders(Borders::ALL)
.title(Span::styled(
strings::title_tags(),
self.theme.title(true),
))
.border_style(self.theme.block(true))
.border_type(BorderType::Thick),
);
let mut table_state = self.table_state.take();
f.render_widget(Clear, area);
f.render_stateful_widget(table, area, &mut table_state);
let area = area.inner(&Margin {
vertical: 1,
horizontal: 0,
});
ui::draw_scrollbar(
f,
area,
&self.theme,
number_of_rows,
table_state.selected().unwrap_or(0),
);
self.table_state.set(table_state);
self.current_height.set(area.height.into());
}
Ok(())
}
}
impl Component for TagListComponent {
fn commands(
&self,
out: &mut Vec<CommandInfo>,
force_all: bool,
) -> CommandBlocking {
if self.visible || 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,
));
out.push(CommandInfo::new(
strings::commands::delete_tag_popup(&self.key_config),
self.valid_selection(),
true,
));
out.push(CommandInfo::new(
strings::commands::select_tag(&self.key_config),
self.valid_selection(),
true,
));
}
visibility_blocking(self)
}
fn event(&mut self, event: Event) -> Result<EventState> {
if self.visible {
if let Event::Key(key) = event {
if key == self.key_config.exit_popup {
self.hide()
} else if key == self.key_config.move_up {
self.move_selection(ScrollType::Up);
} else if key == self.key_config.move_down {
self.move_selection(ScrollType::Down);
} else if key == self.key_config.shift_up
|| key == self.key_config.home
{
self.move_selection(ScrollType::Home);
} else if key == self.key_config.shift_down
|| key == self.key_config.end
{
self.move_selection(ScrollType::End);
} else if key == self.key_config.page_down {
self.move_selection(ScrollType::PageDown);
} else if key == self.key_config.page_up {
self.move_selection(ScrollType::PageUp);
} else if key == self.key_config.delete_tag {
return self.selected_tag().map_or(
Ok(EventState::NotConsumed),
|tag| {
self.queue.borrow_mut().push_back(
InternalEvent::ConfirmAction(
Action::DeleteTag(
tag.name.clone(),
),
),
);
Ok(EventState::Consumed)
},
);
} else if key == self.key_config.select_tag {
return self.selected_tag().map_or(
Ok(EventState::NotConsumed),
|tag| {
self.queue.borrow_mut().push_back(
InternalEvent::SelectCommitInRevlog(
tag.commit_id,
),
);
Ok(EventState::Consumed)
},
);
}
}
Ok(EventState::Consumed)
} else {
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;
Ok(())
}
}
impl TagListComponent {
pub fn new(
queue: &Queue,
theme: SharedTheme,
key_config: SharedKeyConfig,
) -> Self {
Self {
theme,
queue: queue.clone(),
tags: None,
visible: false,
table_state: std::cell::Cell::new(TableState::default()),
current_height: std::cell::Cell::new(0),
key_config,
}
}
///
pub fn open(&mut self) -> Result<()> {
self.table_state.get_mut().select(Some(0));
self.show()?;
self.update_tags()?;
Ok(())
}
/// fetch list of tags
pub fn update_tags(&mut self) -> Result<()> {
let tags = get_tags_with_metadata(CWD)?;
self.tags = Some(tags);
Ok(())
}
///
fn move_selection(&mut self, scroll_type: ScrollType) -> bool {
let mut table_state = self.table_state.take();
let old_selection = table_state.selected().unwrap_or(0);
let max_selection =
self.tags.as_ref().map_or(0, |tags| tags.len() - 1);
let new_selection = match scroll_type {
ScrollType::Up => old_selection.saturating_sub(1),
ScrollType::Down => {
old_selection.saturating_add(1).min(max_selection)
}
ScrollType::Home => 0,
ScrollType::End => max_selection,
ScrollType::PageUp => old_selection.saturating_sub(
self.current_height.get().saturating_sub(1),
),
ScrollType::PageDown => old_selection
.saturating_add(
self.current_height.get().saturating_sub(1),
)
.min(max_selection),
};
let needs_update = new_selection != old_selection;
table_state.select(Some(new_selection));
self.table_state.set(table_state);
needs_update
}
///
fn get_rows(&self) -> Vec<Row> {
if let Some(ref tags) = self.tags {
tags.iter().map(|tag| self.get_row(tag)).collect()
} else {
vec![]
}
}
///
fn get_row(&self, tag: &TagWithMetadata) -> Row {
let cells: Vec<Cell> = vec![
Cell::from(tag.name.clone())
.style(self.theme.text(true, false)),
Cell::from(utils::time_to_string(tag.time, true))
.style(self.theme.commit_time(false)),
Cell::from(tag.author.clone())
.style(self.theme.commit_author(false)),
Cell::from(tag.message.clone())
.style(self.theme.text(true, false)),
];
Row::new(cells)
}
fn valid_selection(&self) -> bool {
self.selected_tag().is_some()
}
fn selected_tag(&self) -> Option<&TagWithMetadata> {
self.tags.as_ref().and_then(|tags| {
let table_state = self.table_state.take();
let tag = table_state
.selected()
.and_then(|selected| tags.get(selected));
self.table_state.set(table_state);
tag
})
}
}

View file

@ -72,6 +72,9 @@ pub struct KeyConfig {
pub select_branch: KeyEvent,
pub delete_branch: KeyEvent,
pub merge_branch: KeyEvent,
pub tags: KeyEvent,
pub delete_tag: KeyEvent,
pub select_tag: KeyEvent,
pub push: KeyEvent,
pub open_file_tree: KeyEvent,
pub force_push: KeyEvent,
@ -134,6 +137,9 @@ impl Default for KeyConfig {
select_branch: KeyEvent { code: KeyCode::Char('b'), modifiers: KeyModifiers::empty()},
delete_branch: KeyEvent { code: KeyCode::Char('D'), modifiers: KeyModifiers::SHIFT},
merge_branch: KeyEvent { code: KeyCode::Char('m'), modifiers: KeyModifiers::empty()},
tags: KeyEvent { code: KeyCode::Char('T'), modifiers: KeyModifiers::SHIFT},
delete_tag: KeyEvent { code: KeyCode::Char('D'), modifiers: KeyModifiers::SHIFT},
select_tag: KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::empty()},
push: KeyEvent { code: KeyCode::Char('p'), modifiers: KeyModifiers::empty()},
force_push: KeyEvent { code: KeyCode::Char('P'), modifiers: KeyModifiers::SHIFT},
pull: KeyEvent { code: KeyCode::Char('f'), modifiers: KeyModifiers::empty()},

View file

@ -33,6 +33,7 @@ pub enum Action {
StashDrop(CommitId),
StashPop(CommitId),
DeleteBranch(String),
DeleteTag(String),
ForcePush(String, bool),
PullMerge { incoming: usize, rebase: bool },
AbortMerge,
@ -59,8 +60,12 @@ pub enum InternalEvent {
///
InspectCommit(CommitId, Option<CommitTags>),
///
SelectCommitInRevlog(CommitId),
///
TagCommit(CommitId),
///
Tags,
///
BlameFile(String),
///
CreateBranch,

View file

@ -23,6 +23,9 @@ pub static PUSH_TAGS_STATES_DONE: &str = "done";
pub fn title_branches() -> String {
"Branches".to_string()
}
pub fn title_tags() -> String {
"Tags".to_string()
}
pub fn title_status(_key_config: &SharedKeyConfig) -> String {
"Unstaged Changes".to_string()
}
@ -165,6 +168,17 @@ pub fn confirm_msg_delete_branch(
) -> String {
format!("Confirm deleting branch: '{}' ?", branch_ref)
}
pub fn confirm_title_delete_tag(
_key_config: &SharedKeyConfig,
) -> String {
"Delete Tag".to_string()
}
pub fn confirm_msg_delete_tag(
_key_config: &SharedKeyConfig,
tag_name: &str,
) -> String {
format!("Confirm deleting Tag: '{}' ?", tag_name)
}
pub fn confirm_title_force_push(
_key_config: &SharedKeyConfig,
) -> String {
@ -999,6 +1013,41 @@ pub mod commands {
)
}
pub fn open_tags_popup(
key_config: &SharedKeyConfig,
) -> CommandText {
CommandText::new(
format!(
"Tags [{}]",
key_config.get_hint(key_config.tags),
),
"open tags popup",
CMD_GROUP_GENERAL,
)
}
pub fn delete_tag_popup(
key_config: &SharedKeyConfig,
) -> CommandText {
CommandText::new(
format!(
"Delete [{}]",
key_config.get_hint(key_config.delete_tag),
),
"delete a tag",
CMD_GROUP_GENERAL,
)
}
pub fn select_tag(key_config: &SharedKeyConfig) -> CommandText {
CommandText::new(
format!(
"Select commit [{}]",
key_config.get_hint(key_config.select_tag),
),
"Select commit in revlog",
CMD_GROUP_GENERAL,
)
}
pub fn status_push(key_config: &SharedKeyConfig) -> CommandText {
CommandText::new(
format!(

View file

@ -166,6 +166,18 @@ impl Revlog {
tags.and_then(|tags| tags.get(&commit).cloned())
})
}
pub fn select_commit(&mut self, id: CommitId) -> Result<()> {
let position = self.git_log.position(id)?;
if let Some(position) = position {
self.list.select_entry(position);
Ok(())
} else {
anyhow::bail!("Could not select commit in revlog. It might not be loaded yet or it might be on a different branch.");
}
}
}
impl DrawableComponent for Revlog {
@ -259,6 +271,11 @@ impl Component for Revlog {
Ok(EventState::Consumed)
},
);
} else if k == self.key_config.tags {
self.queue
.borrow_mut()
.push_back(InternalEvent::Tags);
return Ok(EventState::Consumed);
}
}
}
@ -302,6 +319,12 @@ impl Component for Revlog {
self.visible || force_all,
));
out.push(CommandInfo::new(
strings::commands::open_tags_popup(&self.key_config),
true,
self.visible || force_all,
));
out.push(CommandInfo::new(
strings::commands::copy_hash(&self.key_config),
self.selected_commit().is_some(),

View file

@ -56,7 +56,7 @@
status_stage_all: ( code: Char('a'), modifiers: ( bits: 0,),),
status_reset_item: ( code: Char('U'), modifiers: ( bits: 1,),),
status_ignore_file: ( code: Char('i'), modifiers: ( bits: 0,),),
diff_reset_lines: ( code: Char('u'), modifiers: ( bits: 0,),),
diff_stage_lines: ( code: Char('s'), modifiers: ( bits: 0,),),
@ -79,6 +79,10 @@
merge_branch: ( code: Char('m'), modifiers: ( bits: 0,),),
abort_merge: ( code: Char('M'), modifiers: ( bits: 1,),),
tags: ( code: Char('T'), modifiers: ( bits: 1,),),
delete_tag: ( code: Char('D'), modifiers: ( bits: 1,),),
select_tag: ( code: Enter, modifiers: ( bits: 0,),),
push: ( code: Char('p'), modifiers: ( bits: 0,),),
force_push: ( code: Char('P'), modifiers: ( bits: 1,),),
pull: ( code: Char('f'), modifiers: ( bits: 0,),),