diff options
author | Stephan Dilly <dilly.stephan@gmail.com> | 2021-09-04 10:50:03 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-09-04 10:50:03 +0200 |
commit | fb2b990072625eaecc23beb76bcd197c6536e80a (patch) | |
tree | 7ce2c42dccc7a4def7d983b51fd28a54719e330b /src | |
parent | 3b5d43ecb28d4846e4f5d16b3fa68ab63fd776c9 (diff) |
find files via fuzzy finder (#890)
Diffstat (limited to 'src')
-rw-r--r-- | src/app.rs | 20 | ||||
-rw-r--r-- | src/components/diff.rs | 5 | ||||
-rw-r--r-- | src/components/file_find.rs | 271 | ||||
-rw-r--r-- | src/components/mod.rs | 21 | ||||
-rw-r--r-- | src/components/reset.rs | 2 | ||||
-rw-r--r-- | src/components/revision_files.rs | 32 | ||||
-rw-r--r-- | src/components/syntax_text.rs | 5 | ||||
-rw-r--r-- | src/components/textinput.rs | 33 | ||||
-rw-r--r-- | src/keys.rs | 2 | ||||
-rw-r--r-- | src/main.rs | 1 | ||||
-rw-r--r-- | src/queue.rs | 12 | ||||
-rw-r--r-- | src/string_utils.rs | 35 | ||||
-rw-r--r-- | src/tabs/files.rs | 6 |
13 files changed, 412 insertions, 33 deletions
@@ -6,7 +6,7 @@ use crate::{ BranchListComponent, CommandBlocking, CommandInfo, CommitComponent, CompareCommitsComponent, Component, ConfirmComponent, CreateBranchComponent, DrawableComponent, - ExternalEditorComponent, HelpComponent, + ExternalEditorComponent, FileFindComponent, HelpComponent, InspectCommitComponent, MsgComponent, OptionsPopupComponent, PullComponent, PushComponent, PushTagsComponent, RenameBranchComponent, RevisionFilesPopup, SharedOptions, @@ -51,6 +51,7 @@ pub struct App { compare_commits_popup: CompareCommitsComponent, external_editor_popup: ExternalEditorComponent, revision_files_popup: RevisionFilesPopup, + find_file_popup: FileFindComponent, push_popup: PushComponent, push_tags_popup: PushTagsComponent, pull_popup: PullComponent, @@ -189,6 +190,11 @@ impl App { key_config.clone(), options.clone(), ), + find_file_popup: FileFindComponent::new( + &queue, + theme.clone(), + key_config.clone(), + ), do_quit: false, cmdbar: RefCell::new(CommandBar::new( theme.clone(), @@ -448,6 +454,7 @@ impl App { rename_branch_popup, select_branch_popup, revision_files_popup, + find_file_popup, tags_popup, options_popup, help, @@ -475,6 +482,7 @@ impl App { create_branch_popup, rename_branch_popup, revision_files_popup, + find_file_popup, push_popup, push_tags_popup, pull_popup, @@ -693,6 +701,11 @@ impl App { flags .insert(NeedsUpdate::ALL | NeedsUpdate::COMMANDS); } + InternalEvent::OpenFileFinder(files) => { + self.find_file_popup.open(&files)?; + flags + .insert(NeedsUpdate::ALL | NeedsUpdate::COMMANDS); + } InternalEvent::OptionSwitched(o) => { match o { AppOption::StatusShowUntracked => { @@ -712,6 +725,11 @@ impl App { flags .insert(NeedsUpdate::ALL | NeedsUpdate::COMMANDS); } + InternalEvent::FileFinderChanged(file) => { + self.files_tab.file_finder_update(file); + flags + .insert(NeedsUpdate::ALL | NeedsUpdate::COMMANDS); + } }; Ok(flags) diff --git a/src/components/diff.rs b/src/components/diff.rs index c4496400..598c96ce 100644 --- a/src/components/diff.rs +++ b/src/components/diff.rs @@ -3,11 +3,10 @@ use super::{ Direction, DrawableComponent, ScrollType, }; use crate::{ - components::{ - tabs_to_spaces, CommandInfo, Component, EventState, - }, + components::{CommandInfo, Component, EventState}, keys::SharedKeyConfig, queue::{Action, InternalEvent, NeedsUpdate, Queue, ResetItem}, + string_utils::tabs_to_spaces, strings, try_or_popup, ui::style::SharedTheme, }; diff --git a/src/components/file_find.rs b/src/components/file_find.rs new file mode 100644 index 00000000..e302783b --- /dev/null +++ b/src/components/file_find.rs @@ -0,0 +1,271 @@ +use super::{ + visibility_blocking, CommandBlocking, CommandInfo, Component, + DrawableComponent, EventState, TextInputComponent, +}; +use crate::{ + keys::SharedKeyConfig, + queue::{InternalEvent, Queue}, + string_utils::trim_length_left, + strings, + ui::{self, style::SharedTheme}, +}; +use anyhow::Result; +use asyncgit::sync::TreeFile; +use crossterm::event::Event; +use fuzzy_matcher::FuzzyMatcher; +use std::borrow::Cow; +use tui::{ + backend::Backend, + layout::{Constraint, Direction, Layout, Margin, Rect}, + text::Span, + widgets::{Block, Borders, Clear}, + Frame, +}; + +pub struct FileFindComponent { + queue: Queue, + visible: bool, + find_text: TextInputComponent, + query: Option<String>, + theme: SharedTheme, + files: Vec<TreeFile>, + selection: Option<usize>, + files_filtered: Vec<usize>, + key_config: SharedKeyConfig, +} + +impl FileFindComponent { + /// + 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, + files: Vec::new(), + files_filtered: Vec::new(), + key_config, + selection: None, + } + } + + 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<String>) { + self.query = query; + + self.files_filtered.clear(); + + if let Some(q) = &self.query { + let matcher = + fuzzy_matcher::skim::SkimMatcherV2::default(); + + self.files_filtered.extend( + self.files.iter().enumerate().filter_map(|a| { + a.1.path.to_str().and_then(|path| { + //TODO: use fuzzy_indices and highlight hits + matcher.fuzzy_match(path, q).map(|_| a.0) + }) + }), + ); + + self.refresh_selection(); + } else { + self.files_filtered + .extend(self.files.iter().enumerate().map(|a| a.0)); + } + } + + fn refresh_selection(&mut self) { + let selection = self.files_filtered.first().copied(); + + if self.selection != selection { + self.selection = selection; + + let file = self + .selection + .and_then(|index| self.files.get(index)) + .map(|f| f.path.clone()); + + self.queue.push(InternalEvent::FileFinderChanged(file)); + } + } + + pub fn open(&mut self, files: &[TreeFile]) -> Result<()> { + self.show()?; + self.find_text.show()?; + self.find_text.set_text(String::new()); + self.query = None; + if self.files != *files { + self.files = files.to_owned(); + } + self.update_query(); + + Ok(()) + } +} + +impl DrawableComponent for FileFindComponent { + fn draw<B: Backend>( + &self, + f: &mut Frame<B>, + area: Rect, + ) -> Result<()> { + if self.is_visible() { + const SIZE: (u16, u16) = (50, 25); + let area = + ui::centered_rect_absolute(SIZE.0, SIZE.1, area); + + f.render_widget(Clear, area); + f.render_widget( + Block::default() + .borders(Borders::all()) + .style(self.theme.title(true)) + .title(Span::styled( + //TODO: strings + "Fuzzy find", + self.theme.title(true), + )), + area, + ); + + let area = 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, area[0])?; + + let height = usize::from(area[1].height); + let width = usize::from(area[1].width); + + let items = + self.files_filtered.iter().take(height).map(|idx| { + let selected = self + .selection + .map_or(false, |selection| selection == *idx); + Span::styled( + Cow::from(trim_length_left( + self.files[*idx] + .path + .to_str() + .unwrap_or_default(), + width, + )), + self.theme.text(selected, false), + ) + }); + + let title = format!( + "Hits: {}/{}", + height.min(self.files_filtered.len()), + self.files_filtered.len() + ); + + ui::draw_list_block( + f, + area[1], + Block::default() + .title(Span::styled( + title, + self.theme.title(true), + )) + .borders(Borders::TOP), + items, + ); + } + Ok(()) + } +} + +impl Component for FileFindComponent { + fn commands( + &self, + out: &mut Vec<CommandInfo>, + 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), + ); + } + + visibility_blocking(self) + } + + fn event( + &mut self, + event: crossterm::event::Event, + ) -> Result<EventState> { + if self.is_visible() { + if let Event::Key(key) = &event { + if *key == self.key_config.exit_popup + || *key == self.key_config.enter + { + self.hide(); + } + } + + 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/mod.rs b/src/components/mod.rs index 523c3f89..064f7edf 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -10,6 +10,7 @@ mod create_branch; mod cred; mod diff; mod externaleditor; +mod file_find; mod filetree; mod help; mod inspect_commit; @@ -41,6 +42,7 @@ pub use compare_commits::CompareCommitsComponent; pub use create_branch::CreateBranchComponent; pub use diff::DiffComponent; pub use externaleditor::ExternalEditorComponent; +pub use file_find::FileFindComponent; pub use help::HelpComponent; pub use inspect_commit::InspectCommitComponent; pub use msg::MsgComponent; @@ -297,27 +299,24 @@ fn popup_paragraph<'a, T>( content: T, theme: &Theme, focused: bool, + block: bool, ) -> Paragraph<'a> where T: Into<Text<'a>>, { - Paragraph::new(content.into()) - .block( + let paragraph = Paragraph::new(content.into()) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }); + + if block { + paragraph.block( Block::default() .title(Span::styled(title, theme.title(focused))) .borders(Borders::ALL) .border_type(BorderType::Thick) .border_style(theme.block(focused)), ) - .alignment(Alignment::Left) - .wrap(Wrap { trim: true }) -} - -//TODO: allow customize tabsize -pub fn tabs_to_spaces(input: String) -> String { - if input.contains('\t') { - input.replace("\t", " ") } else { - input + paragraph } } diff --git a/src/components/reset.rs b/src/components/reset.rs index 02bef26e..0ff0a704 100644 --- a/src/components/reset.rs +++ b/src/components/reset.rs @@ -41,7 +41,7 @@ impl DrawableComponent for ConfirmComponent { let area = ui::centered_rect(50, 20, f.size()); f.render_widget(Clear, area); f.render_widget( - popup_paragraph(&title, txt, &self.theme, true), + popup_paragraph(&title, txt, &self.theme, true, true), area, ); } diff --git a/src/components/revision_files.rs b/src/components/revision_files.rs index 4a36bbd0..0abcf457 100644 --- a/src/components/revision_files.rs +++ b/src/components/revision_files.rs @@ -18,7 +18,11 @@ use asyncgit::{ use crossbeam_channel::Sender; use crossterm::event::Event; use filetreelist::{FileTree, FileTreeItem}; -use std::{collections::BTreeSet, convert::From, path::Path}; +use std::{ + collections::BTreeSet, + convert::From, + path::{Path, PathBuf}, +}; use tui::{ backend::Backend, layout::{Constraint, Direction, Layout, Rect}, @@ -137,6 +141,20 @@ impl RevisionFilesComponent { }) } + fn open_finder(&self) { + self.queue + .push(InternalEvent::OpenFileFinder(self.files.clone())); + } + + pub fn find_file(&mut self, file: Option<PathBuf>) { + if let Some(file) = file { + self.tree.collapse_but_root(); + if self.tree.select_file(&file) { + self.selection_changed(); + } + } + } + fn selection_changed(&mut self) { //TODO: retrieve TreeFile from tree datastructure if let Some(file) = self @@ -144,6 +162,7 @@ impl RevisionFilesComponent { .selected_file() .map(|file| file.full_path_str().to_string()) { + log::info!("selected: {:?}", file); let path = Path::new(&file); if let Some(item) = self.files.iter().find(|f| f.path == path) @@ -188,7 +207,7 @@ impl RevisionFilesComponent { "Files at [{}]", self.revision .map(|c| c.get_short_string()) - .unwrap_or_default() + .unwrap_or_default(), ); ui::draw_list_block( f, @@ -241,7 +260,9 @@ impl Component for RevisionFilesComponent { out: &mut Vec<CommandInfo>, force_all: bool, ) -> CommandBlocking { - if matches!(self.focus, Focus::Tree) || force_all { + let is_tree_focused = matches!(self.focus, Focus::Tree); + + if is_tree_focused || force_all { out.push( CommandInfo::new( strings::commands::blame_file(&self.key_config), @@ -288,6 +309,11 @@ impl Component for RevisionFilesComponent { self.focus(false); return Ok(EventState::Consumed); } + } else if key == self.key_config.file_find { + if is_tree_focused { + self.open_finder(); + return Ok(EventState::Consumed); + } } else if !is_tree_focused { return self.current_file.event(event); } diff --git a/src/components/syntax_text.rs b/src/components/syntax_text.rs index cfef3199..9df85a49 100644 --- a/src/components/syntax_text.rs +++ b/src/components/syntax_text.rs @@ -1,9 +1,10 @@ use super::{ - tabs_to_spaces, CommandBlocking, CommandInfo, Component, - DrawableComponent, EventState, + CommandBlocking, CommandInfo, Component, DrawableComponent, + EventState, }; use crate::{ keys::SharedKeyConfig, + string_utils::tabs_to_spaces, strings, ui::{ self, common_nav, style::SharedTheme, AsyncSyntaxJob, diff --git a/src/components/textinput.rs b/src/components/textinput.rs index a53dd258..6086753d 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -41,6 +41,7 @@ pub struct TextInputComponent { cursor_position: usize, input_type: InputType, current_area: Cell<Rect>, + embed: bool, } impl TextInputComponent { @@ -63,6 +64,7 @@ impl TextInputComponent { cursor_position: 0, input_type: InputType::Multiline, current_area: Cell::new(Rect::default()), + embed: false, } } @@ -90,6 +92,11 @@ impl TextInputComponent { self.current_area.get() } + /// embed into parent draw area + pub fn embed(&mut self) { + self.embed = true; + } + /// Move the cursor right one char. fn incr_cursor(&mut self) { if let Some(pos) = self.next_char_position() { @@ -267,7 +274,7 @@ impl DrawableComponent for TextInputComponent { fn draw<B: Backend>( &self, f: &mut Frame<B>, - _rect: Rect, + rect: Rect, ) -> Result<()> { if self.visible { let txt = if self.msg.is_empty() { @@ -279,16 +286,21 @@ impl DrawableComponent for TextInputComponent { self.get_draw_text() }; - let area = match self.input_type { - InputType::Multiline => { - let area = ui::centered_rect(60, 20, f.size()); - ui::rect_inside( - Size::new(10, 3), - f.size().into(), - area, - ) + let area = if self.embed { + rect + } else { + match self.input_type { + InputType::Multiline => { + let area = + ui::centered_rect(60, 20, f.size()); + ui::rect_inside( + Size::new(10, 3), + f.size().into(), + area, + ) + } + _ => ui::centered_rect_absolute(32, 3, f.size()), } - _ => ui::centered_rect_absolute(32, 3, f.size()), }; f.render_widget(Clear, area); @@ -298,6 +310,7 @@ impl DrawableComponent for TextInputComponent { txt, &self.theme, true, + !self.embed, ), area, ); diff --git a/src/keys.rs b/src/keys.rs index 201610cf..dd6c2cff 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -83,6 +83,7 @@ pub struct KeyConfig { pub select_tag: KeyEvent, pub push: KeyEvent, pub open_file_tree: KeyEvent, + pub file_find: KeyEvent, pub force_push: KeyEvent, pub pull: KeyEvent, pub abort_merge: KeyEvent, @@ -159,6 +160,7 @@ impl Default for KeyConfig { pull: KeyEvent { code: KeyCode::Char('f'), modifiers: KeyModifiers::empty()}, abort_merge: KeyEvent { code: KeyCode::Char('M'), modifiers: KeyModifiers::SHIFT}, open_file_tree: KeyEvent { code: KeyCode::Char('F'), modifiers: KeyModifiers::SHIFT}, + file_find: KeyEvent { code: KeyCode::Char('f'), modifiers: KeyModifiers::empty()}, } } } diff --git a/src/main.rs b/src/main.rs index 37dd5171..fb272fca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ mod notify_mutex; mod profiler; mod queue; mod spinner; +mod string_utils; mod strings; mod tabs; mod ui; diff --git a/src/queue.rs b/src/queue.rs index d9bd740c..94cc43ab 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -1,7 +1,11 @@ use crate::{components::AppOption, tabs::StashingOptions}; -use asyncgit::sync::{diff::DiffLinePosition, CommitId, CommitTags}; +use asyncgit::sync::{ + diff::DiffLinePosition, CommitId, CommitTags, TreeFile, +}; use bitflags::bitflags; -use std::{cell::RefCell, collections::VecDeque, rc::Rc}; +use std::{ + cell::RefCell, collections::VecDeque, path::PathBuf, rc::Rc, +}; bitflags! { /// flags defining what part of the app need to update @@ -87,6 +91,10 @@ pub enum InternalEvent { OpenFileTree(CommitId), /// OptionSwitched(AppOption), + /// + OpenFileFinder(Vec<TreeFile>), + /// + FileFinderChanged(Option<PathBuf>), } /// single threaded simple queue for components to communicate with each other diff --git a/src/string_utils.rs b/src/string_utils.rs new file mode 100644 index 00000000..627a0e60 --- /dev/null +++ b/src/string_utils.rs @@ -0,0 +1,35 @@ +/// +pub fn trim_length_left(s: &str, width: usize) -> &str { + let len = s.len(); + if len > width { + for i in len - width..len { + if s.is_char_boundary(i) { + return &s[i..]; + } + } + } + + s +} + +//TODO: allow customize tabsize +pub fn tabs_to_spaces(input: String) -> String { + if input.contains('\t') { + input.replace("\t", " ") + } else { + input + } +} + +#[cfg(test)] +mod test { + use pretty_assertions::assert_eq; + + use crate::string_utils::trim_length_left; + + #[test] + fn test_trim() { + assert_eq!(trim_length_left("👍foo", 3), "foo"); + assert_eq!(trim_length_left("👍foo", 4), "foo"); + } +} diff --git a/src/tabs/files.rs b/src/tabs/files.rs index 2f832393..eb405eab 100644 --- a/src/tabs/files.rs +++ b/src/tabs/files.rs @@ -4,6 +4,8 @@ clippy::unused_self )] +use std::path::PathBuf; + use crate::{ components::{ visibility_blocking, CommandBlocking, CommandInfo, Component, @@ -68,6 +70,10 @@ impl FilesTab { self.files.update(ev); } } + + pub fn file_finder_update(&mut self, file: Option<PathBuf>) { + self.files.find_file(file); + } } impl DrawableComponent for FilesTab { |