feat: improve path auto-completion results (#2765)

This commit is contained in:
三咲雅 · Misaki Masa 2025-05-16 16:37:04 +08:00 committed by GitHub
parent e257581fe7
commit ffc635e434
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 94 additions and 57 deletions

1
Cargo.lock generated
View file

@ -3659,6 +3659,7 @@ dependencies = [
"yazi-config", "yazi-config",
"yazi-macro", "yazi-macro",
"yazi-plugin", "yazi-plugin",
"yazi-proxy",
"yazi-shared", "yazi-shared",
] ]

View file

@ -1,11 +1,12 @@
use std::{collections::HashMap, path::PathBuf}; use std::{collections::HashMap, path::PathBuf};
use yazi_proxy::options::CmpItem;
use yazi_shared::Id; use yazi_shared::Id;
#[derive(Default)] #[derive(Default)]
pub struct Cmp { pub struct Cmp {
pub(super) caches: HashMap<PathBuf, Vec<String>>, pub(super) caches: HashMap<PathBuf, Vec<CmpItem>>,
pub(super) cands: Vec<String>, pub(super) cands: Vec<CmpItem>,
pub(super) offset: usize, pub(super) offset: usize,
pub cursor: usize, pub cursor: usize,
@ -16,7 +17,7 @@ pub struct Cmp {
impl Cmp { impl Cmp {
// --- Cands // --- Cands
#[inline] #[inline]
pub fn window(&self) -> &[String] { pub fn window(&self) -> &[CmpItem] {
let end = (self.offset + self.limit()).min(self.cands.len()); let end = (self.offset + self.limit()).min(self.cands.len());
&self.cands[self.offset..end] &self.cands[self.offset..end]
} }
@ -25,7 +26,7 @@ impl Cmp {
pub fn limit(&self) -> usize { self.cands.len().min(10) } pub fn limit(&self) -> usize { self.cands.len().min(10) }
#[inline] #[inline]
pub fn selected(&self) -> Option<&String> { self.cands.get(self.cursor) } pub fn selected(&self) -> Option<&CmpItem> { self.cands.get(self.cursor) }
// --- Cursor // --- Cursor
#[inline] #[inline]

View file

@ -1,14 +1,15 @@
use std::{borrow::Cow, mem, ops::ControlFlow, path::PathBuf}; use std::{borrow::Cow, mem, ops::ControlFlow, path::PathBuf};
use yazi_macro::render; 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; use crate::cmp::Cmp;
const LIMIT: usize = 30; const LIMIT: usize = 30;
struct Opt { struct Opt {
cache: Vec<String>, cache: Vec<CmpItem>,
cache_name: PathBuf, cache_name: PathBuf,
word: Cow<'static, str>, word: Cow<'static, str>,
ticket: Id, ticket: Id,
@ -55,34 +56,28 @@ impl Cmp {
render!(); render!();
} }
fn match_candidates(word: &str, cache: &[String]) -> Vec<String> { fn match_candidates(word: &str, cache: &[CmpItem]) -> Vec<CmpItem> {
let smart = !word.bytes().any(|c| c.is_ascii_uppercase()); let smart = !word.bytes().any(|c| c.is_ascii_uppercase());
let flow = cache.iter().try_fold( let flow = cache.iter().try_fold((Vec::new(), Vec::new()), |(mut exact, mut fuzzy), item| {
(Vec::with_capacity(LIMIT), Vec::with_capacity(LIMIT)), if osstr_starts_with(&item.name, word, smart) {
|(mut prefixed, mut fuzzy), s| { exact.push(item);
if (smart && s.to_lowercase().starts_with(word)) || (!smart && s.starts_with(word)) { if exact.len() >= LIMIT {
if s != word { return ControlFlow::Break((exact, fuzzy));
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)
} }
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::Continue(v) => v,
ControlFlow::Break(v) => v, ControlFlow::Break(v) => v,
}; };
if prefixed.len() < LIMIT {
prefixed.extend(fuzzy.into_iter().take(LIMIT - prefixed.len())) let it = fuzzy.into_iter().take(LIMIT - exact.len());
} exact.into_iter().chain(it).cloned().collect()
prefixed.into_iter().map(ToOwned::to_owned).collect()
} }
} }

View file

@ -3,7 +3,8 @@ use std::{borrow::Cow, mem, path::{MAIN_SEPARATOR_STR, PathBuf}};
use tokio::fs; use tokio::fs;
use yazi_fs::{CWD, expand_path}; use yazi_fs::{CWD, expand_path};
use yazi_macro::{emit, render}; 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; use crate::cmp::Cmp;
@ -43,17 +44,16 @@ impl Cmp {
tokio::spawn(async move { tokio::spawn(async move {
let mut dir = fs::read_dir(&parent).await?; let mut dir = fs::read_dir(&parent).await?;
let mut cache = vec![]; let mut cache = vec![];
while let Ok(Some(f)) = dir.next_entry().await { while let Ok(Some(ent)) = dir.next_entry().await {
let Ok(meta) = f.metadata().await else { continue }; if let Ok(ft) = ent.file_type().await {
cache.push(CmpItem { name: ent.file_name(), is_dir: ft.is_dir() });
cache.push(format!( }
"{}{}",
f.file_name().to_string_lossy(),
if meta.is_dir() { MAIN_SEPARATOR_STR } else { "" },
));
} }
if !cache.is_empty() { if !cache.is_empty() {
cache.sort_unstable_by(|a, b| {
natsort(a.name.as_encoded_bytes(), b.name.as_encoded_bytes(), false)
});
emit!(Call( emit!(Call(
Cmd::new("cmp:show") Cmd::new("cmp:show")
.with_any("cache", cache) .with_any("cache", cache)

View file

@ -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 ratatui::{buffer::Buffer, layout::Rect, widgets::{Block, BorderType, List, ListItem, Widget}};
use yazi_adapter::Dimension; use yazi_adapter::Dimension;
@ -23,10 +23,10 @@ impl Widget for Cmp<'_> {
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, x)| { .map(|(i, x)| {
let icon = let icon = if x.is_dir { &THEME.cmp.icon_folder } else { &THEME.cmp.icon_file };
if x.ends_with(MAIN_SEPARATOR) { &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() { if i == self.cx.cmp.rel_cursor() {
item = item.style(THEME.cmp.active); item = item.style(THEME.cmp.active);
} else { } else {

View file

@ -3,6 +3,8 @@ use yazi_config::popup::InputCfg;
use yazi_macro::emit; use yazi_macro::emit;
use yazi_shared::{Id, errors::InputError, event::Cmd}; use yazi_shared::{Id, errors::InputError, event::Cmd};
use crate::options::CmpItem;
pub struct InputProxy; pub struct InputProxy;
impl InputProxy { impl InputProxy {
@ -14,7 +16,7 @@ impl InputProxy {
} }
#[inline] #[inline]
pub fn complete(word: &str, ticket: Id) { pub fn complete(item: &CmpItem, ticket: Id) {
emit!(Call(Cmd::args("input:complete", &[word]).with("ticket", ticket))); emit!(Call(Cmd::new("input:complete").with_any("item", item.clone()).with("ticket", ticket)));
} }
} }

View file

@ -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 { "" })
}
}

View file

@ -1 +1 @@
yazi_macro::mod_flat!(notify open plugin process search); yazi_macro::mod_flat!(cmp notify open plugin process search);

View file

@ -1,5 +1,5 @@
use core::str; use core::str;
use std::borrow::Cow; use std::{borrow::Cow, ffi::OsStr};
pub const MIME_DIR: &str = "inode/directory"; 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) } unsafe { String::from_utf8_unchecked(buf) }
} }
pub fn osstr_contains(s: impl AsRef<OsStr>, needle: impl AsRef<OsStr>) -> 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<OsStr>,
prefix: impl AsRef<OsStr>,
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
}
}

View file

@ -13,6 +13,7 @@ yazi-codegen = { path = "../yazi-codegen", version = "25.5.14" }
yazi-config = { path = "../yazi-config", version = "25.5.14" } yazi-config = { path = "../yazi-config", version = "25.5.14" }
yazi-macro = { path = "../yazi-macro", version = "25.5.14" } yazi-macro = { path = "../yazi-macro", version = "25.5.14" }
yazi-plugin = { path = "../yazi-plugin", 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" } yazi-shared = { path = "../yazi-shared", version = "25.5.14" }
# External dependencies # External dependencies

View file

@ -1,6 +1,7 @@
use std::{borrow::Cow, path::MAIN_SEPARATOR_STR}; use std::path::MAIN_SEPARATOR_STR;
use yazi_macro::render; use yazi_macro::render;
use yazi_proxy::options::CmpItem;
use yazi_shared::{Id, event::{CmdCow, Data}}; use yazi_shared::{Id, event::{CmdCow, Data}};
use crate::input::Input; use crate::input::Input;
@ -11,28 +12,31 @@ const SEPARATOR: [char; 2] = ['/', '\\'];
#[cfg(not(windows))] #[cfg(not(windows))]
const SEPARATOR: char = std::path::MAIN_SEPARATOR; const SEPARATOR: char = std::path::MAIN_SEPARATOR;
struct Opt { pub struct Opt {
word: Cow<'static, str>, item: CmpItem,
_ticket: Id, // FIXME: not used _ticket: Id, // FIXME: not used
} }
impl From<CmdCow> for Opt { impl TryFrom<CmdCow> for Opt {
fn from(mut c: CmdCow) -> Self { type Error = ();
Self {
word: c.take_first_str().unwrap_or_default(), fn try_from(mut c: CmdCow) -> Result<Self, Self::Error> {
Ok(Self {
item: c.take_any("item").ok_or(())?,
_ticket: c.get("ticket").and_then(Data::as_id).unwrap_or_default(), _ticket: c.get("ticket").and_then(Data::as_id).unwrap_or_default(),
} })
} }
} }
impl Input { impl Input {
#[yazi_codegen::command] pub fn complete(&mut self, opt: impl TryInto<Opt>) {
pub fn complete(&mut self, opt: Opt) { let Ok(opt): Result<Opt, _> = opt.try_into() else { return };
let (before, after) = self.partition(); let (before, after) = self.partition();
let new = if let Some((prefix, _)) = before.rsplit_once(SEPARATOR) { 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 { } 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(); let snap = self.snap_mut();