tlc: Sprint 5 G-series — error dialog retry wiring + SFTP open_write

G1: Error dialog Retry now re-invokes the stored file operation (copy/move/
    delete) using the PendingErrorOp parameters. Previously Retry just logged
    'Retry not yet wired'. Copy/move/delete error paths now create a
    PendingErrorOp + ErrorDialog instead of silently setting a status message.
    Skip/Ignore/Abort outcomes produce appropriate status messages.

G2: SftpVfs::open_write now returns a working SftpWriter that buffers writes
    and flushes to the remote SFTP server via session.write(). Previously
    returned VfsError::Unsupported. The SftpWriter buffers in memory and
    writes on flush/drop, matching the existing open_read buffer pattern.

1369 tests pass (default and --features sftp).
This commit is contained in:
2026-07-06 00:02:10 +03:00
parent 43bc027485
commit 19d093129e
2 changed files with 135 additions and 18 deletions
@@ -1002,7 +1002,19 @@ impl FileManager {
)));
}
Err(e) => {
self.status.set_message(format!("copy: {e}"));
self.pending_error_op = Some(PendingErrorOp {
kind: "copy".to_string(),
sources: sources.clone(),
dst: Some(dst.clone()),
preserve_attributes,
follow_links,
});
self.dialog = Some(DialogState::Error(Box::new(
error_dialog::ErrorDialog::new(
sources.first().map(|p| p.display().to_string()).unwrap_or_default(),
e.to_string(),
),
)));
}
}
}
@@ -1099,7 +1111,19 @@ impl FileManager {
)));
}
Err(e) => {
self.status.set_message(format!("move: {e}"));
self.pending_error_op = Some(PendingErrorOp {
kind: "move".to_string(),
sources: sources.clone(),
dst: Some(dst.clone()),
preserve_attributes,
follow_links,
});
self.dialog = Some(DialogState::Error(Box::new(
error_dialog::ErrorDialog::new(
sources.first().map(|p| p.display().to_string()).unwrap_or_default(),
e.to_string(),
),
)));
}
}
}
@@ -1120,7 +1144,19 @@ impl FileManager {
let _ = self.active_panel_mut().refresh();
}
Err(e) => {
self.status.set_message(format!("delete: {e}"));
self.pending_error_op = Some(PendingErrorOp {
kind: "delete".to_string(),
sources: paths.clone(),
dst: None,
preserve_attributes: false,
follow_links: false,
});
self.dialog = Some(DialogState::Error(Box::new(
error_dialog::ErrorDialog::new(
paths.first().map(|p| p.display().to_string()).unwrap_or_default(),
e.to_string(),
),
)));
}
}
}
@@ -1219,28 +1255,62 @@ impl FileManager {
}
Some(DialogState::Error(d)) => {
use error_dialog::ErrorOutcome;
if let Some(_op) = self.pending_error_op.take() {
if let Some(op) = self.pending_error_op.take() {
if d.is_finished() {
match d.outcome {
ErrorOutcome::Retry => {
self.status.set_message(
"Retry not yet wired (requires batch step resumption)".to_string(),
let handle = self.ops_manager.begin(
match op.kind.as_str() {
"move" => crate::ops::OpKind::Move,
"delete" => crate::ops::OpKind::Delete,
_ => crate::ops::OpKind::Copy,
},
op.sources.clone(),
op.dst.clone(),
);
let result = match op.kind.as_str() {
"delete" => crate::ops::delete::delete_many(&op.sources, &handle),
"move" => crate::ops::move_op::move_many(
&op.sources,
op.dst.as_deref().unwrap_or_else(|| std::path::Path::new("/")),
&handle,
false,
op.preserve_attributes,
op.follow_links,
),
_ => crate::ops::copy::copy_many(
&op.sources,
op.dst.as_deref().unwrap_or_else(|| std::path::Path::new("/")),
&handle,
false,
op.preserve_attributes,
op.follow_links,
),
};
match result {
Ok(()) => {
self.ops_manager.finish();
self.status.set_message(format!("{}: retried successfully", op.kind));
let _ = self.active_panel_mut().refresh();
}
Err(e) => {
self.status.set_message(format!("{} retry: {e}", op.kind));
}
}
}
ErrorOutcome::Skip
| ErrorOutcome::Ignore
| ErrorOutcome::Abort => {
self.status.set_message(format!(
"{}: aborted (see pending error op)",
d.failed_path
));
| ErrorOutcome::Ignore => {
self.status.set_message(format!("{}: skipped ({})", op.kind, d.failed_path));
}
ErrorOutcome::Abort => {
self.status.set_message(format!("{}: aborted", op.kind));
}
ErrorOutcome::Running => {
self.pending_error_op = Some(_op);
self.pending_error_op = Some(op);
}
}
} else {
self.pending_error_op = Some(_op);
self.pending_error_op = Some(op);
}
}
}
+51 -4
View File
@@ -408,20 +408,67 @@ impl Vfs for SftpVfs {
}
fn open_write(&self, p: &VfsPath) -> Result<Box<dyn Write + Send>, VfsError> {
let _ = sftp_path(p)?;
Err(VfsError::Unsupported("open_write"))
let path = sftp_path(p)?;
Ok(Box::new(SftpWriter {
session: Arc::clone(&self.session),
handle: self.runtime.handle().clone(),
path,
buffer: Vec::new(),
flushed: false,
}))
}
}
impl Drop for SftpVfs {
fn drop(&mut self) {
// Best-effort: close the SftpSession before dropping the
// runtime. We swallow errors — Drop cannot propagate.
let session = Arc::clone(&self.session);
let _ = self.runtime.block_on(async move { session.close().await });
}
}
struct SftpWriter {
session: Arc<SftpSession>,
handle: tokio::runtime::Handle,
path: String,
buffer: Vec<u8>,
flushed: bool,
}
impl Write for SftpWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if self.flushed {
return Err(io::Error::new(
io::ErrorKind::WriteZero,
"sftp writer already flushed",
));
}
self.buffer.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
if self.flushed {
return Ok(());
}
let session = Arc::clone(&self.session);
let path = self.path.clone();
let data = std::mem::take(&mut self.buffer);
self.handle
.block_on(async move { session.write(&path, &data).await })
.map_err(|e| io::Error::other(format!("sftp write: {e}")))?;
self.flushed = true;
Ok(())
}
}
impl Drop for SftpWriter {
fn drop(&mut self) {
if !self.flushed {
let _ = self.flush();
}
}
}
#[cfg(test)]
mod tests {
use super::*;