Phase 10: SFTP VFS full read-write surface — mkdir, remove, rename
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.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user