diff --git a/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs b/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs index d8fb7f7020..d0d7aaead4 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/dialog_ops.rs @@ -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); } } } diff --git a/local/recipes/tui/tlc/source/src/vfs/sftp.rs b/local/recipes/tui/tlc/source/src/vfs/sftp.rs index 922328e1e8..3bd1c9a199 100644 --- a/local/recipes/tui/tlc/source/src/vfs/sftp.rs +++ b/local/recipes/tui/tlc/source/src/vfs/sftp.rs @@ -408,20 +408,67 @@ impl Vfs for SftpVfs { } fn open_write(&self, p: &VfsPath) -> Result, 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, + handle: tokio::runtime::Handle, + path: String, + buffer: Vec, + flushed: bool, +} + +impl Write for SftpWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + 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::*;