249 feature request better search results (#282)
Some checks failed
Linux / build (push) Has been cancelled
MacOS / build (push) Has been cancelled
Windows / build (push) Has been cancelled

* feat: issue 249 - fuzzy search replaced the old find explorer

* fix: forgot to upload file

* fix: removed debug
This commit is contained in:
Christian Visintin
2024-10-02 17:45:48 +02:00
committed by GitHub
parent c507d54700
commit b2a8a3041c
13 changed files with 625 additions and 226 deletions

View File

@@ -41,8 +41,9 @@
Released on Released on
- [Issue 268](https://github.com/veeso/termscp/issues/268): Pods and container explorer for Kube protocol. - [Issue 249](https://github.com/veeso/termscp/issues/249): The old *find* command has been replaced with a brand new explorer with support to 🪄 **Fuzzy search** 🪄. The command is still `<F>`.
- BREAKING ‼️ Kube address argument has changed; see manual! - [Issue 268](https://github.com/veeso/termscp/issues/268): 📦 **Pods and container explorer** 🐳 for Kube protocol.
- BREAKING ‼️ Kube address argument has changed to `namespace[@<cluster_url>][$<path>]`
- Pod and container argumets have been removed; from now on you will connect with the following syntax to the provided namespace: `/pod-name/container-name/path/to/file` - Pod and container argumets have been removed; from now on you will connect with the following syntax to the provided namespace: `/pod-name/container-name/path/to/file`
- [Issue 279](https://github.com/veeso/termscp/issues/279): do not clear screen - [Issue 279](https://github.com/veeso/termscp/issues/279): do not clear screen
- [Issue 277](https://github.com/veeso/termscp/issues/277): Fix a bug in the configuration page, which caused being stuck if the added SSH key was empty - [Issue 277](https://github.com/veeso/termscp/issues/277): Fix a bug in the configuration page, which caused being stuck if the added SSH key was empty

65
Cargo.lock generated
View File

@@ -461,6 +461,25 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "crossbeam-deque"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.20" version = "0.8.20"
@@ -1828,6 +1847,27 @@ dependencies = [
"tauri-winrt-notification", "tauri-winrt-notification",
] ]
[[package]]
name = "nucleo"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5262af4c94921c2646c5ac6ff7900c2af9cbb08dc26a797e18130a7019c039d4"
dependencies = [
"nucleo-matcher",
"parking_lot 0.12.3",
"rayon",
]
[[package]]
name = "nucleo-matcher"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85"
dependencies = [
"memchr",
"unicode-segmentation",
]
[[package]] [[package]]
name = "num" name = "num"
version = "0.4.3" version = "0.4.3"
@@ -2441,6 +2481,26 @@ dependencies = [
"unicode-width 0.1.14", "unicode-width 0.1.14",
] ]
[[package]]
name = "rayon"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.2.16" version = "0.2.16"
@@ -3497,6 +3557,7 @@ dependencies = [
"magic-crypt", "magic-crypt",
"notify", "notify",
"notify-rust", "notify-rust",
"nucleo",
"open", "open",
"pretty_assertions", "pretty_assertions",
"rand", "rand",
@@ -4167,9 +4228,9 @@ dependencies = [
[[package]] [[package]]
name = "wildmatch" name = "wildmatch"
version = "2.3.4" version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3928939971918220fed093266b809d1ee4ec6c1a2d72692ff6876898f3b16c19" checksum = "68ce1ab1f8c62655ebe1350f589c61e505cf94d385bc6a12899442d9081e71fd"
[[package]] [[package]]
name = "winapi" name = "winapi"

View File

@@ -5,13 +5,7 @@ description = "termscp is a feature rich terminal file transfer and explorer wit
edition = "2021" edition = "2021"
homepage = "https://termscp.veeso.dev" homepage = "https://termscp.veeso.dev"
include = ["src/**/*", "LICENSE", "README.md", "CHANGELOG.md"] include = ["src/**/*", "LICENSE", "README.md", "CHANGELOG.md"]
keywords = [ keywords = ["terminal", "ftp", "scp", "sftp", "tui"]
"scp-client",
"sftp-client",
"ftp-client",
"winscp",
"command-line-utility",
]
license = "MIT" license = "MIT"
name = "termscp" name = "termscp"
readme = "README.md" readme = "README.md"
@@ -46,13 +40,18 @@ dirs = "^5.0"
edit = "^0.1" edit = "^0.1"
filetime = "^0.2" filetime = "^0.2"
hostname = "^0.4" hostname = "^0.4"
keyring = { version = "^3", optional = true, features = ["apple-native", "windows-native", "sync-secret-service"] } keyring = { version = "^3", optional = true, features = [
"apple-native",
"windows-native",
"sync-secret-service",
] }
lazy-regex = "^3" lazy-regex = "^3"
lazy_static = "^1" lazy_static = "^1"
log = "^0.4" log = "^0.4"
magic-crypt = "^3" magic-crypt = "^3"
notify = "6" notify = "6"
notify-rust = { version = "^4.5", default-features = false, features = ["d"] } notify-rust = { version = "^4.5", default-features = false, features = ["d"] }
nucleo = "0.5"
open = "^5.0" open = "^5.0"
rand = "^0.8.5" rand = "^0.8.5"
regex = "^1" regex = "^1"
@@ -83,7 +82,7 @@ tuirealm = "^1.9"
unicode-width = "^0.2" unicode-width = "^0.2"
version-compare = "^0.2" version-compare = "^0.2"
whoami = "^1.5" whoami = "^1.5"
wildmatch = "^2.3" wildmatch = "^2"
[dev-dependencies] [dev-dependencies]
pretty_assertions = "^1" pretty_assertions = "^1"

View File

@@ -30,6 +30,7 @@ pub enum FileSorting {
ModifyTime, ModifyTime,
CreationTime, CreationTime,
Size, Size,
None,
} }
/// GroupDirs defines how directories should be grouped in sorting files /// GroupDirs defines how directories should be grouped in sorting files
@@ -178,6 +179,7 @@ impl FileExplorer {
FileSorting::CreationTime => self.sort_files_by_creation_time(), FileSorting::CreationTime => self.sort_files_by_creation_time(),
FileSorting::ModifyTime => self.sort_files_by_mtime(), FileSorting::ModifyTime => self.sort_files_by_mtime(),
FileSorting::Size => self.sort_files_by_size(), FileSorting::Size => self.sort_files_by_size(),
FileSorting::None => {}
} }
// Directories first (NOTE: MUST COME AFTER OTHER SORTING) // Directories first (NOTE: MUST COME AFTER OTHER SORTING)
// Group directories if necessary // Group directories if necessary
@@ -245,6 +247,7 @@ impl std::fmt::Display for FileSorting {
FileSorting::ModifyTime => "by_mtime", FileSorting::ModifyTime => "by_mtime",
FileSorting::Name => "by_name", FileSorting::Name => "by_name",
FileSorting::Size => "by_size", FileSorting::Size => "by_size",
FileSorting::None => "none",
} }
) )
} }

View File

@@ -9,15 +9,15 @@ use super::super::browser::FileExplorerTab;
use super::{File, FileTransferActivity, LogLevel, SelectedFile, TransferOpts, TransferPayload}; use super::{File, FileTransferActivity, LogLevel, SelectedFile, TransferOpts, TransferPayload};
impl FileTransferActivity { impl FileTransferActivity {
pub(crate) fn action_local_find(&mut self, input: String) -> Result<Vec<File>, String> { pub(crate) fn action_walkdir_local(&mut self) -> Result<Vec<File>, String> {
match self.host.find(input.as_str()) { match self.host.find("*") {
Ok(entries) => Ok(entries), Ok(entries) => Ok(entries),
Err(err) => Err(format!("Could not search for files: {err}")), Err(err) => Err(format!("Could not search for files: {err}")),
} }
} }
pub(crate) fn action_remote_find(&mut self, input: String) -> Result<Vec<File>, String> { pub(crate) fn action_walkdir_remote(&mut self) -> Result<Vec<File>, String> {
match self.client.as_mut().find(input.as_str()) { match self.client.as_mut().find("*") {
Ok(entries) => Ok(entries), Ok(entries) => Ok(entries),
Err(err) => Err(format!("Could not search for files: {err}")), Err(err) => Err(format!("Could not search for files: {err}")),
} }
@@ -26,6 +26,7 @@ impl FileTransferActivity {
pub(crate) fn action_find_changedir(&mut self) { pub(crate) fn action_find_changedir(&mut self) {
// Match entry // Match entry
if let SelectedFile::One(entry) = self.get_found_selected_entries() { if let SelectedFile::One(entry) = self.get_found_selected_entries() {
debug!("Changedir to: {}", entry.name());
// Get path: if a directory, use directory path; if it is a File, get parent path // Get path: if a directory, use directory path; if it is a File, get parent path
let path = if entry.is_dir() { let path = if entry.is_dir() {
entry.path().to_path_buf() entry.path().to_path_buf()

View File

@@ -17,12 +17,12 @@ mod transfer;
pub use misc::FooterBar; pub use misc::FooterBar;
pub use popups::{ pub use popups::{
ChmodPopup, CopyPopup, DeletePopup, DisconnectPopup, ErrorPopup, ExecPopup, FatalPopup, ChmodPopup, CopyPopup, DeletePopup, DisconnectPopup, ErrorPopup, ExecPopup, FatalPopup,
FileInfoPopup, FilterPopup, FindPopup, GoToPopup, KeybindingsPopup, MkdirPopup, NewfilePopup, FileInfoPopup, FilterPopup, GoToPopup, KeybindingsPopup, MkdirPopup, NewfilePopup,
OpenWithPopup, ProgressBarFull, ProgressBarPartial, QuitPopup, RenamePopup, ReplacePopup, OpenWithPopup, ProgressBarFull, ProgressBarPartial, QuitPopup, RenamePopup, ReplacePopup,
ReplacingFilesListPopup, SaveAsPopup, SortingPopup, StatusBarLocal, StatusBarRemote, ReplacingFilesListPopup, SaveAsPopup, SortingPopup, StatusBarLocal, StatusBarRemote,
SymlinkPopup, SyncBrowsingMkdirPopup, WaitPopup, WatchedPathsList, WatcherPopup, SymlinkPopup, SyncBrowsingMkdirPopup, WaitPopup, WatchedPathsList, WatcherPopup,
}; };
pub use transfer::{ExplorerFind, ExplorerLocal, ExplorerRemote}; pub use transfer::{ExplorerFind, ExplorerFuzzy, ExplorerLocal, ExplorerRemote};
pub use self::log::Log; pub use self::log::Log;

View File

@@ -583,89 +583,6 @@ impl Component<Msg, NoUserEvent> for FileInfoPopup {
} }
} }
#[derive(MockComponent)]
pub struct FindPopup {
component: Input,
}
impl FindPopup {
pub fn new(color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.input_type(InputType::Text)
.placeholder("*.txt", Style::default().fg(Color::Rgb(128, 128, 128)))
.title("Search files by name or wildmatch", Alignment::Center),
}
}
}
impl Component<Msg, NoUserEvent> for FindPopup {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(KeyEvent {
code: Key::Left, ..
}) => {
self.perform(Cmd::Move(Direction::Left));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Right, ..
}) => {
self.perform(Cmd::Move(Direction::Right));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Home, ..
}) => {
self.perform(Cmd::GoTo(Position::Begin));
Some(Msg::None)
}
Event::Keyboard(KeyEvent { code: Key::End, .. }) => {
self.perform(Cmd::GoTo(Position::End));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Delete, ..
}) => {
self.perform(Cmd::Cancel);
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Backspace,
..
}) => {
self.perform(Cmd::Delete);
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Char(ch),
..
}) => {
self.perform(Cmd::Type(ch));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Enter, ..
}) => match self.state() {
State::One(StateValue::String(i)) => {
Some(Msg::Transfer(TransferMsg::SearchFile(i)))
}
_ => Some(Msg::None),
},
Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
Some(Msg::Ui(UiMsg::CloseFindPopup))
}
_ => None,
}
}
}
#[derive(MockComponent)] #[derive(MockComponent)]
pub struct GoToPopup { pub struct GoToPopup {
component: Input, component: Input,
@@ -1675,6 +1592,7 @@ impl SortingPopup {
FileSorting::ModifyTime => 1, FileSorting::ModifyTime => 1,
FileSorting::Name => 0, FileSorting::Name => 0,
FileSorting::Size => 3, FileSorting::Size => 3,
FileSorting::None => 0,
}), }),
} }
} }
@@ -1778,6 +1696,7 @@ fn file_sorting_label(sorting: FileSorting) -> &'static str {
FileSorting::ModifyTime => "By modify time", FileSorting::ModifyTime => "By modify time",
FileSorting::Name => "By name", FileSorting::Name => "By name",
FileSorting::Size => "By size", FileSorting::Size => "By size",
FileSorting::None => "",
} }
} }

View File

@@ -0,0 +1,160 @@
use tui_realm_stdlib::Input;
use tuirealm::command::{Cmd, CmdResult};
use tuirealm::props::{Alignment, AttrValue, Attribute, Borders, Color, Table};
use tuirealm::tui::layout::{Constraint, Direction, Layout};
use tuirealm::{MockComponent, State};
use super::file_list::FileList;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
List,
#[default]
Search,
}
#[derive(Default)]
struct OwnStates {
focus: Focus,
}
impl OwnStates {
pub fn next(&mut self) {
self.focus = match self.focus {
Focus::List => Focus::Search,
Focus::Search => Focus::List,
};
}
}
#[derive(Default)]
pub struct FileListWithSearch {
file_list: FileList,
search: Input,
states: OwnStates,
}
impl FileListWithSearch {
pub fn focus(&self) -> Focus {
self.states.focus
}
pub fn foreground(mut self, fg: Color) -> Self {
self.file_list
.attr(Attribute::Foreground, AttrValue::Color(fg));
self.search
.attr(Attribute::Foreground, AttrValue::Color(fg));
self
}
pub fn background(mut self, bg: Color) -> Self {
self.file_list
.attr(Attribute::Background, AttrValue::Color(bg));
self.search
.attr(Attribute::Background, AttrValue::Color(bg));
self
}
pub fn borders(mut self, b: Borders) -> Self {
self.file_list
.attr(Attribute::Borders, AttrValue::Borders(b.clone()));
self.search.attr(Attribute::Borders, AttrValue::Borders(b));
self
}
pub fn title<S: AsRef<str>>(mut self, t: S, a: Alignment) -> Self {
self.file_list.attr(
Attribute::Title,
AttrValue::Title((t.as_ref().to_string(), a)),
);
self.search.attr(
Attribute::Title,
AttrValue::Title(("Fuzzy search".to_string(), a)),
);
self
}
pub fn highlighted_color(mut self, c: Color) -> Self {
self.file_list
.attr(Attribute::HighlightedColor, AttrValue::Color(c));
self
}
pub fn rows(mut self, rows: Table) -> Self {
self.file_list
.attr(Attribute::Content, AttrValue::Table(rows));
self
}
}
impl MockComponent for FileListWithSearch {
fn view(&mut self, frame: &mut tuirealm::Frame, area: tuirealm::tui::layout::Rect) {
// split the area in two
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(3), // Search
Constraint::Fill(1), // File list
]
.as_ref(),
)
.split(area);
// render the search input
self.search.view(frame, chunks[0]);
// render the file list
self.file_list.view(frame, chunks[1]);
}
fn query(&self, attr: Attribute) -> Option<AttrValue> {
self.file_list.query(attr)
}
fn attr(&mut self, attr: Attribute, value: AttrValue) {
if attr == Attribute::Focus {
let value = value.unwrap_flag();
match value {
true => self.states.focus = Focus::Search,
false => self.states.focus = Focus::List,
}
self.search.attr(
Attribute::Focus,
AttrValue::Flag(self.states.focus == Focus::Search),
);
self.file_list.attr(
Attribute::Focus,
AttrValue::Flag(self.states.focus == Focus::List),
);
} else {
self.file_list.attr(attr, value);
}
}
fn state(&self) -> State {
match self.states.focus {
Focus::List => self.file_list.state(),
Focus::Search => self.search.state(),
}
}
fn perform(&mut self, cmd: Cmd) -> CmdResult {
match cmd {
Cmd::Change => {
self.states.next();
self.search.attr(
Attribute::Focus,
AttrValue::Flag(self.states.focus == Focus::Search),
);
self.file_list.attr(
Attribute::Focus,
AttrValue::Flag(self.states.focus == Focus::List),
);
CmdResult::None
}
cmd if self.states.focus == Focus::Search => self.search.perform(cmd),
cmd => self.file_list.perform(cmd),
}
}
}

View File

@@ -2,14 +2,220 @@
//! //!
//! file transfer components //! file transfer components
use super::{Msg, TransferMsg, UiMsg};
mod file_list; mod file_list;
use file_list::FileList; mod file_list_with_search;
use tuirealm::command::{Cmd, Direction, Position};
use tuirealm::command::{Cmd, CmdResult, Direction, Position};
use tuirealm::event::{Key, KeyEvent, KeyModifiers}; use tuirealm::event::{Key, KeyEvent, KeyModifiers};
use tuirealm::props::{Alignment, Borders, Color, TextSpan}; use tuirealm::props::{Alignment, Borders, Color, TextSpan};
use tuirealm::{Component, Event, MockComponent, NoUserEvent}; use tuirealm::{Component, Event, MockComponent, NoUserEvent, State, StateValue};
use self::file_list::FileList;
use self::file_list_with_search::FileListWithSearch;
use super::{Msg, TransferMsg, UiMsg};
#[derive(MockComponent)]
pub struct ExplorerFuzzy {
component: FileListWithSearch,
}
impl ExplorerFuzzy {
pub fn new<S: AsRef<str>>(title: S, files: &[&str], bg: Color, fg: Color, hg: Color) -> Self {
Self {
component: FileListWithSearch::default()
.background(bg)
.borders(Borders::default().color(hg))
.foreground(fg)
.highlighted_color(hg)
.title(title, Alignment::Left)
.rows(files.iter().map(|x| vec![TextSpan::from(x)]).collect()),
}
}
fn on_search(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(KeyEvent {
code: Key::Left, ..
}) => {
self.perform(Cmd::Move(Direction::Left));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Right, ..
}) => {
self.perform(Cmd::Move(Direction::Right));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Home, ..
}) => {
self.perform(Cmd::GoTo(Position::Begin));
Some(Msg::None)
}
Event::Keyboard(KeyEvent { code: Key::End, .. }) => {
self.perform(Cmd::GoTo(Position::End));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Delete, ..
}) => {
self.perform(Cmd::Cancel);
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Backspace,
..
}) => {
self.perform(Cmd::Delete);
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Tab | Key::Up | Key::Down,
..
}) => {
self.perform(Cmd::Change);
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Char(ch),
..
}) => match self.perform(Cmd::Type(ch)) {
CmdResult::Changed(State::One(StateValue::String(search))) => {
Some(Msg::Ui(UiMsg::FuzzySearch(search)))
}
_ => Some(Msg::None),
},
Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
Some(Msg::Ui(UiMsg::CloseFindExplorer))
}
_ => None,
}
}
fn on_file_list(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(KeyEvent {
code: Key::Down, ..
}) => {
self.perform(Cmd::Move(Direction::Down));
Some(Msg::None)
}
Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
self.perform(Cmd::Move(Direction::Up));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::PageDown,
..
}) => {
self.perform(Cmd::Scroll(Direction::Down));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::PageUp, ..
}) => {
self.perform(Cmd::Scroll(Direction::Up));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Home, ..
}) => {
self.perform(Cmd::GoTo(Position::Begin));
Some(Msg::None)
}
Event::Keyboard(KeyEvent { code: Key::End, .. }) => {
self.perform(Cmd::GoTo(Position::End));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Char('a'),
modifiers: KeyModifiers::CONTROL,
}) => {
let _ = self.perform(Cmd::Custom(file_list::FILE_LIST_CMD_SELECT_ALL));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Char('a'),
modifiers: KeyModifiers::ALT,
}) => {
let _ = self.perform(Cmd::Custom(file_list::FILE_LIST_CMD_DESELECT_ALL));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Char('m'),
modifiers: KeyModifiers::NONE,
}) => {
let _ = self.perform(Cmd::Toggle);
Some(Msg::None)
}
Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
self.perform(Cmd::Change);
Some(Msg::None)
}
// -- comp msg
Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
Some(Msg::Ui(UiMsg::CloseFindExplorer))
}
Event::Keyboard(KeyEvent {
code: Key::Left | Key::Right,
..
}) => Some(Msg::Ui(UiMsg::ChangeTransferWindow)),
Event::Keyboard(KeyEvent {
code: Key::Enter, ..
}) => Some(Msg::Transfer(TransferMsg::EnterDirectory)),
Event::Keyboard(KeyEvent {
code: Key::Char(' '),
..
}) => Some(Msg::Transfer(TransferMsg::TransferFile)),
Event::Keyboard(KeyEvent {
code: Key::Backspace,
..
}) => Some(Msg::Transfer(TransferMsg::GoToPreviousDirectory)),
Event::Keyboard(KeyEvent {
code: Key::Char('a'),
modifiers: KeyModifiers::NONE,
}) => Some(Msg::Ui(UiMsg::ToggleHiddenFiles)),
Event::Keyboard(KeyEvent {
code: Key::Char('b'),
modifiers: KeyModifiers::NONE,
}) => Some(Msg::Ui(UiMsg::ShowFileSortingPopup)),
Event::Keyboard(KeyEvent {
code: Key::Char('e') | Key::Delete | Key::Function(8),
modifiers: KeyModifiers::NONE,
}) => Some(Msg::Ui(UiMsg::ShowDeletePopup)),
Event::Keyboard(KeyEvent {
code: Key::Char('i'),
modifiers: KeyModifiers::NONE,
}) => Some(Msg::Ui(UiMsg::ShowFileInfoPopup)),
Event::Keyboard(KeyEvent {
code: Key::Char('s') | Key::Function(2),
modifiers: KeyModifiers::NONE,
}) => Some(Msg::Ui(UiMsg::ShowSaveAsPopup)),
Event::Keyboard(KeyEvent {
code: Key::Char('v') | Key::Function(3),
modifiers: KeyModifiers::NONE,
}) => Some(Msg::Transfer(TransferMsg::OpenFile)),
Event::Keyboard(KeyEvent {
code: Key::Char('w'),
modifiers: KeyModifiers::NONE,
}) => Some(Msg::Ui(UiMsg::ShowOpenWithPopup)),
Event::Keyboard(KeyEvent {
code: Key::Char('z'),
modifiers: KeyModifiers::NONE,
}) => Some(Msg::Ui(UiMsg::ShowChmodPopup)),
_ => None,
}
}
}
impl Component<Msg, NoUserEvent> for ExplorerFuzzy {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
match self.component.focus() {
file_list_with_search::Focus::List => self.on_file_list(ev),
file_list_with_search::Focus::Search => self.on_search(ev),
}
}
}
#[derive(MockComponent)] #[derive(MockComponent)]
pub struct ExplorerFind { pub struct ExplorerFind {
@@ -261,7 +467,7 @@ impl Component<Msg, NoUserEvent> for ExplorerLocal {
Event::Keyboard(KeyEvent { Event::Keyboard(KeyEvent {
code: Key::Char('f'), code: Key::Char('f'),
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
}) => Some(Msg::Ui(UiMsg::ShowFindPopup)), }) => Some(Msg::Transfer(TransferMsg::InitFuzzySearch)),
Event::Keyboard(KeyEvent { Event::Keyboard(KeyEvent {
code: Key::Char('g'), code: Key::Char('g'),
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
@@ -457,7 +663,7 @@ impl Component<Msg, NoUserEvent> for ExplorerRemote {
Event::Keyboard(KeyEvent { Event::Keyboard(KeyEvent {
code: Key::Char('f'), code: Key::Char('f'),
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
}) => Some(Msg::Ui(UiMsg::ShowFindPopup)), }) => Some(Msg::Transfer(TransferMsg::InitFuzzySearch)),
Event::Keyboard(KeyEvent { Event::Keyboard(KeyEvent {
code: Key::Char('g'), code: Key::Char('g'),
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,

View File

@@ -4,12 +4,15 @@
use std::path::Path; use std::path::Path;
use nucleo::Utf32String;
use remotefs::File; use remotefs::File;
use crate::explorer::builder::FileExplorerBuilder; use crate::explorer::builder::FileExplorerBuilder;
use crate::explorer::{FileExplorer, FileSorting, GroupDirs}; use crate::explorer::{FileExplorer, FileSorting};
use crate::system::config_client::ConfigClient; use crate::system::config_client::ConfigClient;
const FUZZY_SEARCH_THRESHOLD: u16 = 50;
/// File explorer tab /// File explorer tab
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub enum FileExplorerTab { pub enum FileExplorerTab {
@@ -28,10 +31,10 @@ pub enum FoundExplorerTab {
/// Browser contains the browser options /// Browser contains the browser options
pub struct Browser { pub struct Browser {
local: FileExplorer, // Local File explorer state local: FileExplorer, // Local File explorer state
remote: FileExplorer, // Remote File explorer state remote: FileExplorer, // Remote File explorer state
found: Option<(FoundExplorerTab, FileExplorer)>, // File explorer for find result found: Option<Found>, // File explorer for find result
tab: FileExplorerTab, // Current selected tab tab: FileExplorerTab, // Current selected tab
pub sync_browsing: bool, pub sync_browsing: bool,
} }
@@ -64,17 +67,35 @@ impl Browser {
} }
pub fn found(&self) -> Option<&FileExplorer> { pub fn found(&self) -> Option<&FileExplorer> {
self.found.as_ref().map(|x| &x.1) self.found.as_ref().map(|x| &x.explorer)
} }
pub fn found_mut(&mut self) -> Option<&mut FileExplorer> { pub fn found_mut(&mut self) -> Option<&mut FileExplorer> {
self.found.as_mut().map(|x| &mut x.1) self.found.as_mut().map(|x| &mut x.explorer)
}
/// Perform fuzzy search on found tab
pub fn fuzzy_search(&mut self, needle: &str) {
if let Some(x) = self.found.as_mut() {
x.fuzzy_search(needle)
}
}
/// Initialize fuzzy search
pub fn init_fuzzy_search(&mut self) {
if let Some(explorer) = self.found_mut() {
explorer.set_files(vec![]);
}
} }
pub fn set_found(&mut self, tab: FoundExplorerTab, files: Vec<File>, wrkdir: &Path) { pub fn set_found(&mut self, tab: FoundExplorerTab, files: Vec<File>, wrkdir: &Path) {
let mut explorer = Self::build_found_explorer(wrkdir); let mut explorer = Self::build_found_explorer(wrkdir);
explorer.set_files(files); explorer.set_files(files.clone());
self.found = Some((tab, explorer)); self.found = Some(Found {
tab,
explorer,
search_results: files,
});
} }
pub fn del_found(&mut self) { pub fn del_found(&mut self) {
@@ -83,7 +104,7 @@ impl Browser {
/// Returns found tab if any /// Returns found tab if any
pub fn found_tab(&self) -> Option<FoundExplorerTab> { pub fn found_tab(&self) -> Option<FoundExplorerTab> {
self.found.as_ref().map(|x| x.0) self.found.as_ref().map(|x| x.tab)
} }
pub fn tab(&self) -> FileExplorerTab { pub fn tab(&self) -> FileExplorerTab {
@@ -129,8 +150,8 @@ impl Browser {
/// Build explorer reading from `ConfigClient`, for found result (has some differences) /// Build explorer reading from `ConfigClient`, for found result (has some differences)
fn build_found_explorer(wrkdir: &Path) -> FileExplorer { fn build_found_explorer(wrkdir: &Path) -> FileExplorer {
FileExplorerBuilder::new() FileExplorerBuilder::new()
.with_file_sorting(FileSorting::Name) .with_file_sorting(FileSorting::None)
.with_group_dirs(Some(GroupDirs::First)) .with_group_dirs(None)
.with_hidden_files(true) .with_hidden_files(true)
.with_stack_size(0) .with_stack_size(0)
.with_formatter(Some( .with_formatter(Some(
@@ -139,3 +160,48 @@ impl Browser {
.build() .build()
} }
} }
/// Found state
struct Found {
explorer: FileExplorer,
/// Search results; original copy of files
search_results: Vec<File>,
tab: FoundExplorerTab,
}
impl Found {
/// Fuzzy search from `search_results` and update `explorer.files` with the results.
pub fn fuzzy_search(&mut self, needle: &str) {
let search = Utf32String::from(needle);
let mut nucleo = nucleo::Matcher::new(nucleo::Config::DEFAULT.match_paths());
// get scores
let mut fuzzy_results_with_score = self
.search_results
.iter()
.map(|f| {
(
Utf32String::from(f.path().to_string_lossy().into_owned()),
f,
)
})
.filter_map(|(path, file)| {
nucleo
.fuzzy_match(path.slice(..), search.slice(..))
.map(|score| (path, file, score))
})
.filter(|(_, _, score)| *score >= FUZZY_SEARCH_THRESHOLD)
.collect::<Vec<_>>();
// sort by score; highest first
fuzzy_results_with_score.sort_by(|(_, _, a), (_, _, b)| b.cmp(a));
// update files
self.explorer.set_files(
fuzzy_results_with_score
.into_iter()
.map(|(_, file, _)| file.clone())
.collect(),
);
}
}

View File

@@ -50,7 +50,6 @@ enum Id {
FatalPopup, FatalPopup,
FileInfoPopup, FileInfoPopup,
FilterPopup, FilterPopup,
FindPopup,
FooterBar, FooterBar,
GlobalListener, GlobalListener,
GotoPopup, GotoPopup,
@@ -104,6 +103,7 @@ enum TransferMsg {
GoTo(String), GoTo(String),
GoToParentDirectory, GoToParentDirectory,
GoToPreviousDirectory, GoToPreviousDirectory,
InitFuzzySearch,
Mkdir(String), Mkdir(String),
NewFile(String), NewFile(String),
OpenFile, OpenFile,
@@ -112,7 +112,6 @@ enum TransferMsg {
ReloadDir, ReloadDir,
RenameFile(String), RenameFile(String),
SaveFileAs(String), SaveFileAs(String),
SearchFile(String),
ToggleWatch, ToggleWatch,
ToggleWatchFor(usize), ToggleWatchFor(usize),
TransferFile, TransferFile,
@@ -133,7 +132,6 @@ enum UiMsg {
CloseFileSortingPopup, CloseFileSortingPopup,
CloseFilterPopup, CloseFilterPopup,
CloseFindExplorer, CloseFindExplorer,
CloseFindPopup,
CloseGotoPopup, CloseGotoPopup,
CloseKeybindingsPopup, CloseKeybindingsPopup,
CloseMkdirPopup, CloseMkdirPopup,
@@ -147,6 +145,7 @@ enum UiMsg {
CloseWatcherPopup, CloseWatcherPopup,
Disconnect, Disconnect,
FilterFiles(String), FilterFiles(String),
FuzzySearch(String),
LogBackTabbed, LogBackTabbed,
Quit, Quit,
ReplacePopupTabbed, ReplacePopupTabbed,
@@ -158,7 +157,6 @@ enum UiMsg {
ShowFileInfoPopup, ShowFileInfoPopup,
ShowFileSortingPopup, ShowFileSortingPopup,
ShowFilterPopup, ShowFilterPopup,
ShowFindPopup,
ShowGotoPopup, ShowGotoPopup,
ShowKeybindingsPopup, ShowKeybindingsPopup,
ShowLogPanel, ShowLogPanel,

View File

@@ -211,6 +211,56 @@ impl FileTransferActivity {
_ => {} _ => {}
} }
} }
TransferMsg::InitFuzzySearch => {
// Mount wait
self.mount_blocking_wait("Scanning current directory…");
// Find
let res: Result<Vec<File>, String> = match self.browser.tab() {
FileExplorerTab::Local => self.action_walkdir_local(),
FileExplorerTab::Remote => self.action_walkdir_remote(),
_ => panic!("Trying to search for files, while already in a find result"),
};
// Umount wait
self.umount_wait();
// Match result
match res {
Err(err) => {
// Mount error
self.mount_error(err.as_str());
}
Ok(files) if files.is_empty() => {
// If no file has been found notify user
self.mount_info("There are no files in the current directory");
}
Ok(files) => {
// Get wrkdir
let wrkdir = match self.browser.tab() {
FileExplorerTab::Local => self.local().wrkdir.clone(),
_ => self.remote().wrkdir.clone(),
};
// Create explorer and load files
self.browser.set_found(
match self.browser.tab() {
FileExplorerTab::Local => FoundExplorerTab::Local,
_ => FoundExplorerTab::Remote,
},
files,
wrkdir.as_path(),
);
// init fuzzy search to display nothing
self.browser.init_fuzzy_search();
// Mount result widget
self.mount_find(format!(r#"Searching at "{}""#, wrkdir.display()), true);
self.update_find_list();
// Initialize tab
self.browser.change_tab(match self.browser.tab() {
FileExplorerTab::Local => FileExplorerTab::FindLocal,
FileExplorerTab::Remote => FileExplorerTab::FindRemote,
_ => FileExplorerTab::FindLocal,
});
}
}
}
TransferMsg::Mkdir(dir) => { TransferMsg::Mkdir(dir) => {
match self.browser.tab() { match self.browser.tab() {
FileExplorerTab::Local => self.action_local_mkdir(dir), FileExplorerTab::Local => self.action_local_mkdir(dir),
@@ -281,57 +331,7 @@ impl FileTransferActivity {
// Reload files // Reload files
self.update_browser_file_list_swapped(); self.update_browser_file_list_swapped();
} }
TransferMsg::SearchFile(search) => {
self.umount_find_input();
// Mount wait
self.mount_blocking_wait(format!(r#"Searching for "{search}"…"#).as_str());
// Find
let res: Result<Vec<File>, String> = match self.browser.tab() {
FileExplorerTab::Local => self.action_local_find(search.clone()),
FileExplorerTab::Remote => self.action_remote_find(search.clone()),
_ => panic!("Trying to search for files, while already in a find result"),
};
// Umount wait
self.umount_wait();
// Match result
match res {
Err(err) => {
// Mount error
self.mount_error(err.as_str());
}
Ok(files) if files.is_empty() => {
// If no file has been found notify user
self.mount_info(
format!(r#"Could not find any file matching "{search}""#).as_str(),
);
}
Ok(files) => {
// Get wrkdir
let wrkdir = match self.browser.tab() {
FileExplorerTab::Local => self.local().wrkdir.clone(),
_ => self.remote().wrkdir.clone(),
};
// Create explorer and load files
self.browser.set_found(
match self.browser.tab() {
FileExplorerTab::Local => FoundExplorerTab::Local,
_ => FoundExplorerTab::Remote,
},
files,
wrkdir.as_path(),
);
// Mount result widget
self.mount_find(&search);
self.update_find_list();
// Initialize tab
self.browser.change_tab(match self.browser.tab() {
FileExplorerTab::Local => FileExplorerTab::FindLocal,
FileExplorerTab::Remote => FileExplorerTab::FindRemote,
_ => FileExplorerTab::FindLocal,
});
}
}
}
TransferMsg::ToggleWatch => self.action_toggle_watch(), TransferMsg::ToggleWatch => self.action_toggle_watch(),
TransferMsg::ToggleWatchFor(index) => self.action_toggle_watch_for(index), TransferMsg::ToggleWatchFor(index) => self.action_toggle_watch_for(index),
TransferMsg::TransferFile => { TransferMsg::TransferFile => {
@@ -405,7 +405,6 @@ impl FileTransferActivity {
self.finalize_find(); self.finalize_find();
self.umount_find(); self.umount_find();
} }
UiMsg::CloseFindPopup => self.umount_find_input(),
UiMsg::CloseGotoPopup => self.umount_goto(), UiMsg::CloseGotoPopup => self.umount_goto(),
UiMsg::CloseKeybindingsPopup => self.umount_help(), UiMsg::CloseKeybindingsPopup => self.umount_help(),
UiMsg::CloseMkdirPopup => self.umount_mkdir(), UiMsg::CloseMkdirPopup => self.umount_mkdir(),
@@ -439,7 +438,7 @@ impl FileTransferActivity {
wrkdir.as_path(), wrkdir.as_path(),
); );
// Mount result widget // Mount result widget
self.mount_find(&filter); self.mount_find(&filter, false);
self.update_find_list(); self.update_find_list();
// Initialize tab // Initialize tab
self.browser.change_tab(match self.browser.tab() { self.browser.change_tab(match self.browser.tab() {
@@ -448,6 +447,10 @@ impl FileTransferActivity {
_ => FileExplorerTab::FindLocal, _ => FileExplorerTab::FindLocal,
}); });
} }
UiMsg::FuzzySearch(needle) => {
self.browser.fuzzy_search(&needle);
self.update_find_list();
}
UiMsg::ShowLogPanel => { UiMsg::ShowLogPanel => {
assert!(self.app.active(&Id::Log).is_ok()); assert!(self.app.active(&Id::Log).is_ok());
} }
@@ -514,7 +517,6 @@ impl FileTransferActivity {
} }
UiMsg::ShowFileSortingPopup => self.mount_file_sorting(), UiMsg::ShowFileSortingPopup => self.mount_file_sorting(),
UiMsg::ShowFilterPopup => self.mount_filter(), UiMsg::ShowFilterPopup => self.mount_filter(),
UiMsg::ShowFindPopup => self.mount_find_input(),
UiMsg::ShowGotoPopup => self.mount_goto(), UiMsg::ShowGotoPopup => self.mount_goto(),
UiMsg::ShowKeybindingsPopup => self.mount_help(), UiMsg::ShowKeybindingsPopup => self.mount_help(),
UiMsg::ShowMkdirPopup => self.mount_mkdir(), UiMsg::ShowMkdirPopup => self.mount_mkdir(),

View File

@@ -177,11 +177,6 @@ impl FileTransferActivity {
f.render_widget(Clear, popup); f.render_widget(Clear, popup);
// make popup // make popup
self.app.view(&Id::FilterPopup, f, popup); self.app.view(&Id::FilterPopup, f, popup);
} else if self.app.mounted(&Id::FindPopup) {
let popup = Popup(Size::Percentage(40), Size::Unit(3)).draw_in(f.size());
f.render_widget(Clear, popup);
// make popup
self.app.view(&Id::FindPopup, f, popup);
} else if self.app.mounted(&Id::GotoPopup) { } else if self.app.mounted(&Id::GotoPopup) {
let popup = Popup(Size::Percentage(40), Size::Unit(3)).draw_in(f.size()); let popup = Popup(Size::Percentage(40), Size::Unit(3)).draw_in(f.size());
f.render_widget(Clear, popup); f.render_widget(Clear, popup);
@@ -515,7 +510,7 @@ impl FileTransferActivity {
let _ = self.app.umount(&Id::ExecPopup); let _ = self.app.umount(&Id::ExecPopup);
} }
pub(super) fn mount_find(&mut self, search: &str) { pub(super) fn mount_find(&mut self, msg: impl ToString, fuzzy_search: bool) {
// Get color // Get color
let (bg, fg, hg) = match self.browser.tab() { let (bg, fg, hg) = match self.browser.tab() {
FileExplorerTab::Local | FileExplorerTab::FindLocal => ( FileExplorerTab::Local | FileExplorerTab::FindLocal => (
@@ -529,18 +524,29 @@ impl FileTransferActivity {
self.theme().transfer_remote_explorer_highlighted, self.theme().transfer_remote_explorer_highlighted,
), ),
}; };
// Mount component // Mount component
assert!(self assert!(self
.app .app
.remount( .remount(
Id::ExplorerFind, Id::ExplorerFind,
Box::new(components::ExplorerFind::new( if fuzzy_search {
format!(r#"Search results for "{search}""#), Box::new(components::ExplorerFuzzy::new(
&[], msg.to_string(),
bg, &[],
fg, bg,
hg fg,
)), hg,
))
} else {
Box::new(components::ExplorerFind::new(
msg.to_string(),
&[],
bg,
fg,
hg,
))
},
vec![], vec![],
) )
.is_ok()); .is_ok());
@@ -551,24 +557,6 @@ impl FileTransferActivity {
let _ = self.app.umount(&Id::ExplorerFind); let _ = self.app.umount(&Id::ExplorerFind);
} }
pub(super) fn mount_find_input(&mut self) {
let input_color = self.theme().misc_input_dialog;
assert!(self
.app
.remount(
Id::FindPopup,
Box::new(components::FindPopup::new(input_color)),
vec![],
)
.is_ok());
assert!(self.app.active(&Id::FindPopup).is_ok());
}
pub(super) fn umount_find_input(&mut self) {
// Umount input find
let _ = self.app.umount(&Id::FindPopup);
}
pub(super) fn mount_goto(&mut self) { pub(super) fn mount_goto(&mut self) {
let input_color = self.theme().misc_input_dialog; let input_color = self.theme().misc_input_dialog;
assert!(self assert!(self
@@ -1094,38 +1082,33 @@ impl FileTransferActivity {
Box::new(SubClause::Not(Box::new(SubClause::IsMounted( Box::new(SubClause::Not(Box::new(SubClause::IsMounted(
Id::SortingPopup, Id::SortingPopup,
)))), )))),
Box::new(SubClause::And(
Box::new(SubClause::Not(Box::new(SubClause::IsMounted(
Id::FindPopup,
)))),
Box::new(SubClause::And( Box::new(SubClause::And(
Box::new(SubClause::Not(Box::new(SubClause::IsMounted( Box::new(SubClause::Not(Box::new(SubClause::IsMounted(
Id::SyncBrowsingMkdirPopup, Id::SyncBrowsingMkdirPopup,
)))),
Box::new(SubClause::And(
Box::new(SubClause::Not(Box::new(SubClause::IsMounted(
Id::SymlinkPopup,
)))), )))),
Box::new(SubClause::And( Box::new(SubClause::And(
Box::new(SubClause::Not(Box::new(SubClause::IsMounted( Box::new(SubClause::Not(Box::new(SubClause::IsMounted(
Id::SymlinkPopup, Id::WatcherPopup,
)))), )))),
Box::new(SubClause::And( Box::new(SubClause::And(
Box::new(SubClause::Not(Box::new(SubClause::IsMounted( Box::new(SubClause::Not(Box::new(SubClause::IsMounted(
Id::WatcherPopup, Id::WatchedPathsList,
)))), )))),
Box::new(SubClause::And( Box::new(SubClause::And(
Box::new(SubClause::Not(Box::new(SubClause::IsMounted( Box::new(SubClause::Not(Box::new(SubClause::IsMounted(
Id::WatchedPathsList, Id::ChmodPopup,
)))), )))),
Box::new(SubClause::And( Box::new(SubClause::And(
Box::new(SubClause::Not(Box::new(SubClause::IsMounted( Box::new(SubClause::Not(Box::new(SubClause::IsMounted(
Id::ChmodPopup, Id::WaitPopup,
)))),
Box::new(SubClause::Not(Box::new(SubClause::IsMounted(
Id::FilterPopup,
)))), )))),
Box::new(SubClause::And(
Box::new(SubClause::Not(Box::new(SubClause::IsMounted(
Id::WaitPopup,
)))),
Box::new(SubClause::Not(Box::new(SubClause::IsMounted(
Id::FilterPopup,
)))),
)),
)), )),
)), )),
)), )),