From ffc635e434daa17692e1452d82f960bd55d5d896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=89=E5=92=B2=E9=9B=85=20=C2=B7=20Misaki=20Masa?= Date: Fri, 16 May 2025 16:37:04 +0800 Subject: [PATCH] feat: improve path auto-completion results (#2765) --- Cargo.lock | 1 + yazi-core/src/cmp/cmp.rs | 9 +++-- yazi-core/src/cmp/commands/show.rs | 43 +++++++++------------ yazi-core/src/cmp/commands/trigger.rs | 18 ++++----- yazi-fm/src/cmp/cmp.rs | 8 ++-- yazi-proxy/src/input.rs | 6 ++- yazi-proxy/src/options/cmp.rs | 13 +++++++ yazi-proxy/src/options/mod.rs | 2 +- yazi-shared/src/chars.rs | 22 ++++++++++- yazi-widgets/Cargo.toml | 1 + yazi-widgets/src/input/commands/complete.rs | 28 ++++++++------ 11 files changed, 94 insertions(+), 57 deletions(-) create mode 100644 yazi-proxy/src/options/cmp.rs diff --git a/Cargo.lock b/Cargo.lock index 63c0d099..cb74512d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3659,6 +3659,7 @@ dependencies = [ "yazi-config", "yazi-macro", "yazi-plugin", + "yazi-proxy", "yazi-shared", ] diff --git a/yazi-core/src/cmp/cmp.rs b/yazi-core/src/cmp/cmp.rs index ec463006..1b19cbc8 100644 --- a/yazi-core/src/cmp/cmp.rs +++ b/yazi-core/src/cmp/cmp.rs @@ -1,11 +1,12 @@ use std::{collections::HashMap, path::PathBuf}; +use yazi_proxy::options::CmpItem; use yazi_shared::Id; #[derive(Default)] pub struct Cmp { - pub(super) caches: HashMap>, - pub(super) cands: Vec, + pub(super) caches: HashMap>, + pub(super) cands: Vec, pub(super) offset: usize, pub cursor: usize, @@ -16,7 +17,7 @@ pub struct Cmp { impl Cmp { // --- Cands #[inline] - pub fn window(&self) -> &[String] { + pub fn window(&self) -> &[CmpItem] { let end = (self.offset + self.limit()).min(self.cands.len()); &self.cands[self.offset..end] } @@ -25,7 +26,7 @@ impl Cmp { pub fn limit(&self) -> usize { self.cands.len().min(10) } #[inline] - pub fn selected(&self) -> Option<&String> { self.cands.get(self.cursor) } + pub fn selected(&self) -> Option<&CmpItem> { self.cands.get(self.cursor) } // --- Cursor #[inline] diff --git a/yazi-core/src/cmp/commands/show.rs b/yazi-core/src/cmp/commands/show.rs index 987e1849..23b933a3 100644 --- a/yazi-core/src/cmp/commands/show.rs +++ b/yazi-core/src/cmp/commands/show.rs @@ -1,14 +1,15 @@ use std::{borrow::Cow, mem, ops::ControlFlow, path::PathBuf}; use yazi_macro::render; -use yazi_shared::{Id, event::{Cmd, CmdCow, Data}}; +use yazi_proxy::options::CmpItem; +use yazi_shared::{Id, event::{Cmd, CmdCow, Data}, osstr_contains, osstr_starts_with}; use crate::cmp::Cmp; const LIMIT: usize = 30; struct Opt { - cache: Vec, + cache: Vec, cache_name: PathBuf, word: Cow<'static, str>, ticket: Id, @@ -55,34 +56,28 @@ impl Cmp { render!(); } - fn match_candidates(word: &str, cache: &[String]) -> Vec { + fn match_candidates(word: &str, cache: &[CmpItem]) -> Vec { let smart = !word.bytes().any(|c| c.is_ascii_uppercase()); - let flow = cache.iter().try_fold( - (Vec::with_capacity(LIMIT), Vec::with_capacity(LIMIT)), - |(mut prefixed, mut fuzzy), s| { - if (smart && s.to_lowercase().starts_with(word)) || (!smart && s.starts_with(word)) { - if s != word { - prefixed.push(s); - if prefixed.len() >= LIMIT { - return ControlFlow::Break((prefixed, fuzzy)); - } - } - } else if fuzzy.len() < LIMIT - prefixed.len() && s.contains(word) { - // here we don't break the control flow, since we want more exact matching. - fuzzy.push(s) + let flow = cache.iter().try_fold((Vec::new(), Vec::new()), |(mut exact, mut fuzzy), item| { + if osstr_starts_with(&item.name, word, smart) { + exact.push(item); + if exact.len() >= LIMIT { + return ControlFlow::Break((exact, fuzzy)); } - ControlFlow::Continue((prefixed, fuzzy)) - }, - ); + } else if fuzzy.len() < LIMIT - exact.len() && osstr_contains(&item.name, word) { + // Here we don't break the control flow, since we want more exact matching. + fuzzy.push(item) + } + ControlFlow::Continue((exact, fuzzy)) + }); - let (mut prefixed, fuzzy) = match flow { + let (exact, fuzzy) = match flow { ControlFlow::Continue(v) => v, ControlFlow::Break(v) => v, }; - if prefixed.len() < LIMIT { - prefixed.extend(fuzzy.into_iter().take(LIMIT - prefixed.len())) - } - prefixed.into_iter().map(ToOwned::to_owned).collect() + + let it = fuzzy.into_iter().take(LIMIT - exact.len()); + exact.into_iter().chain(it).cloned().collect() } } diff --git a/yazi-core/src/cmp/commands/trigger.rs b/yazi-core/src/cmp/commands/trigger.rs index 19dd3c81..d207c7f5 100644 --- a/yazi-core/src/cmp/commands/trigger.rs +++ b/yazi-core/src/cmp/commands/trigger.rs @@ -3,7 +3,8 @@ use std::{borrow::Cow, mem, path::{MAIN_SEPARATOR_STR, PathBuf}}; use tokio::fs; use yazi_fs::{CWD, expand_path}; use yazi_macro::{emit, render}; -use yazi_shared::{Id, event::{Cmd, CmdCow, Data}}; +use yazi_proxy::options::CmpItem; +use yazi_shared::{Id, event::{Cmd, CmdCow, Data}, natsort}; use crate::cmp::Cmp; @@ -43,17 +44,16 @@ impl Cmp { tokio::spawn(async move { let mut dir = fs::read_dir(&parent).await?; let mut cache = vec![]; - while let Ok(Some(f)) = dir.next_entry().await { - let Ok(meta) = f.metadata().await else { continue }; - - cache.push(format!( - "{}{}", - f.file_name().to_string_lossy(), - if meta.is_dir() { MAIN_SEPARATOR_STR } else { "" }, - )); + while let Ok(Some(ent)) = dir.next_entry().await { + if let Ok(ft) = ent.file_type().await { + cache.push(CmpItem { name: ent.file_name(), is_dir: ft.is_dir() }); + } } if !cache.is_empty() { + cache.sort_unstable_by(|a, b| { + natsort(a.name.as_encoded_bytes(), b.name.as_encoded_bytes(), false) + }); emit!(Call( Cmd::new("cmp:show") .with_any("cache", cache) diff --git a/yazi-fm/src/cmp/cmp.rs b/yazi-fm/src/cmp/cmp.rs index d105c10a..3bf37ab9 100644 --- a/yazi-fm/src/cmp/cmp.rs +++ b/yazi-fm/src/cmp/cmp.rs @@ -1,4 +1,4 @@ -use std::path::MAIN_SEPARATOR; +use std::path::MAIN_SEPARATOR_STR; use ratatui::{buffer::Buffer, layout::Rect, widgets::{Block, BorderType, List, ListItem, Widget}}; use yazi_adapter::Dimension; @@ -23,10 +23,10 @@ impl Widget for Cmp<'_> { .iter() .enumerate() .map(|(i, x)| { - let icon = - if x.ends_with(MAIN_SEPARATOR) { &THEME.cmp.icon_folder } else { &THEME.cmp.icon_file }; + let icon = if x.is_dir { &THEME.cmp.icon_folder } else { &THEME.cmp.icon_file }; + let slash = if x.is_dir { MAIN_SEPARATOR_STR } else { "" }; - let mut item = ListItem::new(format!(" {icon} {x}")); + let mut item = ListItem::new(format!(" {icon} {}{slash}", x.name.display())); if i == self.cx.cmp.rel_cursor() { item = item.style(THEME.cmp.active); } else { diff --git a/yazi-proxy/src/input.rs b/yazi-proxy/src/input.rs index bdbbcf28..dd79480f 100644 --- a/yazi-proxy/src/input.rs +++ b/yazi-proxy/src/input.rs @@ -3,6 +3,8 @@ use yazi_config::popup::InputCfg; use yazi_macro::emit; use yazi_shared::{Id, errors::InputError, event::Cmd}; +use crate::options::CmpItem; + pub struct InputProxy; impl InputProxy { @@ -14,7 +16,7 @@ impl InputProxy { } #[inline] - pub fn complete(word: &str, ticket: Id) { - emit!(Call(Cmd::args("input:complete", &[word]).with("ticket", ticket))); + pub fn complete(item: &CmpItem, ticket: Id) { + emit!(Call(Cmd::new("input:complete").with_any("item", item.clone()).with("ticket", ticket))); } } diff --git a/yazi-proxy/src/options/cmp.rs b/yazi-proxy/src/options/cmp.rs new file mode 100644 index 00000000..ce7401a4 --- /dev/null +++ b/yazi-proxy/src/options/cmp.rs @@ -0,0 +1,13 @@ +use std::{ffi::OsString, path::MAIN_SEPARATOR_STR}; + +#[derive(Debug, Clone)] +pub struct CmpItem { + pub name: OsString, + pub is_dir: bool, +} + +impl CmpItem { + pub fn completable(&self) -> String { + format!("{}{}", self.name.to_string_lossy(), if self.is_dir { MAIN_SEPARATOR_STR } else { "" }) + } +} diff --git a/yazi-proxy/src/options/mod.rs b/yazi-proxy/src/options/mod.rs index c97e1a26..4ba644b7 100644 --- a/yazi-proxy/src/options/mod.rs +++ b/yazi-proxy/src/options/mod.rs @@ -1 +1 @@ -yazi_macro::mod_flat!(notify open plugin process search); +yazi_macro::mod_flat!(cmp notify open plugin process search); diff --git a/yazi-shared/src/chars.rs b/yazi-shared/src/chars.rs index c76ce38f..0d7983eb 100644 --- a/yazi-shared/src/chars.rs +++ b/yazi-shared/src/chars.rs @@ -1,5 +1,5 @@ use core::str; -use std::borrow::Cow; +use std::{borrow::Cow, ffi::OsStr}; pub const MIME_DIR: &str = "inode/directory"; @@ -106,3 +106,23 @@ pub fn replace_to_printable(s: &[String], tab_size: u8) -> String { } unsafe { String::from_utf8_unchecked(buf) } } + +pub fn osstr_contains(s: impl AsRef, needle: impl AsRef) -> bool { + memchr::memmem::find(s.as_ref().as_encoded_bytes(), needle.as_ref().as_encoded_bytes()).is_some() +} + +pub fn osstr_starts_with( + s: impl AsRef, + prefix: impl AsRef, + insensitive: bool, +) -> bool { + let (s, prefix) = (s.as_ref().as_encoded_bytes(), prefix.as_ref().as_encoded_bytes()); + if s.len() < prefix.len() { + return false; + } + if insensitive { + s[..prefix.len()].eq_ignore_ascii_case(prefix) + } else { + s[..prefix.len()] == *prefix + } +} diff --git a/yazi-widgets/Cargo.toml b/yazi-widgets/Cargo.toml index a0340688..7003bc5b 100644 --- a/yazi-widgets/Cargo.toml +++ b/yazi-widgets/Cargo.toml @@ -13,6 +13,7 @@ yazi-codegen = { path = "../yazi-codegen", version = "25.5.14" } yazi-config = { path = "../yazi-config", version = "25.5.14" } yazi-macro = { path = "../yazi-macro", version = "25.5.14" } yazi-plugin = { path = "../yazi-plugin", version = "25.5.14" } +yazi-proxy = { path = "../yazi-proxy", version = "25.5.14" } yazi-shared = { path = "../yazi-shared", version = "25.5.14" } # External dependencies diff --git a/yazi-widgets/src/input/commands/complete.rs b/yazi-widgets/src/input/commands/complete.rs index 9d123115..20a10ae1 100644 --- a/yazi-widgets/src/input/commands/complete.rs +++ b/yazi-widgets/src/input/commands/complete.rs @@ -1,6 +1,7 @@ -use std::{borrow::Cow, path::MAIN_SEPARATOR_STR}; +use std::path::MAIN_SEPARATOR_STR; use yazi_macro::render; +use yazi_proxy::options::CmpItem; use yazi_shared::{Id, event::{CmdCow, Data}}; use crate::input::Input; @@ -11,28 +12,31 @@ const SEPARATOR: [char; 2] = ['/', '\\']; #[cfg(not(windows))] const SEPARATOR: char = std::path::MAIN_SEPARATOR; -struct Opt { - word: Cow<'static, str>, +pub struct Opt { + item: CmpItem, _ticket: Id, // FIXME: not used } -impl From for Opt { - fn from(mut c: CmdCow) -> Self { - Self { - word: c.take_first_str().unwrap_or_default(), +impl TryFrom for Opt { + type Error = (); + + fn try_from(mut c: CmdCow) -> Result { + Ok(Self { + item: c.take_any("item").ok_or(())?, _ticket: c.get("ticket").and_then(Data::as_id).unwrap_or_default(), - } + }) } } impl Input { - #[yazi_codegen::command] - pub fn complete(&mut self, opt: Opt) { + pub fn complete(&mut self, opt: impl TryInto) { + let Ok(opt): Result = opt.try_into() else { return }; + let (before, after) = self.partition(); let new = if let Some((prefix, _)) = before.rsplit_once(SEPARATOR) { - format!("{prefix}/{}{after}", opt.word).replace(SEPARATOR, MAIN_SEPARATOR_STR) + format!("{prefix}/{}{after}", opt.item.completable()).replace(SEPARATOR, MAIN_SEPARATOR_STR) } else { - format!("{}{after}", opt.word).replace(SEPARATOR, MAIN_SEPARATOR_STR) + format!("{}{after}", opt.item.completable()).replace(SEPARATOR, MAIN_SEPARATOR_STR) }; let snap = self.snap_mut();