feat: remote filesystem support for interactive cd (#3398)

This commit is contained in:
三咲雅 misaki masa 2025-12-03 17:06:42 +08:00 committed by GitHub
parent c7739c5941
commit c6e03e91d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 148 additions and 123 deletions

View file

@ -68,8 +68,8 @@ impl Actor for Trigger {
impl Trigger {
fn split_url(s: &str) -> Option<(UrlBuf, PathBufDyn)> {
let (scheme, path) = SchemeCow::parse(s.as_bytes()).ok()?;
let scheme = scheme.zeroed();
tracing::debug!(?scheme, ?path);
if scheme.is_local() && path.as_strand() == "~" {
return None; // We don't autocomplete a `~`, but `~/`
}
@ -86,7 +86,11 @@ impl Trigger {
(UrlCow::try_from((scheme, root)).ok()?.into_owned(), c.into())
}
Some((p, c)) => (expand_url(UrlCow::try_from((scheme, p)).ok()?), c.into()),
None => (CWD.load().as_ref().clone(), path.into()),
None if CWD.load().scheme().covariant(&scheme) => (CWD.load().as_ref().clone(), path.into()),
None => {
let empty = PathDyn::with_str(scheme.kind(), "");
(UrlCow::try_from((scheme, empty)).ok()?.into_owned(), path.into())
}
})
}
}
@ -108,6 +112,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn test_split() {
yazi_shared::init_tests();
yazi_fs::init();
compare("", "", "");
compare(" ", "", " ");
@ -124,6 +129,12 @@ mod tests {
compare("//foo/", "/foo/", "");
compare("/foo/bar", "/foo/", "bar");
compare("///foo/bar", "/foo/", "bar");
CWD.set(&"sftp://test/".parse::<UrlBuf>().unwrap(), || {});
compare("sftp://test/a", "sftp://test/", "a");
compare("sftp://test//a", "sftp://test:0//", "a");
compare("sftp://test2/a", "sftp://test2/", "a");
compare("sftp://test2//a", "sftp://test2:0//", "a");
}
#[cfg(windows)]
@ -131,9 +142,11 @@ mod tests {
fn test_split() {
yazi_fs::init();
compare("foo", "", "foo");
compare(r"foo\", r"foo\", "");
compare(r"foo\bar", r"foo\", "bar");
compare(r"foo\bar\", r"foo\bar\", "");
compare(r"C:\", r"C:\", "");
compare(r"C:\foo", r"C:\", "foo");
compare(r"C:\foo\", r"C:\foo\", "");

View file

@ -9,8 +9,8 @@ use yazi_fs::{File, FilesOp, path::expand_url};
use yazi_macro::{act, err, render, succ};
use yazi_parser::mgr::CdOpt;
use yazi_proxy::{CmpProxy, InputProxy, MgrProxy};
use yazi_shared::{Debounce, data::Data, errors::InputError, url::{UrlBuf, UrlLike}};
use yazi_vfs::VfsFile;
use yazi_shared::{Debounce, data::Data, errors::InputError, url::{AsUrl, UrlBuf, UrlLike}};
use yazi_vfs::{VfsFile, provider};
use crate::{Actor, Ctx};
@ -24,7 +24,7 @@ impl Actor for Cd {
fn act(cx: &mut Ctx, opt: Self::Options) -> Result<Data> {
act!(mgr:escape_visual, cx)?;
if opt.interactive {
return Self::cd_interactive();
return Self::cd_interactive(cx);
}
let tab = cx.tab_mut();
@ -59,8 +59,8 @@ impl Actor for Cd {
}
impl Cd {
fn cd_interactive() -> Result<Data> {
let input = InputProxy::show(InputCfg::cd());
fn cd_interactive(cx: &Ctx) -> Result<Data> {
let input = InputProxy::show(InputCfg::cd(cx.cwd().as_url()));
tokio::spawn(async move {
let rx = Debounce::new(UnboundedReceiverStream::new(input), Duration::from_millis(50));
@ -70,6 +70,7 @@ impl Cd {
match result {
Ok(s) => {
let Ok(url) = UrlBuf::try_from(s).map(expand_url) else { return };
let Ok(url) = provider::absolute(&url).await else { return };
let Ok(file) = File::new(&url).await else { return };
if file.is_dir() {
@ -79,10 +80,10 @@ impl Cd {
if let Some(p) = url.parent() {
FilesOp::Upserting(p.into(), [(url.urn().into(), file)].into()).emit();
}
MgrProxy::reveal(&url);
MgrProxy::reveal(url);
}
Err(InputError::Completed(before, ticket)) => {
CmpProxy::trigger(&before, ticket);
CmpProxy::trigger(before, ticket);
}
_ => break,
}

View file

@ -1,7 +1,6 @@
use anyhow::{Result, bail};
use yazi_fs::FilesOp;
use yazi_macro::{act, succ};
use yazi_parser::{VoidOpt, mgr::{CdSource, DisplaceDoOpt}};
use anyhow::Result;
use yazi_macro::succ;
use yazi_parser::{VoidOpt, mgr::DisplaceDoOpt};
use yazi_proxy::MgrProxy;
use yazi_shared::{data::Data, url::UrlLike};
use yazi_vfs::provider;
@ -23,42 +22,9 @@ impl Actor for Displace {
let tab = cx.tab().id;
let from = cx.cwd().to_owned();
tokio::spawn(async move {
MgrProxy::displace_do(tab, DisplaceDoOpt {
to: provider::absolute(&from).await.map(|u| u.into_owned()),
from,
});
MgrProxy::displace_do(tab, DisplaceDoOpt { to: provider::canonicalize(&from).await, from });
});
succ!();
}
}
// --- Do
pub struct DisplaceDo;
impl Actor for DisplaceDo {
type Options = DisplaceDoOpt;
const NAME: &str = "displace_do";
fn act(cx: &mut Ctx, opt: Self::Options) -> Result<Data> {
if cx.cwd() != opt.from {
succ!()
}
let to = match opt.to {
Ok(url) => url,
Err(e) => return act!(mgr:update_files, cx, FilesOp::IOErr(opt.from, e.into())),
};
if !to.is_absolute() {
bail!("Target URL must be absolute");
} else if let Some(hovered) = cx.hovered()
&& let Ok(url) = to.try_join(hovered.urn())
{
act!(mgr:reveal, cx, (url, CdSource::Displace))
} else {
act!(mgr:cd, cx, (to, CdSource::Displace))
}
}
}

View file

@ -0,0 +1,36 @@
use anyhow::{Result, bail};
use yazi_fs::FilesOp;
use yazi_macro::{act, succ};
use yazi_parser::mgr::{CdSource, DisplaceDoOpt};
use yazi_shared::{data::Data, url::UrlLike};
use crate::{Actor, Ctx};
pub struct DisplaceDo;
impl Actor for DisplaceDo {
type Options = DisplaceDoOpt;
const NAME: &str = "displace_do";
fn act(cx: &mut Ctx, opt: Self::Options) -> Result<Data> {
if cx.cwd() != opt.from {
succ!()
}
let to = match opt.to {
Ok(url) => url,
Err(e) => return act!(mgr:update_files, cx, FilesOp::IOErr(opt.from, e.into())),
};
if !to.is_absolute() {
bail!("Target URL must be absolute");
} else if let Some(hovered) = cx.hovered()
&& let Ok(url) = to.try_join(hovered.urn())
{
act!(mgr:reveal, cx, (url, CdSource::Displace))
} else {
act!(mgr:cd, cx, (to, CdSource::Displace))
}
}
}

View file

@ -7,6 +7,7 @@ yazi_macro::mod_flat!(
copy
create
displace
displace_do
download
enter
escape

View file

@ -8,7 +8,7 @@ use yazi_fs::{FilesOp, cha::Cha};
use yazi_macro::{act, succ};
use yazi_parser::{VoidOpt, mgr::{CdSource, SearchOpt, SearchOptVia}};
use yazi_plugin::external;
use yazi_proxy::{InputProxy, MgrProxy};
use yazi_proxy::{AppProxy, InputProxy, MgrProxy};
use yazi_shared::{data::Data, url::UrlLike};
use crate::{Actor, Ctx};
@ -52,8 +52,10 @@ impl Actor for SearchDo {
handle.abort();
}
let cwd = tab.cwd().to_search(&opt.subject)?;
let hidden = tab.pref.show_hidden;
let Ok(cwd) = tab.cwd().to_search(&opt.subject) else {
succ!(AppProxy::notify_warn("Search", "Only local filesystem searches are supported"));
};
tab.search = Some(tokio::spawn(async move {
let rx = match opt.via {

View file

@ -14,7 +14,7 @@ impl Actor for Stash {
const NAME: &str = "stash";
fn act(cx: &mut Ctx, opt: Self::Options) -> Result<Data> {
if opt.target.is_internal() {
if opt.target.is_absolute() && opt.target.is_internal() {
cx.tab_mut().backstack.push(opt.target.as_url());
}

View file

@ -1,5 +1,5 @@
use ratatui::{text::{Line, Text}, widgets::{Paragraph, Wrap}};
use yazi_shared::{strand::ToStrand, url::UrlBuf};
use yazi_shared::{scheme::Encode as EncodeScheme, strand::ToStrand, url::{Url, UrlBuf}};
use super::{Offset, Position};
use crate::YAZI;
@ -31,9 +31,10 @@ pub struct ConfirmCfg {
}
impl InputCfg {
pub fn cd() -> Self {
pub fn cd(cwd: Url) -> Self {
Self {
title: YAZI.input.cd_title.clone(),
value: if cwd.kind().is_local() { String::new() } else { EncodeScheme(cwd).to_string() },
position: Position::new(YAZI.input.cd_origin, YAZI.input.cd_offset),
completion: true,
..Default::default()

View file

@ -2,8 +2,8 @@ use std::process::Stdio;
use anyhow::Result;
use tokio::{io::{AsyncBufReadExt, BufReader}, process::{Child, Command}, sync::mpsc::{self, UnboundedReceiver}};
use yazi_fs::File;
use yazi_shared::url::{UrlBuf, UrlLike};
use yazi_fs::{File, FsUrl};
use yazi_shared::url::{AsUrl, UrlBuf, UrlLike};
use yazi_vfs::VfsFile;
pub struct FdOpt {
@ -34,16 +34,9 @@ pub fn fd(opt: FdOpt) -> Result<UnboundedReceiver<File>> {
}
fn spawn(program: &str, opt: &FdOpt) -> std::io::Result<Child> {
let Some(path) = opt.cwd.as_local() else {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"fd can only search local filesystem",
));
};
Command::new(program)
.arg("--base-directory")
.arg(path)
.arg(&*opt.cwd.as_url().unified_path())
.arg("--regex")
.arg(if opt.hidden { "--hidden" } else { "--no-hidden" })
.args(&opt.args)

View file

@ -1,9 +1,9 @@
use std::process::Stdio;
use anyhow::{Result, bail};
use anyhow::Result;
use tokio::{io::{AsyncBufReadExt, BufReader}, process::Command, sync::mpsc::{self, UnboundedReceiver}};
use yazi_fs::File;
use yazi_shared::url::{UrlBuf, UrlLike};
use yazi_fs::{File, FsUrl};
use yazi_shared::url::{AsUrl, UrlBuf, UrlLike};
use yazi_vfs::VfsFile;
pub struct RgOpt {
@ -14,16 +14,12 @@ pub struct RgOpt {
}
pub fn rg(opt: RgOpt) -> Result<UnboundedReceiver<File>> {
let Some(path) = opt.cwd.as_local() else {
bail!("rg can only search local filesystem");
};
let mut child = Command::new("rg")
.args(["--color=never", "--files-with-matches", "--smart-case"])
.arg(if opt.hidden { "--hidden" } else { "--no-hidden" })
.args(opt.args)
.arg(opt.subject)
.arg(path)
.arg(&*opt.cwd.as_url().unified_path())
.kill_on_drop(true)
.stdout(Stdio::piped())
.stderr(Stdio::null())

View file

@ -1,9 +1,9 @@
use std::process::Stdio;
use anyhow::{Result, bail};
use anyhow::Result;
use tokio::{io::{AsyncBufReadExt, BufReader}, process::Command, sync::mpsc::{self, UnboundedReceiver}};
use yazi_fs::File;
use yazi_shared::url::{UrlBuf, UrlLike};
use yazi_fs::{File, FsUrl};
use yazi_shared::url::{AsUrl, UrlBuf, UrlLike};
use yazi_vfs::VfsFile;
pub struct RgaOpt {
@ -14,16 +14,12 @@ pub struct RgaOpt {
}
pub fn rga(opt: RgaOpt) -> Result<UnboundedReceiver<File>> {
let Some(path) = opt.cwd.as_local() else {
bail!("rga can only search local filesystem");
};
let mut child = Command::new("rga")
.args(["--color=never", "--files-with-matches", "--smart-case"])
.arg(if opt.hidden { "--hidden" } else { "--no-hidden" })
.args(opt.args)
.arg(opt.subject)
.arg(path)
.arg(&*opt.cwd.as_url().unified_path())
.kill_on_drop(true)
.stdout(Stdio::piped())
.stderr(Stdio::null())

View file

@ -9,7 +9,7 @@ impl CmpProxy {
emit!(Call(relay!(cmp:show).with_any("opt", opt)));
}
pub fn trigger(word: &str, ticket: Id) {
emit!(Call(relay!(cmp:trigger, [word]).with("ticket", ticket)));
pub fn trigger(word: impl Into<String>, ticket: Id) {
emit!(Call(relay!(cmp:trigger, [word.into()]).with("ticket", ticket)));
}
}

View file

@ -11,8 +11,8 @@ impl MgrProxy {
emit!(Call(relay!(mgr:arrow, [step.into()])));
}
pub fn cd(target: &UrlBuf) {
emit!(Call(relay!(mgr:cd, [target]).with("raw", true)));
pub fn cd(target: impl Into<UrlBuf>) {
emit!(Call(relay!(mgr:cd, [target.into()]).with("raw", true)));
}
pub fn displace_do(tab: Id, opt: DisplaceDoOpt) {
@ -41,8 +41,8 @@ impl MgrProxy {
));
}
pub fn reveal(target: &UrlBuf) {
emit!(Call(relay!(mgr:reveal, [target]).with("raw", true).with("no-dummy", true)));
pub fn reveal(target: impl Into<UrlBuf>) {
emit!(Call(relay!(mgr:reveal, [target.into()]).with("raw", true).with("no-dummy", true)));
}
pub fn search_do(opt: SearchOpt) {

View file

@ -168,6 +168,14 @@ impl<'a> SchemeCow<'a> {
}
impl<'a> SchemeCow<'a> {
#[inline]
pub fn into_domain(self) -> Option<SymbolCow<'a, str>> {
Some(match self {
SchemeCow::Borrowed(s) => s.domain()?.into(),
SchemeCow::Owned(s) => s.into_domain()?.into(),
})
}
#[inline]
pub fn into_owned(self) -> Scheme {
match self {
@ -176,15 +184,17 @@ impl<'a> SchemeCow<'a> {
}
}
#[inline]
pub fn into_domain(self) -> Option<SymbolCow<'a, str>> {
Some(match self {
SchemeCow::Borrowed(s) => s.domain()?.into(),
SchemeCow::Owned(s) => s.into_domain()?.into(),
})
pub fn with_ports(self, uri: usize, urn: usize) -> Self {
match self {
Self::Borrowed(s) => s.with_ports(uri, urn).into(),
Self::Owned(s) => s.with_ports(uri, urn).into(),
}
}
#[inline]
pub fn zeroed(self) -> Self { self.with_ports(0, 0) }
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -43,13 +43,9 @@ impl From<SchemeRef<'_>> for Scheme {
impl<'a> SchemeRef<'a> {
#[inline]
pub const fn kind(self) -> SchemeKind {
match self {
Self::Regular { .. } => SchemeKind::Regular,
Self::Search { .. } => SchemeKind::Search,
Self::Archive { .. } => SchemeKind::Archive,
Self::Sftp { .. } => SchemeKind::Sftp,
}
pub fn covariant(self, other: impl AsScheme) -> bool {
let other = other.as_scheme();
if self.is_virtual() || other.is_virtual() { self == other } else { true }
}
#[inline]
@ -62,6 +58,16 @@ impl<'a> SchemeRef<'a> {
}
}
#[inline]
pub const fn kind(self) -> SchemeKind {
match self {
Self::Regular { .. } => SchemeKind::Regular,
Self::Search { .. } => SchemeKind::Search,
Self::Archive { .. } => SchemeKind::Archive,
Self::Sftp { .. } => SchemeKind::Sftp,
}
}
#[inline]
pub const fn ports(self) -> (usize, usize) {
match self {
@ -72,22 +78,6 @@ impl<'a> SchemeRef<'a> {
}
}
#[inline]
pub const fn zeroed(self) -> Self {
match self {
Self::Regular { .. } => Self::Regular { uri: 0, urn: 0 },
Self::Search { domain, .. } => Self::Search { domain, uri: 0, urn: 0 },
Self::Archive { domain, .. } => Self::Archive { domain, uri: 0, urn: 0 },
Self::Sftp { domain, .. } => Self::Sftp { domain, uri: 0, urn: 0 },
}
}
#[inline]
pub fn covariant(self, other: impl AsScheme) -> bool {
let other = other.as_scheme();
if self.is_virtual() || other.is_virtual() { self == other } else { true }
}
pub fn to_owned(self) -> Scheme {
match self {
Self::Regular { uri, urn } => Scheme::Regular { uri, urn },
@ -96,4 +86,16 @@ impl<'a> SchemeRef<'a> {
Self::Sftp { domain, uri, urn } => Scheme::Sftp { domain: domain.intern(), uri, urn },
}
}
pub const fn with_ports(self, uri: usize, urn: usize) -> Self {
match self {
Self::Regular { .. } => Self::Regular { uri, urn },
Self::Search { domain, .. } => Self::Search { domain, uri, urn },
Self::Archive { domain, .. } => Self::Archive { domain, uri, urn },
Self::Sftp { domain, .. } => Self::Sftp { domain, uri, urn },
}
}
#[inline]
pub const fn zeroed(self) -> Self { self.with_ports(0, 0) }
}

View file

@ -38,4 +38,7 @@ impl Scheme {
Self::Sftp { domain, .. } => Self::Sftp { domain, uri, urn },
}
}
#[inline]
pub fn zeroed(self) -> Self { self.with_ports(0, 0) }
}

View file

@ -44,15 +44,15 @@ pub(super) fn copy_with_progress_impl(
Some(f)
};
let chunks = (cha.len + 10485760 - 1) / 10485760;
let chunks = (cha.len + 5242880 - 1) / 5242880;
let mut result = futures::stream::iter(0..chunks)
.map(|i| {
let acc_ = acc_.clone();
let (from, to) = (from.clone(), to.clone());
let (src, dist) = (src.take(), dist.take());
async move {
let offset = i * 10485760;
let take = cha.len.saturating_sub(offset).min(10485760);
let offset = i * 5242880;
let take = cha.len.saturating_sub(offset).min(5242880);
let mut src = BufReader::with_capacity(524288, match src {
Some(f) => f,
@ -93,7 +93,7 @@ pub(super) fn copy_with_progress_impl(
}
}
})
.buffer_unordered(3)
.buffer_unordered(4)
.try_fold(None, |first, file| async { Ok(first.or(file)) })
.await;

View file

@ -2,7 +2,7 @@ use std::{io, sync::Arc};
use tokio::{io::{AsyncWriteExt, BufReader, BufWriter}, sync::mpsc::Receiver};
use yazi_config::vfs::{ProviderSftp, Vfs};
use yazi_fs::provider::{DirReader, FileHolder, Provider};
use yazi_fs::{CWD, provider::{DirReader, FileHolder, Provider}};
use yazi_sftp::fs::{Attrs, Flags};
use yazi_shared::{loc::LocBuf, path::{AsPath, PathBufDyn}, pool::InternStr, scheme::SchemeKind, url::{Url, UrlBuf, UrlCow, UrlLike}};
@ -26,7 +26,14 @@ impl<'a> Provider for Sftp<'a> {
type UrlCow = UrlCow<'a>;
async fn absolute(&self) -> io::Result<Self::UrlCow> {
Ok(if self.url.is_absolute() { self.url.into() } else { self.canonicalize().await?.into() })
let cwd = CWD.load();
Ok(if self.url.is_absolute() {
self.url.into()
} else if cwd.scheme().covariant(self.url.scheme()) {
cwd.try_join(self.path)?.into()
} else {
self.canonicalize().await?.into()
})
}
async fn canonicalize(&self) -> io::Result<UrlBuf> {

View file

@ -59,7 +59,7 @@ impl Linked {
Ok(to) if to != *from => _ = linked.write().entry_ref(from).insert(to),
Ok(_) => _ = linked.write().remove(from),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => _ = linked.write().remove(from),
Err(_) => {}
Err(e) => tracing::error!("Failed to canonicalize watched path {from:?}: {e:?}"),
}
}
})

View file

@ -56,10 +56,8 @@ impl Watcher {
if !to_unwatch.is_empty() || !to_watch.is_empty() {
backend = Self::sync(backend, to_unwatch, to_watch).await;
if !rx.has_changed().unwrap_or(false) {
backend = backend.sync().await;
}
}
if rx.changed().await.is_err() {
break;