mirror of
https://github.com/gitui-org/gitui
synced 2026-05-23 08:58:21 +00:00
find files via fuzzy finder (#890)
This commit is contained in:
parent
3b5d43ecb2
commit
fb2b990072
19 changed files with 694 additions and 36 deletions
19
Cargo.lock
generated
19
Cargo.lock
generated
|
|
@ -410,6 +410,15 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fuzzy-matcher"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
|
||||
dependencies = [
|
||||
"thread_local",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.3"
|
||||
|
|
@ -491,6 +500,7 @@ dependencies = [
|
|||
"dirs-next",
|
||||
"easy-cast",
|
||||
"filetreelist",
|
||||
"fuzzy-matcher",
|
||||
"gh-emoji",
|
||||
"itertools",
|
||||
"lazy_static",
|
||||
|
|
@ -1422,6 +1432,15 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8018d24e04c95ac8790716a5987d0fec4f8b27249ffa0f7d33f1369bdfb88cbd"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.1.43"
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ bugreport = "0.4"
|
|||
lazy_static = "1.4"
|
||||
syntect = { version = "4.5", default-features = false, features = ["metadata", "default-fancy"]}
|
||||
gh-emoji = "1.0.6"
|
||||
fuzzy-matcher = "0.3"
|
||||
|
||||
[target.'cfg(all(target_family="unix",not(target_os="macos")))'.dependencies]
|
||||
which = "4.1"
|
||||
|
|
|
|||
|
|
@ -142,6 +142,25 @@ impl FileTree {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn select_file(&mut self, path: &Path) -> bool {
|
||||
let new_selection = self
|
||||
.items
|
||||
.tree_items
|
||||
.iter()
|
||||
.position(|item| item.info().full_path() == path);
|
||||
|
||||
if new_selection == self.selection {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.selection = new_selection;
|
||||
if let Some(selection) = self.selection {
|
||||
self.items.show_element(selection);
|
||||
}
|
||||
self.visual_selection = self.calc_visual_selection();
|
||||
true
|
||||
}
|
||||
|
||||
fn visual_index_to_absolute(
|
||||
&self,
|
||||
visual_index: usize,
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ impl FileTreeItems {
|
|||
if item_path.starts_with(&path) {
|
||||
item.hide();
|
||||
} else {
|
||||
return;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -188,6 +188,90 @@ impl FileTreeItems {
|
|||
}
|
||||
}
|
||||
|
||||
/// makes sure `index` is visible.
|
||||
/// this expands all parents and shows all siblings
|
||||
pub fn show_element(&mut self, index: usize) -> Option<usize> {
|
||||
Some(
|
||||
self.show_element_upward(index)?
|
||||
+ self.show_element_downward(index)?,
|
||||
)
|
||||
}
|
||||
|
||||
fn show_element_upward(&mut self, index: usize) -> Option<usize> {
|
||||
let mut shown = 0_usize;
|
||||
|
||||
let item = self.tree_items.get(index)?;
|
||||
let mut current_folder: (PathBuf, u8) = (
|
||||
item.info().full_path().parent()?.to_path_buf(),
|
||||
item.info().indent(),
|
||||
);
|
||||
|
||||
let item_count = self.tree_items.len();
|
||||
for item in self
|
||||
.tree_items
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.skip(item_count - index - 1)
|
||||
{
|
||||
if item.info().indent() == current_folder.1 {
|
||||
item.show();
|
||||
shown += 1;
|
||||
} else if item.info().indent() == current_folder.1 - 1 {
|
||||
// this must be our parent
|
||||
|
||||
item.expand_path();
|
||||
|
||||
if item.info().is_visible() {
|
||||
// early out if parent already visible
|
||||
break;
|
||||
}
|
||||
|
||||
item.show();
|
||||
shown += 1;
|
||||
|
||||
current_folder = (
|
||||
item.info().full_path().parent()?.to_path_buf(),
|
||||
item.info().indent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some(shown)
|
||||
}
|
||||
|
||||
fn show_element_downward(
|
||||
&mut self,
|
||||
index: usize,
|
||||
) -> Option<usize> {
|
||||
let mut shown = 0_usize;
|
||||
|
||||
let item = self.tree_items.get(index)?;
|
||||
let mut current_folder: (PathBuf, u8) = (
|
||||
item.info().full_path().parent()?.to_path_buf(),
|
||||
item.info().indent(),
|
||||
);
|
||||
|
||||
for item in self.tree_items.iter_mut().skip(index + 1) {
|
||||
if item.info().indent() == current_folder.1 {
|
||||
item.show();
|
||||
shown += 1;
|
||||
}
|
||||
if item.info().indent() == current_folder.1 - 1 {
|
||||
// this must be our parent
|
||||
|
||||
item.show();
|
||||
shown += 1;
|
||||
|
||||
current_folder = (
|
||||
item.info().full_path().parent()?.to_path_buf(),
|
||||
item.info().indent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some(shown)
|
||||
}
|
||||
|
||||
fn update_visibility(
|
||||
&mut self,
|
||||
prefix: &Option<PathBuf>,
|
||||
|
|
@ -687,6 +771,152 @@ mod tests {
|
|||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_show_element() {
|
||||
let items = vec![
|
||||
Path::new("a/b/c"), //
|
||||
Path::new("a/b2/d"), //
|
||||
Path::new("a/b2/e"), //
|
||||
];
|
||||
|
||||
//0 a/
|
||||
//1 b/
|
||||
//2 c
|
||||
//3 b2/
|
||||
//4 d
|
||||
//5 e
|
||||
|
||||
let mut tree =
|
||||
FileTreeItems::new(&items, &BTreeSet::new()).unwrap();
|
||||
|
||||
tree.collapse(0, true);
|
||||
|
||||
let res = tree.show_element(5).unwrap();
|
||||
assert_eq!(res, 4);
|
||||
assert!(tree.tree_items[3].kind().is_path());
|
||||
assert!(!tree.tree_items[3].kind().is_path_collapsed());
|
||||
|
||||
assert_eq!(
|
||||
get_visibles(&tree),
|
||||
vec![
|
||||
true, //
|
||||
true, //
|
||||
false, //
|
||||
true, //
|
||||
true, //
|
||||
true,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_show_element_later_elements() {
|
||||
let items = vec![
|
||||
Path::new("a/b"), //
|
||||
Path::new("a/c"), //
|
||||
];
|
||||
|
||||
//0 a/
|
||||
//1 b
|
||||
//2 c
|
||||
|
||||
let mut tree =
|
||||
FileTreeItems::new(&items, &BTreeSet::new()).unwrap();
|
||||
|
||||
tree.collapse(0, true);
|
||||
|
||||
assert_eq!(
|
||||
get_visibles(&tree),
|
||||
vec![
|
||||
true, //
|
||||
false, //
|
||||
false, //
|
||||
]
|
||||
);
|
||||
|
||||
let res = tree.show_element(1).unwrap();
|
||||
assert_eq!(res, 2);
|
||||
|
||||
assert_eq!(
|
||||
get_visibles(&tree),
|
||||
vec![
|
||||
true, //
|
||||
true, //
|
||||
true, //
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_show_element_downward_parent() {
|
||||
let items = vec![
|
||||
Path::new("a/b/c"), //
|
||||
Path::new("a/d"), //
|
||||
Path::new("a/e"), //
|
||||
];
|
||||
|
||||
//0 a/
|
||||
//1 b/
|
||||
//2 c
|
||||
//3 d
|
||||
//4 e
|
||||
|
||||
let mut tree =
|
||||
FileTreeItems::new(&items, &BTreeSet::new()).unwrap();
|
||||
|
||||
tree.collapse(0, true);
|
||||
|
||||
let res = tree.show_element(2).unwrap();
|
||||
assert_eq!(res, 4);
|
||||
|
||||
assert_eq!(
|
||||
get_visibles(&tree),
|
||||
vec![
|
||||
true, //
|
||||
true, //
|
||||
true, //
|
||||
true, //
|
||||
true, //
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_show_element_expand_visible_parent() {
|
||||
let items = vec![
|
||||
Path::new("a/b"), //
|
||||
];
|
||||
|
||||
//0 a/
|
||||
//1 b
|
||||
|
||||
let mut tree =
|
||||
FileTreeItems::new(&items, &BTreeSet::new()).unwrap();
|
||||
|
||||
tree.collapse(0, true);
|
||||
|
||||
assert_eq!(
|
||||
get_visibles(&tree),
|
||||
vec![
|
||||
true, //
|
||||
false, //
|
||||
]
|
||||
);
|
||||
|
||||
let res = tree.show_element(1).unwrap();
|
||||
assert_eq!(res, 1);
|
||||
assert!(tree.tree_items[0].kind().is_path());
|
||||
assert!(!tree.tree_items[0].kind().is_path_collapsed());
|
||||
|
||||
assert_eq!(
|
||||
get_visibles(&tree),
|
||||
vec![
|
||||
true, //
|
||||
true, //
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -164,13 +164,17 @@ impl FileTreeItem {
|
|||
&self.kind
|
||||
}
|
||||
|
||||
///
|
||||
/// # Panics
|
||||
/// panics if self is not a path
|
||||
pub fn collapse_path(&mut self) {
|
||||
assert!(self.kind.is_path());
|
||||
self.kind = FileTreeItemKind::Path(PathCollapsed(true));
|
||||
}
|
||||
|
||||
///
|
||||
/// # Panics
|
||||
/// panics if self is not a path
|
||||
pub fn expand_path(&mut self) {
|
||||
assert!(self.kind.is_path());
|
||||
self.kind = FileTreeItemKind::Path(PathCollapsed(false));
|
||||
}
|
||||
|
||||
|
|
@ -178,6 +182,11 @@ impl FileTreeItem {
|
|||
pub fn hide(&mut self) {
|
||||
self.info.visible = false;
|
||||
}
|
||||
|
||||
///
|
||||
pub fn show(&mut self) {
|
||||
self.info.visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for FileTreeItem {}
|
||||
|
|
|
|||
20
src/app.rs
20
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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
271
src/components/file_find.rs
Normal file
271
src/components/file_find.rs
Normal file
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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()},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ mod notify_mutex;
|
|||
mod profiler;
|
||||
mod queue;
|
||||
mod spinner;
|
||||
mod string_utils;
|
||||
mod strings;
|
||||
mod tabs;
|
||||
mod ui;
|
||||
|
|
|
|||
12
src/queue.rs
12
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
|
||||
|
|
|
|||
35
src/string_utils.rs
Normal file
35
src/string_utils.rs
Normal file
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@
|
|||
pull: ( code: Char('f'), modifiers: ( bits: 0,),),
|
||||
|
||||
open_file_tree: ( code: Char('F'), modifiers: ( bits: 1,),),
|
||||
file_find: ( code: Char('f'), modifiers: ( bits: 0,),),
|
||||
|
||||
//removed in 0.11
|
||||
//tab_toggle_reverse_windows: ( code: BackTab, modifiers: ( bits: 1,),),
|
||||
|
|
|
|||
Loading…
Reference in a new issue