Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 37 additions & 12 deletions app/src/sftp_manager/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1014,15 +1014,6 @@ impl SftpBrowserView {
is_move: bool,
ctx: &mut ViewContext<Self>,
) {
if is_move {
self.show_error_toast(
"Moving across connections is coming next — copy, then delete the source."
.to_string(),
ctx,
);
return;
}

// Upload (local source → remote target) transfers through the target's
// session; download (remote source → local target) through ours.
let direction = match plan_transfer(&self.fs_namespace(), &target.fs) {
Expand All @@ -1048,6 +1039,9 @@ impl SftpBrowserView {
}
},
};
// For a move, the source is deleted after the transfer succeeds, via the
// *source* pane's own backend (ours) — a cross-connection copy-then-delete.
let source_backend = self.sftp.clone();

let target_dir = target.current_path.clone();
let mut started = 0usize;
Expand Down Expand Up @@ -1082,11 +1076,23 @@ impl SftpBrowserView {
TransferDirection::Upload => (source.clone(), dest),
TransferDirection::Download => (dest, source.clone()),
};
self.spawn_transfer_with_backend(local_path, remote_path, backend.clone(), direction, ctx);
let delete_after = match (is_move, &source_backend) {
(true, Some(b)) => Some((b.clone(), source.clone())),
_ => None,
};
self.spawn_transfer_with_backend(
local_path,
remote_path,
backend.clone(),
direction,
delete_after,
ctx,
);
started += 1;
}

let mut parts = vec![format!("{started} transfer(s) started")];
let verb = if is_move { "move" } else { "copy" };
let mut parts = vec![format!("{started} {verb} transfer(s) started")];
if skipped_exist > 0 {
parts.push(format!("{skipped_exist} skipped (already exist)"));
}
Expand All @@ -1100,12 +1106,18 @@ impl SftpBrowserView {
/// Spawn a single upload/download on the transfer engine using an explicit
/// backend (the target's for uploads, ours for downloads), mirroring the
/// drag-and-drop upload path but parameterised by direction and backend.
///
/// `delete_after` = `Some((source_backend, source_path))` turns a copy into
/// a **move**: on success the source is deleted through its own backend
/// (copy-then-delete across connections). It is never deleted on failure or
/// cancellation, so a failed move can't lose data.
fn spawn_transfer_with_backend(
&mut self,
local_path: PathBuf,
remote_path: PathBuf,
backend: Arc<dyn SftpBackend>,
direction: TransferDirection,
delete_after: Option<(Arc<dyn SftpBackend>, PathBuf)>,
ctx: &mut ViewContext<Self>,
) {
let total_size = match direction {
Expand Down Expand Up @@ -1174,7 +1186,20 @@ impl SftpBrowserView {
}
me.transfer_handles.remove(&task_id);
match &result {
Ok(Ok(())) => me.refresh_dir(ctx),
Ok(Ok(())) => {
// Copy-then-delete: only remove the source once the
// transfer has fully succeeded (never on error/cancel).
if let Some((source_backend, source_path)) = &delete_after {
if let Err(e) = source_backend.delete_file(source_path) {
log::warn!("fm move: source delete failed: {e}");
me.show_error_toast(
format!("Copied, but removing the source failed: {e}"),
ctx,
);
}
}
me.refresh_dir(ctx);
}
Ok(Err(e)) => {
log::error!("sftp: cross-pane transfer failed: {e}");
me.show_error_toast(format!("Transfer failed: {e}"), ctx);
Expand Down
14 changes: 8 additions & 6 deletions app/src/sftp_manager/browser_integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2069,10 +2069,11 @@ fn test_f5_local_to_remote_starts_an_upload_transfer() {
});
}

/// F6 (move) across connections is deferred, not partially done: no transfer
/// starts and nothing is renamed.
/// F6 (move) across connections now runs as an upload transfer (copy-then-delete):
/// a transfer starts. The source removal happens on async completion, so this
/// synchronous check asserts the transfer was created, not the final deletion.
#[test]
fn test_f6_move_across_connections_is_deferred() {
fn test_f6_move_across_connections_starts_transfer() {
warpui::App::test((), |mut app| async move {
initialize_app(&mut app);
let temp = create_temp_dir_with_files(&[("left/bar.txt", b"x"), ("right/.keep", b"")]);
Expand All @@ -2095,8 +2096,9 @@ fn test_f6_move_across_connections_is_deferred() {
v.handle_action(&SftpBrowserAction::MoveToOtherPane, ctx);
});

view_a.read(&app, |v, _| assert!(v.transfers.is_empty(), "no transfer started"));
assert!(root.join("left/bar.txt").exists(), "source untouched");
assert!(!root.join("right/bar.txt").exists(), "nothing moved");
view_a.read(&app, |v, _| {
assert_eq!(v.transfers.len(), 1, "cross-connection move starts one transfer");
assert_eq!(v.transfers[0].direction, TransferDirection::Upload);
});
});
}
Loading