From 103a6389a106cdbc643fd1222b9ad4e7adab3a22 Mon Sep 17 00:00:00 2001 From: vasilito Date: Wed, 8 Jul 2026 18:32:01 +0300 Subject: [PATCH] =?UTF-8?q?Phase=2010:=20SFTP=20VFS=20full=20read-write=20?= =?UTF-8?q?surface=20=E2=80=94=20mkdir,=20remove,=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SFTP backend now implements the complete Vfs trait with zero Unsupported stubs. Three new operations: - mkdir: creates directories via session.create_dir(). Supports parents=true by walking path components and creating each missing parent directory step-by-step via SFTP protocol calls. - remove: deletes files and directories via session.remove_file() / session.remove_dir(). Supports recursive=true by listing children with read_dir() and removing them depth-first before the directory. - rename: renames/moves via session.rename(oldpath, newpath). Fixed 2 pre-existing warnings: - Removed unused std::path::PathBuf import - Added doc comment on SshHandlerError::KeyMismatch.host field Updated module doc to reflect full read-write surface. Updated README.md Phase 7 VFS status: 🚧 partial → ✅ complete. 1433 tests passing, zero warnings with --features sftp. --- local/recipes/tui/tlc/PLAN.md | 18 ++++ local/recipes/tui/tlc/README.md | 2 +- local/recipes/tui/tlc/source/src/vfs/sftp.rs | 91 ++++++++++++++++++-- 3 files changed, 103 insertions(+), 8 deletions(-) diff --git a/local/recipes/tui/tlc/PLAN.md b/local/recipes/tui/tlc/PLAN.md index 6f64ccba5a..9c2ae19d9f 100644 --- a/local/recipes/tui/tlc/PLAN.md +++ b/local/recipes/tui/tlc/PLAN.md @@ -222,6 +222,24 @@ All previously deferred items are now resolved: ## 4. RECENT CHANGELOG (last 6 months, condensed) +### 2026-07-08 — Phase 10 — SFTP VFS full read-write surface + +**SFTP mkdir, remove, rename implemented:** +- `mkdir`: creates a directory on the remote SFTP server via + `session.create_dir()`. Supports `parents=true` — walks path + components upward, creating each missing parent directory. +- `remove`: deletes files and directories. Supports `recursive=true` — + lists child entries via `read_dir()` and removes them depth-first + before removing the target directory. +- `rename`: renames/moves files and directories via + `session.rename(oldpath, newpath)`. +- Fixed 2 pre-existing warnings (unused `PathBuf` import, missing doc + on `SshHandlerError::KeyMismatch.host` field). + +The SFTP VFS backend now exposes the full Vfs trait surface: +`read_dir`, `stat`, `exists`, `is_dir`, `is_file`, `open_read`, +`open_write`, `mkdir`, `remove`, `rename` — zero `Unsupported` stubs. + ### 2026-07-08 — W79 — MC visual/behavioral parity polish **W79a Viewer:** Two MC parity improvements: diff --git a/local/recipes/tui/tlc/README.md b/local/recipes/tui/tlc/README.md index 2c90e308db..8fb13d07b3 100644 --- a/local/recipes/tui/tlc/README.md +++ b/local/recipes/tui/tlc/README.md @@ -155,7 +155,7 @@ dialog. Selection persists to `~/.config/tlc/config.toml`. | 4 keymap + dispatcher | ✅ | | 5 editor polish | ✅ | | 6 filemanager + viewer extras | ✅ | -| 7 VFS (local + remote backends) | 🚧 partial | +| 7 VFS (local + remote backends) | ✅ SFTP full read-write, FTP, tar/cpio/zip/extfs read-only | | 8 archives + skin + i18n | ✅ skin + i18n (8 built-in skins, Alt-S); archives 🚧 | | 13 skins + runtime selection | ✅ 8 built-in skins, Alt-S dialog, config persistence | | 14a critical features | ✅ menu bar (F9), select/unselect group, quick cd, Ctrl-O sub-shell, Alt-Enter cmdline | diff --git a/local/recipes/tui/tlc/source/src/vfs/sftp.rs b/local/recipes/tui/tlc/source/src/vfs/sftp.rs index 3bd1c9a199..8d6cd6076f 100644 --- a/local/recipes/tui/tlc/source/src/vfs/sftp.rs +++ b/local/recipes/tui/tlc/source/src/vfs/sftp.rs @@ -6,12 +6,9 @@ //! (matching the rest of TLC's Vfs trait) and dispatches onto the //! runtime via [`SftpVfs::block_on`]. //! -//! The backend is read-mostly: `read_dir`, `stat`, `exists`, -//! `is_dir`, `is_file`, `open_read` are implemented. `open_write`, -//! `mkdir`, `remove`, and `rename` inherit the -//! [`VfsError::Unsupported`] default. (`open_write` could be added -//! in a later phase — it is a one-liner against -//! `SftpSession::create`.) +//! The backend provides the full read-write surface: `read_dir`, +//! `stat`, `exists`, `is_dir`, `is_file`, `open_read`, `open_write`, +//! `mkdir`, `remove`, and `rename` are all implemented. //! //! Host-key verification uses the [`crate::vfs::known_hosts`] store //! at `~/.config/tlc/known_hosts` (or `$XDG_CONFIG_HOME/tlc/known_hosts`). @@ -26,7 +23,6 @@ #![cfg(feature = "sftp")] use std::io::{self, Read, Write}; -use std::path::PathBuf; use std::sync::Arc; use async_trait::async_trait; @@ -50,6 +46,7 @@ use crate::vfs::traits::{Vfs, VfsError}; pub enum SshHandlerError { /// Host is in the store but the key changed (possible MITM). KeyMismatch { + /// Remote hostname (e.g. `"example.com"`). host: String, /// Algorithm name (e.g. `"ssh-ed25519"`). algo: String, @@ -417,6 +414,86 @@ impl Vfs for SftpVfs { flushed: false, })) } + + fn mkdir(&self, p: &VfsPath, parents: bool) -> Result<(), VfsError> { + let path = sftp_path(p)?; + let session = Arc::clone(&self.session); + if parents { + // Walk up the path, creating each missing parent directory. + let mut components: Vec<&str> = + path.split('/').filter(|c| !c.is_empty()).collect(); + let mut current = String::new(); + // Skip the last component — we create it last via + // session.create_dir. + if let Some(_target) = components.pop() { + for component in &components { + if current.is_empty() { + current.push('/'); + } + current.push_str(component); + let dir = current.clone(); + let exists = self.block_on({ + let s = Arc::clone(&session); + let d = dir.clone(); + async move { s.try_exists(&d).await } + }) + .unwrap_or(false); + if !exists { + self.block_on({ + let s = Arc::clone(&session); + let d = dir.clone(); + async move { + s.create_dir(&d).await.map_err(|e| { + VfsError::Other(format!("sftp mkdir parent: {e}")) + }) + } + })?; + } + current.push('/'); + } + } + } + self.block_on(async move { + session.create_dir(&path).await + .map_err(|e| VfsError::Other(format!("sftp mkdir: {e}"))) + }) + } + + fn remove(&self, p: &VfsPath, recursive: bool) -> Result<(), VfsError> { + let path = sftp_path(p)?; + let session = Arc::clone(&self.session); + let is_dir = self.is_dir(p).unwrap_or(false); + if is_dir { + if recursive { + // List contents and remove each child first, then + // the directory itself. + let children = self.read_dir(p, true)?; + for child in &children { + let child_path = p.join(&child.name); + self.remove(&child_path, true)?; + } + } + self.block_on(async move { + session.remove_dir(&path).await + .map_err(|e| VfsError::Other(format!("sftp rmdir: {e}"))) + }) + } else { + self.block_on(async move { + session.remove_file(&path).await + .map_err(|e| VfsError::Other(format!("sftp rm: {e}"))) + }) + } + } + + fn rename(&self, from: &VfsPath, to: &VfsPath) -> Result<(), VfsError> { + let from_path = sftp_path(from)?; + let to_path = sftp_path(to)?; + let session = Arc::clone(&self.session); + self.block_on(async move { + session.rename(&from_path, &to_path).await + .map_err(|e| VfsError::Other(format!("sftp rename: {e}"))) + }) + } } impl Drop for SftpVfs {