From 3a6f292bf5400c98b369848f38d7b07e7eb66b54 Mon Sep 17 00:00:00 2001 From: UG <55311933+UUGTech@users.noreply.github.com> Date: Sat, 22 Apr 2023 06:03:35 +0900 Subject: [PATCH] add fuzzy finder in branch list (#1658) * add branch_find_popup * capital F for fetch in branchlist, f for find * add command info of return closes #1350 --- src/app.rs | 20 +- src/components/branch_find_popup.rs | 339 ++++++++++++++++++++++++++++ src/components/branchlist.rs | 99 +++++--- src/components/file_find_popup.rs | 17 +- src/components/mod.rs | 2 + src/keys/key_list.rs | 2 + src/queue.rs | 4 + src/strings.rs | 36 ++- 8 files changed, 475 insertions(+), 44 deletions(-) create mode 100644 src/components/branch_find_popup.rs diff --git a/src/app.rs b/src/app.rs index 60db251d..26ce4647 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,7 +2,7 @@ use crate::{ accessors, cmdbar::CommandBar, components::{ - event_pump, AppOption, BlameFileComponent, + event_pump, AppOption, BlameFileComponent, BranchFindPopup, BranchListComponent, CommandBlocking, CommandInfo, CommitComponent, CompareCommitsComponent, Component, ConfirmComponent, CreateBranchComponent, DrawableComponent, @@ -73,6 +73,7 @@ pub struct App { external_editor_popup: ExternalEditorComponent, revision_files_popup: RevisionFilesPopup, find_file_popup: FileFindPopup, + branch_find_popup: BranchFindPopup, push_popup: PushComponent, push_tags_popup: PushTagsComponent, pull_popup: PullComponent, @@ -273,6 +274,11 @@ impl App { theme.clone(), key_config.clone(), ), + branch_find_popup: BranchFindPopup::new( + &queue, + theme.clone(), + key_config.clone(), + ), do_quit: QuitState::None, cmdbar: RefCell::new(CommandBar::new( theme.clone(), @@ -578,6 +584,7 @@ impl App { self, [ find_file_popup, + branch_find_popup, msg, reset, commit, @@ -629,6 +636,7 @@ impl App { rename_branch_popup, revision_files_popup, find_file_popup, + branch_find_popup, push_popup, push_tags_popup, pull_popup, @@ -892,6 +900,11 @@ impl App { flags .insert(NeedsUpdate::ALL | NeedsUpdate::COMMANDS); } + InternalEvent::OpenBranchFinder(branches) => { + self.branch_find_popup.open(branches)?; + flags + .insert(NeedsUpdate::ALL | NeedsUpdate::COMMANDS); + } InternalEvent::OptionSwitched(o) => { match o { AppOption::StatusShowUntracked => { @@ -912,6 +925,11 @@ impl App { flags .insert(NeedsUpdate::ALL | NeedsUpdate::COMMANDS); } + InternalEvent::BranchFinderChanged(idx) => { + self.select_branch_popup.branch_finder_update(idx)?; + flags + .insert(NeedsUpdate::ALL | NeedsUpdate::COMMANDS); + } InternalEvent::OpenPopup(popup) => { self.open_popup(popup)?; flags diff --git a/src/components/branch_find_popup.rs b/src/components/branch_find_popup.rs new file mode 100644 index 00000000..71dd0a01 --- /dev/null +++ b/src/components/branch_find_popup.rs @@ -0,0 +1,339 @@ +use super::{ + visibility_blocking, CommandBlocking, CommandInfo, Component, + DrawableComponent, EventState, ScrollType, TextInputComponent, +}; +use crate::{ + keys::{key_match, SharedKeyConfig}, + queue::{InternalEvent, Queue}, + string_utils::trim_length_left, + strings, + ui::{self, style::SharedTheme}, +}; +use anyhow::Result; +use crossterm::event::Event; +use fuzzy_matcher::FuzzyMatcher; +use ratatui::{ + backend::Backend, + layout::{Constraint, Direction, Layout, Margin, Rect}, + text::{Span, Spans}, + widgets::{Block, Borders, Clear}, + Frame, +}; +use std::borrow::Cow; + +pub struct BranchFindPopup { + queue: Queue, + visible: bool, + find_text: TextInputComponent, + query: Option, + theme: SharedTheme, + branches: Vec, + selection: usize, + selected_index: Option, + branches_filtered: Vec<(usize, Vec)>, + key_config: SharedKeyConfig, +} + +impl BranchFindPopup { + /// + pub fn new( + queue: &Queue, + theme: SharedTheme, + key_config: SharedKeyConfig, + ) -> Self { + let mut find_text = TextInputComponent::new( + theme.clone(), + key_config.clone(), + "", + "start typing..", + false, + ); + find_text.embed(); + + Self { + queue: queue.clone(), + visible: false, + query: None, + find_text, + theme, + branches: Vec::new(), + branches_filtered: Vec::new(), + selected_index: None, + key_config, + selection: 0, + } + } + + fn update_query(&mut self) { + if self.find_text.get_text().is_empty() { + self.set_query(None); + } else if self + .query + .as_ref() + .map_or(true, |q| q != self.find_text.get_text()) + { + self.set_query(Some( + self.find_text.get_text().to_string(), + )); + } + } + + fn set_query(&mut self, query: Option) { + self.query = query; + + self.branches_filtered.clear(); + + if let Some(q) = &self.query { + let matcher = + fuzzy_matcher::skim::SkimMatcherV2::default(); + + let mut branches = self + .branches + .iter() + .enumerate() + .filter_map(|a| { + matcher + .fuzzy_indices(a.1, q) + .map(|(score, indices)| (score, a.0, indices)) + }) + .collect::>(); + + branches.sort_by(|(score1, _, _), (score2, _, _)| { + score2.cmp(score1) + }); + + self.branches_filtered.extend( + branches.into_iter().map(|entry| (entry.1, entry.2)), + ); + } + + self.selection = 0; + self.refresh_selection(); + } + + fn refresh_selection(&mut self) { + let selection = + self.branches_filtered.get(self.selection).map(|a| a.0); + + if self.selected_index != selection { + self.selected_index = selection; + + let idx = self.selected_index; + self.queue.push(InternalEvent::BranchFinderChanged(idx)); + } + } + + pub fn open(&mut self, branches: Vec) -> Result<()> { + self.show()?; + self.find_text.show()?; + self.find_text.set_text(String::new()); + self.query = None; + if self.branches != branches { + self.branches = branches; + } + self.update_query(); + + Ok(()) + } + + fn move_selection(&mut self, move_type: ScrollType) -> bool { + let new_selection = match move_type { + ScrollType::Up => self.selection.saturating_sub(1), + ScrollType::Down => self.selection.saturating_add(1), + _ => self.selection, + }; + + let new_selection = new_selection + .clamp(0, self.branches_filtered.len().saturating_sub(1)); + + if new_selection != self.selection { + self.selection = new_selection; + self.refresh_selection(); + return true; + } + + false + } +} + +impl DrawableComponent for BranchFindPopup { + fn draw( + &self, + f: &mut Frame, + area: Rect, + ) -> Result<()> { + if self.is_visible() { + const MAX_SIZE: (u16, u16) = (50, 20); + + let any_hits = !self.branches_filtered.is_empty(); + + let area = ui::centered_rect_absolute( + MAX_SIZE.0, MAX_SIZE.1, area, + ); + + let area = if any_hits { + area + } else { + Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(3), + Constraint::Percentage(100), + ] + .as_ref(), + ) + .split(area)[0] + }; + + f.render_widget(Clear, area); + f.render_widget( + Block::default() + .borders(Borders::all()) + .style(self.theme.title(true)) + .title(Span::styled( + strings::POPUP_TITLE_FUZZY_FIND, + self.theme.title(true), + )), + area, + ); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(1), + Constraint::Percentage(100), + ] + .as_ref(), + ) + .split(area.inner(&Margin { + horizontal: 1, + vertical: 1, + })); + + self.find_text.draw(f, chunks[0])?; + + if any_hits { + let title = + format!("Hits: {}", self.branches_filtered.len()); + + let height = usize::from(chunks[1].height); + let width = usize::from(chunks[1].width); + + let items = self + .branches_filtered + .iter() + .take(height) + .map(|(idx, indicies)| { + let selected = self + .selected_index + .map_or(false, |index| index == *idx); + let full_text = trim_length_left( + &self.branches[*idx], + width, + ); + Spans::from( + full_text + .char_indices() + .map(|(c_idx, c)| { + Span::styled( + Cow::from(c.to_string()), + self.theme.text( + selected, + indicies.contains(&c_idx), + ), + ) + }) + .collect::>(), + ) + }); + + ui::draw_list_block( + f, + chunks[1], + Block::default() + .title(Span::styled( + title, + self.theme.title(true), + )) + .borders(Borders::TOP), + items, + ); + } + } + Ok(()) + } +} + +impl Component for BranchFindPopup { + fn commands( + &self, + out: &mut Vec, + force_all: bool, + ) -> CommandBlocking { + if self.is_visible() || force_all { + out.push(CommandInfo::new( + strings::commands::scroll_popup(&self.key_config), + true, + true, + )); + + out.push(CommandInfo::new( + strings::commands::close_fuzzy_finder( + &self.key_config, + ), + true, + true, + )); + } + + visibility_blocking(self) + } + + fn event( + &mut self, + event: &crossterm::event::Event, + ) -> Result { + if self.is_visible() { + if let Event::Key(key) = event { + if key_match(key, self.key_config.keys.exit_popup) + || key_match(key, self.key_config.keys.enter) + { + self.hide(); + } else if key_match( + key, + self.key_config.keys.popup_down, + ) { + self.move_selection(ScrollType::Down); + } else if key_match( + key, + self.key_config.keys.popup_up, + ) { + self.move_selection(ScrollType::Up); + } + } + + if self.find_text.event(event)?.is_consumed() { + self.update_query(); + } + + return Ok(EventState::Consumed); + } + + 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(()) + } +} diff --git a/src/components/branchlist.rs b/src/components/branchlist.rs index 2da368b2..611abf13 100644 --- a/src/components/branchlist.rs +++ b/src/components/branchlist.rs @@ -25,7 +25,7 @@ use asyncgit::{ }, AsyncGitNotification, }; -use crossterm::event::Event; +use crossterm::event::{Event, KeyEvent}; use ratatui::{ backend::Backend, layout::{ @@ -206,6 +206,12 @@ impl Component for BranchListComponent { self.has_remotes, !self.local, )); + + out.push(CommandInfo::new( + strings::commands::find_branch(&self.key_config), + true, + true, + )); } visibility_blocking(self) } @@ -218,37 +224,11 @@ impl Component for BranchListComponent { } 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.tab_toggle) { - self.local = !self.local; - self.check_remotes(); - self.update_branches()?; - } else if key_match(e, self.key_config.keys.enter) { + if self.move_event(e)?.is_consumed() { + return Ok(EventState::Consumed); + } + + if key_match(e, self.key_config.keys.enter) { try_or_popup!( self, "switch branch error:", @@ -302,7 +282,7 @@ impl Component for BranchListComponent { ), )); } - } else if key_match(e, self.key_config.keys.pull) + } else if key_match(e, self.key_config.keys.fetch) && !self.local && self.has_remotes { self.queue.push(InternalEvent::FetchRemotes); @@ -312,6 +292,14 @@ impl Component for BranchListComponent { ) { //do not consume if its the more key return Ok(EventState::NotConsumed); + } else if key_match(e, self.key_config.keys.branch_find) { + let branches = self + .branches + .iter() + .map(|b| b.name.clone()) + .collect(); + self.queue + .push(InternalEvent::OpenBranchFinder(branches)); } } @@ -355,6 +343,41 @@ impl BranchListComponent { } } + fn move_event(&mut self, e: &KeyEvent) -> Result { + 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.tab_toggle) { + self.local = !self.local; + self.check_remotes(); + self.update_branches()?; + } + Ok(EventState::NotConsumed) + } + /// pub fn open(&mut self) -> Result<()> { self.show()?; @@ -363,6 +386,16 @@ impl BranchListComponent { Ok(()) } + pub fn branch_finder_update( + &mut self, + idx: Option, + ) -> Result<()> { + if let Some(idx) = idx { + self.set_selection(idx.try_into()?)?; + } + Ok(()) + } + fn check_remotes(&mut self) { if !self.local && self.visible { self.has_remotes = diff --git a/src/components/file_find_popup.rs b/src/components/file_find_popup.rs index 1385e63e..43982839 100644 --- a/src/components/file_find_popup.rs +++ b/src/components/file_find_popup.rs @@ -283,17 +283,16 @@ impl Component for FileFindPopup { force_all: bool, ) -> CommandBlocking { if self.is_visible() || force_all { - out.push( - CommandInfo::new( - strings::commands::close_popup(&self.key_config), - true, - true, - ) - .order(1), - ); + out.push(CommandInfo::new( + strings::commands::scroll_popup(&self.key_config), + true, + true, + )); out.push(CommandInfo::new( - strings::commands::scroll(&self.key_config), + strings::commands::close_fuzzy_finder( + &self.key_config, + ), true, true, )); diff --git a/src/components/mod.rs b/src/components/mod.rs index aca46106..5d882e4e 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,4 +1,5 @@ mod blame_file; +mod branch_find_popup; mod branchlist; mod changes; mod command; @@ -36,6 +37,7 @@ mod utils; pub use self::status_tree::StatusTreeComponent; pub use blame_file::{BlameFileComponent, BlameFileOpen}; +pub use branch_find_popup::BranchFindPopup; pub use branchlist::BranchListComponent; pub use changes::ChangesComponent; pub use command::{CommandInfo, CommandText}; diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index 7e1406e5..a3c79c93 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -103,6 +103,7 @@ pub struct KeysList { pub push: GituiKeyEvent, pub open_file_tree: GituiKeyEvent, pub file_find: GituiKeyEvent, + pub branch_find: GituiKeyEvent, pub force_push: GituiKeyEvent, pub fetch: GituiKeyEvent, pub pull: GituiKeyEvent, @@ -191,6 +192,7 @@ impl Default for KeysList { abort_merge: GituiKeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT), open_file_tree: GituiKeyEvent::new(KeyCode::Char('F'), KeyModifiers::SHIFT), file_find: GituiKeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()), + branch_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/queue.rs b/src/queue.rs index 81282b29..961a53a7 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -113,8 +113,12 @@ pub enum InternalEvent { /// OpenFileFinder(Vec), /// + OpenBranchFinder(Vec), + /// FileFinderChanged(Option), /// + BranchFinderChanged(Option), + /// FetchRemotes, /// OpenPopup(StackablePopupOpen), diff --git a/src/strings.rs b/src/strings.rs index 07d33e89..fc7ef107 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -450,6 +450,16 @@ pub mod commands { CMD_GROUP_GENERAL, ) } + pub fn find_branch(key_config: &SharedKeyConfig) -> CommandText { + CommandText::new( + format!( + "Find [{}]", + key_config.get_hint(key_config.keys.branch_find) + ), + "find branch in list", + CMD_GROUP_GENERAL, + ) + } pub fn toggle_tabs_direct( key_config: &SharedKeyConfig, ) -> CommandText { @@ -683,6 +693,19 @@ pub mod commands { CMD_GROUP_DIFF, ) } + pub fn close_fuzzy_finder( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Close [{}{}]", + key_config.get_hint(key_config.keys.exit_popup), + key_config.get_hint(key_config.keys.enter), + ), + "close fuzzy finder", + CMD_GROUP_GENERAL, + ) + } pub fn close_popup(key_config: &SharedKeyConfig) -> CommandText { CommandText::new( format!( @@ -693,6 +716,17 @@ pub mod commands { CMD_GROUP_GENERAL, ) } + pub fn scroll_popup(key_config: &SharedKeyConfig) -> CommandText { + CommandText::new( + format!( + "Scroll [{}{}]", + key_config.get_hint(key_config.keys.popup_down), + key_config.get_hint(key_config.keys.popup_up), + ), + "scroll up or down in popup", + CMD_GROUP_GENERAL, + ) + } pub fn close_msg(key_config: &SharedKeyConfig) -> CommandText { CommandText::new( format!( @@ -1555,7 +1589,7 @@ pub mod commands { CommandText::new( format!( "Fetch [{}]", - key_config.get_hint(key_config.keys.pull), + key_config.get_hint(key_config.keys.fetch), ), "fetch/prune", CMD_GROUP_BRANCHES,