feat: remote file management (#3396)

This commit is contained in:
三咲雅 misaki masa 2025-12-02 19:33:27 +08:00 committed by GitHub
parent 878b74acb7
commit c7739c5941
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 105 additions and 62 deletions

View file

@ -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

View file

@ -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 `~/`
}

View file

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

View file

@ -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);

View file

@ -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() {

View file

@ -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<Brand, Unknown>,
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<String> {
let (_, s) = resp.split_once("\x1bP>|")?;
Some(s[..s.find("\x1b\\")?].to_owned())
}
fn light_bg(resp: &str) -> Result<bool> {
match resp.split_once("]11;rgb:") {
Some((_, s)) if s.len() >= 14 => {

View file

@ -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<Emulator> = SyncCell::new(Emulator::unknown());
pub static EMULATOR: RoCell<Emulator> = RoCell::new();
pub static ADAPTOR: SyncCell<Adapter> = 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(())
}

View file

@ -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;

View file

@ -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<OsStr>, arg: impl AsRef<OsStr>) -> String {
match std::process::Command::new(&name).arg(arg).output() {
Ok(out) if out.status.success() => {

View file

@ -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,

View file

@ -1,8 +1,8 @@
use std::{io, path::{Path, PathBuf}};
pub async fn must_case_match(path: impl AsRef<Path>) -> bool {
pub async fn match_name_case(path: impl AsRef<Path>) -> 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<Path>) -> io::Result<PathBuf> {

View file

@ -4,7 +4,7 @@ use yazi_shared::{event::CmdCow, url::UrlBuf};
#[derive(Debug)]
pub struct DisplaceDoOpt {
pub to: UrlBuf,
pub to: std::io::Result<UrlBuf>,
pub from: UrlBuf,
}

View file

@ -20,6 +20,10 @@ impl TryFrom<CmdCow> for UpdateFilesOpt {
}
}
impl From<FilesOp> for UpdateFilesOpt {
fn from(op: FilesOp) -> Self { Self { op } }
}
impl FromLua for UpdateFilesOpt {
fn from_lua(_: Value, _: &Lua) -> mlua::Result<Self> { Err("unsupported".into_lua_err()) }
}

View file

@ -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

View file

@ -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 {},
}

View file

@ -5,7 +5,7 @@ use yazi_binding::{Composer, ComposerGet, ComposerSet};
pub(super) fn term() -> Composer<ComposerGet, ComposerSet> {
fn get(lua: &Lua, key: &[u8]) -> mlua::Result<Value> {
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),
}

View file

@ -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 {

View file

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

View file

@ -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;