From c7739c5941ee93f6a3ffa55718fd98cec41da7cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=89=E5=92=B2=E9=9B=85=20misaki=20masa?= Date: Tue, 2 Dec 2025 19:33:27 +0800 Subject: [PATCH] feat: remote file management (#3396) --- CHANGELOG.md | 5 ++++- yazi-actor/src/cmp/trigger.rs | 5 +++-- yazi-actor/src/mgr/displace.rs | 27 +++++++++++++---------- yazi-adapter/src/adapter.rs | 2 +- yazi-adapter/src/dimension.rs | 2 +- yazi-adapter/src/emulator.rs | 30 +++++++++++++++----------- yazi-adapter/src/lib.rs | 15 +++++++------ yazi-adapter/src/unknown.rs | 4 +--- yazi-boot/src/actions/debug.rs | 23 ++++++++++++++++++-- yazi-config/src/vfs/provider.rs | 2 +- yazi-fs/src/provider/local/casefold.rs | 4 ++-- yazi-parser/src/mgr/displace_do.rs | 2 +- yazi-parser/src/mgr/update_files.rs | 4 ++++ yazi-plugin/preset/plugins/extract.lua | 2 +- yazi-plugin/preset/plugins/folder.lua | 7 +++--- yazi-plugin/src/runtime/term.rs | 2 +- yazi-vfs/src/provider/copier.rs | 23 +++++++++++--------- yazi-vfs/src/provider/sftp/conn.rs | 6 ++++-- yazi-watcher/src/local/local.rs | 2 +- 19 files changed, 105 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 968ccde6..1da97b9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/): ### Added -- Remote file management ([#3166], [#3170], [#3172], [#3198], [#3201], [#3243], [#3264], [#3268]) +- Remote file management ([#3396]) - Virtual file system ([#3034], [#3035], [#3094], [#3108], [#3187], [#3203]) - Shell formatting ([#3232]) - Multi-entry support for plugin system ([#3154]) @@ -83,6 +83,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/): - Make preload tasks discardable ([#2875]) - Reduce file change event frequency ([#2820]) +- Upload and download of a single file over SFTP in chunks concurrently ([#3393]) - Do not listen for file changes in inactive tabs ([#2958]) - Switch to a higher-performance hash algorithm ([#3083]) - Sequence-based rendering merge strategy ([#2861]) @@ -1548,3 +1549,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/): [#3385]: https://github.com/sxyazi/yazi/pull/3385 [#3387]: https://github.com/sxyazi/yazi/pull/3387 [#3391]: https://github.com/sxyazi/yazi/pull/3391 +[#3393]: https://github.com/sxyazi/yazi/pull/3393 +[#3396]: https://github.com/sxyazi/yazi/pull/3396 diff --git a/yazi-actor/src/cmp/trigger.rs b/yazi-actor/src/cmp/trigger.rs index c2f36b73..c92ef137 100644 --- a/yazi-actor/src/cmp/trigger.rs +++ b/yazi-actor/src/cmp/trigger.rs @@ -5,7 +5,7 @@ use yazi_fs::{CWD, path::expand_url, provider::{DirReader, FileHolder}}; use yazi_macro::{act, render, succ}; use yazi_parser::cmp::{CmpItem, ShowOpt, TriggerOpt}; use yazi_proxy::CmpProxy; -use yazi_shared::{AnyAsciiChar, data::Data, natsort, path::{PathBufDyn, PathDyn, PathLike}, scheme::{SchemeCow, SchemeLike}, strand::StrandLike, url::{UrlBuf, UrlCow, UrlLike}}; +use yazi_shared::{AnyAsciiChar, data::Data, natsort, path::{PathBufDyn, PathDyn, PathLike}, scheme::{SchemeCow, SchemeLike}, strand::{AsStrand, StrandLike}, url::{UrlBuf, UrlCow, UrlLike}}; use yazi_vfs::provider; use crate::{Actor, Ctx}; @@ -69,7 +69,8 @@ impl Trigger { fn split_url(s: &str) -> Option<(UrlBuf, PathBufDyn)> { let (scheme, path) = SchemeCow::parse(s.as_bytes()).ok()?; - if scheme.is_local() && path == "~" { + tracing::debug!(?scheme, ?path); + if scheme.is_local() && path.as_strand() == "~" { return None; // We don't autocomplete a `~`, but `~/` } diff --git a/yazi-actor/src/mgr/displace.rs b/yazi-actor/src/mgr/displace.rs index a23f1f68..3fa0d6ea 100644 --- a/yazi-actor/src/mgr/displace.rs +++ b/yazi-actor/src/mgr/displace.rs @@ -1,4 +1,5 @@ use anyhow::{Result, bail}; +use yazi_fs::FilesOp; use yazi_macro::{act, succ}; use yazi_parser::{VoidOpt, mgr::{CdSource, DisplaceDoOpt}}; use yazi_proxy::MgrProxy; @@ -22,11 +23,10 @@ impl Actor for Displace { let tab = cx.tab().id; let from = cx.cwd().to_owned(); tokio::spawn(async move { - if let Ok(to) = provider::absolute(&from).await - && to.is_owned() - { - MgrProxy::displace_do(tab, DisplaceDoOpt { to: to.into(), from }); - } + MgrProxy::displace_do(tab, DisplaceDoOpt { + to: provider::absolute(&from).await.map(|u| u.into_owned()), + from, + }); }); succ!(); @@ -42,18 +42,23 @@ impl Actor for DisplaceDo { const NAME: &str = "displace_do"; fn act(cx: &mut Ctx, opt: Self::Options) -> Result { - if !opt.to.is_absolute() { - bail!("Target URL must be absolute"); - } - 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) = opt.to.try_join(hovered.urn()) + && let Ok(url) = to.try_join(hovered.urn()) { act!(mgr:reveal, cx, (url, CdSource::Displace)) } else { - act!(mgr:cd, cx, (opt.to, CdSource::Displace)) + act!(mgr:cd, cx, (to, CdSource::Displace)) } } } diff --git a/yazi-adapter/src/adapter.rs b/yazi-adapter/src/adapter.rs index 8a8f091b..e10b7db7 100644 --- a/yazi-adapter/src/adapter.rs +++ b/yazi-adapter/src/adapter.rs @@ -84,7 +84,7 @@ impl Adapter { } impl Adapter { - pub fn matches(emulator: Emulator) -> Self { + pub fn matches(emulator: &Emulator) -> Self { let mut protocols = emulator.adapters().to_owned(); if env_exists("ZELLIJ_SESSION_NAME") { protocols.retain(|p| *p == Self::Sixel); diff --git a/yazi-adapter/src/dimension.rs b/yazi-adapter/src/dimension.rs index 9a81e320..dcbcb6fd 100644 --- a/yazi-adapter/src/dimension.rs +++ b/yazi-adapter/src/dimension.rs @@ -50,7 +50,7 @@ impl Dimension { } pub fn cell_size() -> Option<(f64, f64)> { - let emu = EMULATOR.get(); + let emu = &*EMULATOR; Some(if emu.force_16t { (emu.csi_16t.0 as f64, emu.csi_16t.1 as f64) } else if let Some(r) = Self::available().ratio() { diff --git a/yazi-adapter/src/emulator.rs b/yazi-adapter/src/emulator.rs index ef77d842..4d192917 100644 --- a/yazi-adapter/src/emulator.rs +++ b/yazi-adapter/src/emulator.rs @@ -10,16 +10,25 @@ use yazi_term::tty::{Handle, TTY}; use crate::{Adapter, Brand, Dimension, Mux, TMUX, Unknown}; -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub struct Emulator { pub kind: Either, + pub version: String, pub light: bool, pub csi_16t: (u16, u16), pub force_16t: bool, } impl Default for Emulator { - fn default() -> Self { Self::unknown() } + fn default() -> Self { + Self { + kind: Either::Right(Unknown::default()), + version: String::new(), + light: false, + csi_16t: (0, 0), + force_16t: false, + } + } } impl Emulator { @@ -60,22 +69,14 @@ impl Emulator { let csi_16t = Self::csi_16t(&resp).unwrap_or_default(); Ok(Self { kind, + version: Self::csi_gt_q(&resp).unwrap_or_default(), light: Self::light_bg(&resp).unwrap_or_default(), csi_16t, force_16t: Self::force_16t(csi_16t), }) } - pub const fn unknown() -> Self { - Self { - kind: Either::Right(Unknown::default()), - light: false, - csi_16t: (0, 0), - force_16t: false, - } - } - - pub fn adapters(self) -> &'static [Adapter] { + pub fn adapters(&self) -> &'static [Adapter] { match self.kind { Either::Left(brand) => brand.adapters(), Either::Right(unknown) => unknown.adapters(), @@ -182,6 +183,11 @@ impl Emulator { Some((w.parse().ok()?, h.parse().ok()?)) } + fn csi_gt_q(resp: &str) -> Option { + let (_, s) = resp.split_once("\x1bP>|")?; + Some(s[..s.find("\x1b\\")?].to_owned()) + } + fn light_bg(resp: &str) -> Result { match resp.split_once("]11;rgb:") { Some((_, s)) if s.len() >= 14 => { diff --git a/yazi-adapter/src/lib.rs b/yazi-adapter/src/lib.rs index cf37ad5f..29d87ad3 100644 --- a/yazi-adapter/src/lib.rs +++ b/yazi-adapter/src/lib.rs @@ -2,9 +2,9 @@ yazi_macro::mod_pub!(drivers); yazi_macro::mod_flat!(adapter brand dimension emulator image info mux unknown); -use yazi_shared::{SyncCell, in_wsl}; +use yazi_shared::{RoCell, SyncCell, in_wsl}; -pub static EMULATOR: SyncCell = SyncCell::new(Emulator::unknown()); +pub static EMULATOR: RoCell = RoCell::new(); pub static ADAPTOR: SyncCell = SyncCell::new(Adapter::Chafa); // Image state @@ -24,8 +24,8 @@ pub fn init() -> anyhow::Result<()> { WSL.set(in_wsl()); // Emulator detection - EMULATOR.set(Emulator::detect().unwrap_or_default()); - TMUX.set(EMULATOR.get().kind.is_left_and(|&b| b == Brand::Tmux)); + let mut emulator = Emulator::detect().unwrap_or_default(); + TMUX.set(emulator.kind.is_left_and(|&b| b == Brand::Tmux)); // Tmux support if TMUX.get() { @@ -33,12 +33,13 @@ pub fn init() -> anyhow::Result<()> { START.set("\x1bPtmux;\x1b\x1b"); CLOSE.set("\x1b\\"); Mux::tmux_passthrough(); - EMULATOR.set(Emulator::detect().unwrap_or_default()); + emulator = Emulator::detect().unwrap_or_default(); } - yazi_config::init_flavor(EMULATOR.get().light)?; + EMULATOR.init(emulator); + yazi_config::init_flavor(EMULATOR.light)?; - ADAPTOR.set(Adapter::matches(EMULATOR.get())); + ADAPTOR.set(Adapter::matches(&EMULATOR)); ADAPTOR.get().start(); Ok(()) } diff --git a/yazi-adapter/src/unknown.rs b/yazi-adapter/src/unknown.rs index fc1f447a..74f5f8fd 100644 --- a/yazi-adapter/src/unknown.rs +++ b/yazi-adapter/src/unknown.rs @@ -1,14 +1,12 @@ use crate::Adapter; -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Default)] pub struct Unknown { pub kgp: bool, pub sixel: bool, } impl Unknown { - pub(super) const fn default() -> Self { Self { kgp: false, sixel: false } } - pub(super) fn adapters(self) -> &'static [Adapter] { use Adapter as A; diff --git a/yazi-boot/src/actions/debug.rs b/yazi-boot/src/actions/debug.rs index b64302f8..b77ff130 100644 --- a/yazi-boot/src/actions/debug.rs +++ b/yazi-boot/src/actions/debug.rs @@ -2,7 +2,8 @@ use std::{env, ffi::OsStr, fmt::Write, path::Path}; use regex::Regex; use yazi_adapter::Mux; -use yazi_config::YAZI; +use yazi_config::{THEME, YAZI}; +use yazi_fs::Xdg; use yazi_shared::timestamp_us; use super::Actions; @@ -19,12 +20,20 @@ impl Actions { writeln!(s, "\nYa")?; writeln!(s, " Version: {}", Self::process_output("ya", "--version"))?; + writeln!(s, "\nConfig")?; + writeln!(s, " Yazi : {}", Self::config_state("yazi"))?; + writeln!(s, " Keymap : {}", Self::config_state("keymap"))?; + writeln!(s, " Theme : {}", Self::config_state("theme"))?; + writeln!(s, " VFS : {}", Self::config_state("vfs"))?; + writeln!(s, " Package : {}", Self::config_state("package"))?; + writeln!(s, " Dark/light flavor: {:?} / {:?}", THEME.flavor.dark, THEME.flavor.light)?; + writeln!(s, "\nEmulator")?; writeln!(s, " TERM : {:?}", env::var_os("TERM"))?; writeln!(s, " TERM_PROGRAM : {:?}", env::var_os("TERM_PROGRAM"))?; writeln!(s, " TERM_PROGRAM_VERSION: {:?}", env::var_os("TERM_PROGRAM_VERSION"))?; writeln!(s, " Brand.from_env : {:?}", yazi_adapter::Brand::from_env())?; - writeln!(s, " Emulator.detect : {:?}", yazi_adapter::EMULATOR)?; + writeln!(s, " Emulator.detect : {:?}", &*yazi_adapter::EMULATOR)?; writeln!(s, "\nAdapter")?; writeln!(s, " Adapter.matches : {:?}", yazi_adapter::ADAPTOR)?; @@ -115,6 +124,16 @@ impl Actions { Ok(s) } + fn config_state(name: &str) -> String { + let p = Xdg::config_dir().join(format!("{name}.toml")); + match std::fs::read_to_string(&p) { + Ok(s) if s.is_empty() => format!("{} (empty)", p.display()), + Ok(s) if s.trim().is_empty() => format!("{} (whitespaces)", p.display()), + Ok(s) => format!("{} ({} chars)", p.display(), s.chars().count()), + Err(e) => format!("{} ({e})", p.display()), + } + } + fn process_output(name: impl AsRef, arg: impl AsRef) -> String { match std::process::Command::new(&name).arg(arg).output() { Ok(out) if out.status.success() => { diff --git a/yazi-config/src/vfs/provider.rs b/yazi-config/src/vfs/provider.rs index 5ae75cdc..544e73ac 100644 --- a/yazi-config/src/vfs/provider.rs +++ b/yazi-config/src/vfs/provider.rs @@ -28,7 +28,7 @@ impl Provider { } // --- SFTP -#[derive(Deserialize, Hash, Serialize, Eq, PartialEq)] +#[derive(Deserialize, Eq, Hash, PartialEq, Serialize)] pub struct ProviderSftp { pub host: String, pub user: String, diff --git a/yazi-fs/src/provider/local/casefold.rs b/yazi-fs/src/provider/local/casefold.rs index c34e1c17..dc576e34 100644 --- a/yazi-fs/src/provider/local/casefold.rs +++ b/yazi-fs/src/provider/local/casefold.rs @@ -1,8 +1,8 @@ use std::{io, path::{Path, PathBuf}}; -pub async fn must_case_match(path: impl AsRef) -> bool { +pub async fn match_name_case(path: impl AsRef) -> bool { let path = path.as_ref(); - casefold(path).await.is_ok_and(|p| p == path) + casefold(path).await.is_ok_and(|p| p.file_name() == path.file_name()) } pub(super) async fn casefold(path: impl AsRef) -> io::Result { diff --git a/yazi-parser/src/mgr/displace_do.rs b/yazi-parser/src/mgr/displace_do.rs index 49d25409..56716945 100644 --- a/yazi-parser/src/mgr/displace_do.rs +++ b/yazi-parser/src/mgr/displace_do.rs @@ -4,7 +4,7 @@ use yazi_shared::{event::CmdCow, url::UrlBuf}; #[derive(Debug)] pub struct DisplaceDoOpt { - pub to: UrlBuf, + pub to: std::io::Result, pub from: UrlBuf, } diff --git a/yazi-parser/src/mgr/update_files.rs b/yazi-parser/src/mgr/update_files.rs index 899c15c4..0402f841 100644 --- a/yazi-parser/src/mgr/update_files.rs +++ b/yazi-parser/src/mgr/update_files.rs @@ -20,6 +20,10 @@ impl TryFrom for UpdateFilesOpt { } } +impl From for UpdateFilesOpt { + fn from(op: FilesOp) -> Self { Self { op } } +} + impl FromLua for UpdateFilesOpt { fn from_lua(_: Value, _: &Lua) -> mlua::Result { Err("unsupported".into_lua_err()) } } diff --git a/yazi-plugin/preset/plugins/extract.lua b/yazi-plugin/preset/plugins/extract.lua index 0c6ae959..d271f3cd 100644 --- a/yazi-plugin/preset/plugins/extract.lua +++ b/yazi-plugin/preset/plugins/extract.lua @@ -66,7 +66,7 @@ function M:try_with(from, pwd, to) if not output then fail("7zip failed to output when extracting '%s', error: %s", from, err) elseif output.status.code ~= 0 then - fail("7zip exited when extracting '%s', error code %s", from, output.status.code) + fail("7zip exited with error code %s when extracting '%s':\n%s", output.status.code, from, output.stderr) end end diff --git a/yazi-plugin/preset/plugins/folder.lua b/yazi-plugin/preset/plugins/folder.lua index 91ecf720..46284a72 100644 --- a/yazi-plugin/preset/plugins/folder.lua +++ b/yazi-plugin/preset/plugins/folder.lua @@ -50,6 +50,7 @@ end function M:spot(job) self.size, self.last = 0, 0 + self:spot_multi(job, false) local url = job.file.url local it = fs.calc_size(url) @@ -69,15 +70,15 @@ function M:spot(job) self:spot_multi(job, true) end -function M:spot_multi(job, force) +function M:spot_multi(job, comp) local now = ya.time() - if not force and now < self.last + 0.1 then + if not comp and now < self.last + 0.1 then return end local rows = { ui.Row({ "Folder" }):style(ui.Style():fg("green")), - ui.Row { " Size:", ya.readable_size(self.size) .. (force and "" or " (?)") }, + ui.Row { " Size:", ya.readable_size(self.size) .. (comp and "" or " (?)") }, ui.Row {}, } diff --git a/yazi-plugin/src/runtime/term.rs b/yazi-plugin/src/runtime/term.rs index 6b8c9713..a4ba61b4 100644 --- a/yazi-plugin/src/runtime/term.rs +++ b/yazi-plugin/src/runtime/term.rs @@ -5,7 +5,7 @@ use yazi_binding::{Composer, ComposerGet, ComposerSet}; pub(super) fn term() -> Composer { fn get(lua: &Lua, key: &[u8]) -> mlua::Result { match key { - b"light" => EMULATOR.get().light.into_lua(lua), + b"light" => EMULATOR.light.into_lua(lua), b"cell_size" => cell_size(lua)?.into_lua(lua), _ => Ok(Value::Nil), } diff --git a/yazi-vfs/src/provider/copier.rs b/yazi-vfs/src/provider/copier.rs index 610006bf..805346a0 100644 --- a/yazi-vfs/src/provider/copier.rs +++ b/yazi-vfs/src/provider/copier.rs @@ -45,7 +45,7 @@ pub(super) fn copy_with_progress_impl( }; let chunks = (cha.len + 10485760 - 1) / 10485760; - let result = futures::stream::iter(0..chunks) + let mut result = futures::stream::iter(0..chunks) .map(|i| { let acc_ = acc_.clone(); let (from, to) = (from.clone(), to.clone()); @@ -79,24 +79,22 @@ pub(super) fn copy_with_progress_impl( copied += n as u64; acc_.fetch_add(n as u64, Ordering::SeqCst); } - dist.flush().await?; - if i == chunks - 1 { - dist.get_ref().set_attrs(attrs).await.ok(); - } - dist.shutdown().await.ok(); - if copied == take { - Ok(()) - } else { + if copied != take { Err(io::Error::other(format!( "short copy for chunk {i}: copied {copied} bytes, expected {take}" ))) + } else if i == chunks - 1 { + Ok(Some(dist.into_inner())) + } else { + dist.shutdown().await.ok(); + Ok(None) } } }) .buffer_unordered(3) - .try_for_each(|_| async { Ok(()) }) + .try_fold(None, |first, file| async { Ok(first.or(file)) }) .await; let n = acc_.swap(0, Ordering::SeqCst); @@ -104,6 +102,11 @@ pub(super) fn copy_with_progress_impl( prog_tx_.send(Ok(n)).await.ok(); } + if let Ok(Some(file)) = &mut result { + file.set_attrs(attrs).await.ok(); + file.shutdown().await.ok(); + } + if let Err(e) = result { prog_tx_.send(Err(e)).await.ok(); } else { diff --git a/yazi-vfs/src/provider/sftp/conn.rs b/yazi-vfs/src/provider/sftp/conn.rs index 14761226..e3d2672e 100644 --- a/yazi-vfs/src/provider/sftp/conn.rs +++ b/yazi-vfs/src/provider/sftp/conn.rs @@ -164,9 +164,11 @@ impl Conn { russh::client::connect(pref, (self.config.host.as_str(), self.config.port), self).await?; for key in keys { - match session.authenticate_publickey_with(&self.config.user, key, None, &mut agent).await { + let hash_alg = session.best_supported_rsa_hash().await?.flatten(); + match session.authenticate_publickey_with(&self.config.user, key, hash_alg, &mut agent).await + { Ok(result) if result.success() => return Ok(session), - Ok(_) => {} + Ok(result) => tracing::debug!("Identity agent authentication failed: {result:?}"), Err(e) => tracing::error!("Identity agent authentication error: {e}"), } } diff --git a/yazi-watcher/src/local/local.rs b/yazi-watcher/src/local/local.rs index 2c317c3c..53ca671e 100644 --- a/yazi-watcher/src/local/local.rs +++ b/yazi-watcher/src/local/local.rs @@ -75,7 +75,7 @@ impl Local { }; if let Some(p) = file.url.as_local() - && !provider::local::must_case_match(p).await + && !provider::local::match_name_case(p).await { ops.push(FilesOp::Deleting(parent.into(), [urn.into()].into())); continue;