summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorStephan Dilly <dilly.stephan@gmail.com>2021-09-04 10:50:03 +0200
committerGitHub <noreply@github.com>2021-09-04 10:50:03 +0200
commitfb2b990072625eaecc23beb76bcd197c6536e80a (patch)
tree7ce2c42dccc7a4def7d983b51fd28a54719e330b /src
parent3b5d43ecb28d4846e4f5d16b3fa68ab63fd776c9 (diff)
find files via fuzzy finder (#890)
Diffstat (limited to 'src')
-rw-r--r--src/app.rs20
-rw-r--r--src/components/diff.rs5
-rw-r--r--src/components/file_find.rs271
-rw-r--r--src/components/mod.rs21
-rw-r--r--src/components/reset.rs2
-rw-r--r--src/components/revision_files.rs32
-rw-r--r--src/components/syntax_text.rs5
-rw-r--r--src/components/textinput.rs33
-rw-r--r--src/keys.rs2
-rw-r--r--src/main.rs1
-rw-r--r--src/queue.rs12
-rw-r--r--src/string_utils.rs35
-rw-r--r--src/tabs/files.rs6
13 files changed, 412 insertions, 33 deletions
diff --git a/src/app.rs b/src/app.rs
index a5506e8f..2692314a 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -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 {