From c90ed91a84f9c2cbf082cb65a73519e22d356db1 Mon Sep 17 00:00:00 2001 From: iret77 <63622643+iret77@users.noreply.github.com> Date: Sat, 4 Jul 2026 11:26:21 +0200 Subject: [PATCH] feat(fm): copy/move between file-manager panes (MC F5/F6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The defining MC operation: F5 copies and F6 moves the selection (or the cursor row) into another file-manager pane — the two-panel workflow the integrated FM was always meant to support. - fm_registry.rs: a data-only singleton of live FM panes (id, live host:/path label, filesystem namespace, current dir). Each browser publishes its descriptor on every directory change and removes it on close — no view handles held, so no cross-view-borrow or handle lifecycle risk. Unit-tested upsert/remove/same-fs matching. - SftpBackend gains copy_file (+ a default recursive copy_dir): native std::fs copy for the local backend; the live SFTP backend round-trips bytes through a local temp via the proven streaming primitives (SFTP has no server-side copy). Move reuses rename (atomic on one fs). - F5/F6 pick the single other same-namespace pane automatically (the MC two-panel case); 0 or >1 candidates are reported, never guessed. Existing targets are skipped, never silently overwritten; the result (copied / skipped / failed → target) is toasted. Cross-connection (local<->remote) transfer stays a later increment. - F6 is now Move (MC parity); rename moves to F2 (kept keyboard-reachable and via the context menu). Verified: cargo check -p warp 0/0; 244 sftp + 3 registry + 6 keynav tests green (5 new end-to-end cross-pane copy/move/dir/guard/skip tests). No build triggered. Co-Authored-By: Claude Fable 5 --- app/src/lib.rs | 3 + app/src/sftp_manager/browser.rs | 209 ++++++++++++++++-- .../sftp_manager/browser_integration_tests.rs | 149 +++++++++++++ app/src/sftp_manager/browser_tests.rs | 1 + app/src/sftp_manager/fm_registry.rs | 149 +++++++++++++ app/src/sftp_manager/mod.rs | 1 + app/src/sftp_manager/sftp_backend.rs | 54 +++++ 7 files changed, 553 insertions(+), 13 deletions(-) create mode 100644 app/src/sftp_manager/fm_registry.rs diff --git a/app/src/lib.rs b/app/src/lib.rs index 9ae0280207..1656d3f259 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -1633,6 +1633,9 @@ fn initialize_app( // Cockpit data spine: registered after HomeDirectoryWatcher (which it subscribes to). ctx.add_singleton_model(cockpit::CockpitModel::new); + // Cross-pane file-manager registry (F5/F6 copy/move target discovery). + ctx.add_singleton_model(|_| sftp_manager::fm_registry::FileManagerRegistry::new()); + // TemplatableMCPServerManager must be registered after UpdateManager and MCPServerManager so it can migrate legacy MCPs on start up // It should also be registered after FileBasedMCPManager so it can receive file-based server updates. ctx.add_singleton_model(|ctx| { diff --git a/app/src/sftp_manager/browser.rs b/app/src/sftp_manager/browser.rs index 6dc4598b2a..61173f801a 100644 --- a/app/src/sftp_manager/browser.rs +++ b/app/src/sftp_manager/browser.rs @@ -39,6 +39,7 @@ use crate::view_components::DismissibleToast; use crate::workspace::ToastStack; use super::context_menu::ContextMenuState; +use super::fm_registry::{FileManagerRegistry, FmPaneDescriptor, FsNamespace}; use super::keynav::{apply_cursor_move, clamp_cursor, CursorMove}; use super::sftp_backend::{LiveSftpBackend, SftpBackend}; use super::sftp_ops; @@ -56,7 +57,7 @@ const FUNCTION_BAR: &[(&str, &str, fn() -> SftpBrowserAction)] = &[ ("F3", "View", || SftpBrowserAction::ActivateCursor), ("F4", "Edit", || SftpBrowserAction::ActivateCursor), ("F5", "Copy", || SftpBrowserAction::CopyToOtherPane), - ("F6", "Rename", || SftpBrowserAction::RenameCursor), + ("F6", "Move", || SftpBrowserAction::MoveToOtherPane), ("F7", "MkDir", || SftpBrowserAction::CreateFolder), ("F8", "Delete", || SftpBrowserAction::DeleteSelected), ("F10", "Quit", || SftpBrowserAction::CloseFileManager), @@ -259,6 +260,9 @@ pub struct SftpBrowserView { /// Hover/click state for each cell of the F-key function bar (mouse parity /// with the keyboard function keys). One per [`FUNCTION_BAR`] entry. fn_bar_handles: Vec, + /// Process-unique id for the cross-pane file-manager registry (F5/F6 + /// copy/move target discovery). + fm_id: u64, // ---- File row mouse handles ---- /// Mouse state handle for each file entry row row_mouse_handles: Vec, @@ -319,6 +323,7 @@ impl SftpBrowserView { search_editor, cursor: 0, fn_bar_handles: FUNCTION_BAR.iter().map(|_| MouseStateHandle::default()).collect(), + fm_id: super::fm_registry::next_fm_id(), row_mouse_handles: Vec::new(), scroll_state: ClippedScrollStateHandle::default(), connect_handle: None, @@ -488,6 +493,7 @@ impl SftpBrowserView { self.pane_configuration.update(ctx, |config, ctx| { config.set_title(title, ctx); }); + self.publish_to_registry(ctx); ctx.notify(); } @@ -701,6 +707,7 @@ impl SftpBrowserView { me.pane_configuration.update(ctx, |config, ctx| { config.set_title(title, ctx); }); + me.publish_to_registry(ctx); ctx.notify(); }, ); @@ -794,6 +801,176 @@ impl SftpBrowserView { } } + // ---- Cross-pane copy/move (MC F5/F6) -------------------------------- + + /// Which filesystem this pane browses — local, or a specific remote host. + /// Two panes can copy/move between each other only within one namespace. + fn fs_namespace(&self) -> FsNamespace { + if self.node_id.is_empty() { + FsNamespace::Local + } else { + FsNamespace::Remote(self.node_id.clone()) + } + } + + /// Human label for the destination picker, kept live with the current dir. + fn fm_label(&self) -> String { + let where_ = if self.node_id.is_empty() { + "local".to_string() + } else { + self.node_id.clone() + }; + format!("{where_}:{}", self.current_path.display()) + } + + /// Publish this pane's live descriptor into the cross-pane registry. + fn publish_to_registry(&self, ctx: &mut ViewContext) { + let descriptor = FmPaneDescriptor { + id: self.fm_id, + label: self.fm_label(), + fs: self.fs_namespace(), + current_path: self.current_path.clone(), + }; + FileManagerRegistry::handle(ctx).update(ctx, move |reg, _| reg.upsert(descriptor)); + } + + /// Remove this pane from the registry (on close). + fn deregister_from_registry(&self, ctx: &mut ViewContext) { + let id = self.fm_id; + FileManagerRegistry::handle(ctx).update(ctx, move |reg, _| reg.remove(id)); + } + + /// The paths this operation acts on: the multi-selection if any, otherwise + /// the single row under the cursor. + fn operation_sources(&self) -> Vec { + if self.selected.is_empty() { + self.cursor_entry_index() + .and_then(|i| self.entries.get(i)) + .map(|e| vec![e.path.clone()]) + .unwrap_or_default() + } else { + let mut indices: Vec = self.selected.iter().copied().collect(); + indices.sort_unstable(); + indices + .iter() + .filter_map(|&i| self.entries.get(i).map(|e| e.path.clone())) + .collect() + } + } + + /// F5/F6: copy (or move) the operation's sources into another file-manager + /// pane on the same filesystem. Chooses the single other same-namespace + /// pane automatically (the MC two-panel case); 0 or >1 candidates are + /// reported rather than guessing. Cross-connection (local↔remote) transfer + /// is a separate increment. + fn copy_or_move_to_other_pane(&mut self, is_move: bool, ctx: &mut ViewContext) { + let verb = if is_move { "move" } else { "copy" }; + let sources = self.operation_sources(); + if sources.is_empty() { + self.show_error_toast(format!("Nothing selected to {verb}."), ctx); + return; + } + + let fs = self.fs_namespace(); + let self_id = self.fm_id; + let candidates = FileManagerRegistry::as_ref(ctx).others_same_fs(self_id, &fs); + let target = match candidates.as_slice() { + [] => { + self.show_error_toast(format!( + "Open a second file-manager pane on the same {} to {verb} into it.", + if matches!(fs, FsNamespace::Local) { "machine" } else { "host" } + ), ctx); + return; + } + [only] => only.clone(), + _ => { + // A destination picker across >2 panes is the next step; be + // explicit rather than picking an arbitrary target. + self.show_error_toast( + "More than one other file panel is open — the target picker is coming; \ + for now keep exactly two panels to copy/move between them." + .to_string(), + ctx, + ); + return; + } + }; + + let backend = match &self.sftp { + Some(b) => b.clone(), + None => { + self.show_error_toast("Not connected.".to_string(), ctx); + return; + } + }; + let target_dir = target.current_path.clone(); + + // Copying into our own current directory would collide name-for-name; + // guard against the no-op/foot-gun. + if target_dir == self.current_path { + self.show_error_toast( + format!("Source and destination are the same folder — nothing to {verb}."), + ctx, + ); + return; + } + + let mut copied = 0usize; + let mut skipped = 0usize; + let mut errors = 0usize; + for source in &sources { + let Some(name) = source.file_name() else { + continue; + }; + let dest = normalize_remote_path(&target_dir.join(name)); + // Never overwrite silently: skip existing targets and report. + if backend.stat(&dest).is_ok() { + skipped += 1; + continue; + } + let is_dir = self + .entries + .iter() + .find(|e| &e.path == source) + .map(|e| matches!(e.file_type, FileEntryType::Directory)) + .unwrap_or(false); + let result = if is_move { + // Same filesystem → a rename moves atomically without copying. + backend.rename(source, &dest) + } else if is_dir { + backend.copy_dir_recursive(source, &dest) + } else { + backend.copy_file(source, &dest) + }; + match result { + Ok(()) => copied += 1, + Err(e) => { + errors += 1; + log::warn!("fm {verb} {source:?} -> {dest:?} failed: {e}"); + } + } + } + + // Report and refresh: a move changes our listing; a copy changes the + // target's (which refreshes itself on its own cadence / user refresh). + let past = if is_move { "moved" } else { "copied" }; + let mut parts = vec![format!("{copied} {past}")]; + if skipped > 0 { + parts.push(format!("{skipped} skipped (already exist)")); + } + if errors > 0 { + parts.push(format!("{errors} failed")); + } + let summary = format!("{} → {}", parts.join(", "), target.label); + if errors > 0 { + self.show_error_toast(summary, ctx); + } else { + self.show_info_toast(summary, ctx); + } + self.selected.clear(); + self.refresh_dir(ctx); + } + /// Show an error toast notification fn show_error_toast(&self, message: String, ctx: &mut ViewContext) { let window_id = ctx.window_id(); @@ -804,6 +981,16 @@ impl SftpBrowserView { }); } + /// Show a success/info toast notification (e.g. a copy/move summary). + fn show_info_toast(&self, message: String, ctx: &mut ViewContext) { + let window_id = ctx.window_id(); + ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { + let toast = DismissibleToast::success(message) + .with_object_id("sftp_info".to_string()); + toast_stack.add_ephemeral_toast(toast, window_id, ctx); + }); + } + /// Navigate to the given path and update the history fn navigate_to(&mut self, path: PathBuf, ctx: &mut ViewContext) { let path = normalize_remote_path(&path); @@ -1917,17 +2104,8 @@ impl TypedActionView for SftpBrowserView { SftpBrowserAction::EnterCursorDir => self.enter_cursor_dir(ctx), SftpBrowserAction::ToggleSelectCursor => self.toggle_select_cursor(ctx), SftpBrowserAction::RenameCursor => self.rename_cursor(ctx), - SftpBrowserAction::CopyToOtherPane | SftpBrowserAction::MoveToOtherPane => { - // Cross-pane transfer (the registry + transfer engine) is the - // next increment. Until then, say so plainly rather than - // silently doing nothing. - self.show_error_toast( - "Copy/Move to another file panel is coming next — open a second \ - file-manager pane to use it." - .to_string(), - ctx, - ); - } + SftpBrowserAction::CopyToOtherPane => self.copy_or_move_to_other_pane(false, ctx), + SftpBrowserAction::MoveToOtherPane => self.copy_or_move_to_other_pane(true, ctx), SftpBrowserAction::CloseFileManager => { // Reverts the pane to its underlying terminal (temporary-replacement seam). ctx.emit(PaneEvent::Close); @@ -2181,10 +2359,13 @@ impl View for SftpBrowserView { "right" => Some(SftpBrowserAction::EnterCursorDir), "left" | "backspace" => Some(SftpBrowserAction::NavigateUp), "space" => Some(SftpBrowserAction::ToggleSelectCursor), + // Rename (F2, the common file-manager convention; MC's F6 + // is repurposed here for the cross-pane move). + "f2" => Some(SftpBrowserAction::RenameCursor), // Function-key bar (MC parity) "f3" | "f4" => Some(SftpBrowserAction::ActivateCursor), "f5" => Some(SftpBrowserAction::CopyToOtherPane), - "f6" => Some(SftpBrowserAction::RenameCursor), + "f6" => Some(SftpBrowserAction::MoveToOtherPane), "f7" => Some(SftpBrowserAction::CreateFolder), "f8" | "delete" => Some(SftpBrowserAction::DeleteSelected), "f10" => Some(SftpBrowserAction::CloseFileManager), @@ -2236,6 +2417,8 @@ impl BackingView for SftpBrowserView { self._session = None; self.sftp = None; self.connection = ConnectionState::Disconnected; + // Stop advertising this pane as a copy/move target. + self.deregister_from_registry(ctx); ctx.emit(PaneEvent::Close); } diff --git a/app/src/sftp_manager/browser_integration_tests.rs b/app/src/sftp_manager/browser_integration_tests.rs index 0eaef2ee15..52fa4778a8 100644 --- a/app/src/sftp_manager/browser_integration_tests.rs +++ b/app/src/sftp_manager/browser_integration_tests.rs @@ -30,6 +30,7 @@ fn initialize_app(app: &mut warpui::App) { app.add_singleton_model(|_| Appearance::mock()); app.add_singleton_model(|_| KeybindingChangedNotifier::mock()); app.add_singleton_model(|_| ToastStack); + app.add_singleton_model(|_| super::fm_registry::FileManagerRegistry::new()); let temp_db = std::env::temp_dir().join("warp_sftp_integration_test.sqlite"); let _ = warp_ssh_manager::set_database_path(temp_db); @@ -1867,3 +1868,151 @@ fn test_render_after_multiple_operations() { }); }); } + +// ============================================================ +// Cross-pane copy/move (MC F5/F6, increment B) +// ============================================================ + +/// Two file-manager panes over one shared filesystem, at `/left` and `/right`. +/// Returns both handles; keep `TempDir` alive for the test's duration. +fn create_two_panes_sharing_fs( + app: &mut warpui::App, + files: &[(&str, &[u8])], +) -> ( + warpui::ViewHandle, + warpui::ViewHandle, + tempfile::TempDir, +) { + let temp = create_temp_dir_with_files(files); + let root = temp.path().to_path_buf(); + + let (_, view_a) = create_view(app); + view_a.update(app, |v, ctx| { + let backend = + Arc::new(InMemorySftpBackend::new(root.clone())) as Arc; + v.set_backend_for_test(backend, PathBuf::from("/left"), ctx); + }); + let (_, view_b) = create_view(app); + view_b.update(app, |v, ctx| { + let backend = + Arc::new(InMemorySftpBackend::new(root.clone())) as Arc; + v.set_backend_for_test(backend, PathBuf::from("/right"), ctx); + }); + + (view_a, view_b, temp) +} + +/// F5 copies the cursor row from one pane into the other pane's directory, +/// leaving the source in place. +#[test] +fn test_f5_copies_cursor_file_into_other_pane() { + warpui::App::test((), |mut app| async move { + initialize_app(&mut app); + let (view_a, _view_b, temp) = create_two_panes_sharing_fs( + &mut app, + &[("left/foo.txt", b"hello"), ("right/.keep", b"")], + ); + let root = temp.path().to_path_buf(); + + // Pane A's cursor is on the only file in /left → F5 → into /right. + view_a.update(&mut app, |v, ctx| { + v.handle_action(&SftpBrowserAction::CopyToOtherPane, ctx); + }); + + assert!(root.join("right/foo.txt").exists(), "copied into /right"); + assert!(root.join("left/foo.txt").exists(), "source kept after copy"); + assert_eq!( + std::fs::read(root.join("right/foo.txt")).unwrap(), + b"hello", + "copied content matches" + ); + }); +} + +/// F6 moves the cursor row into the other pane (source removed). +#[test] +fn test_f6_moves_cursor_file_into_other_pane() { + warpui::App::test((), |mut app| async move { + initialize_app(&mut app); + let (view_a, _view_b, temp) = create_two_panes_sharing_fs( + &mut app, + &[("left/bar.txt", b"data"), ("right/.keep", b"")], + ); + let root = temp.path().to_path_buf(); + + view_a.update(&mut app, |v, ctx| { + v.handle_action(&SftpBrowserAction::MoveToOtherPane, ctx); + }); + + assert!(root.join("right/bar.txt").exists(), "moved into /right"); + assert!(!root.join("left/bar.txt").exists(), "source removed after move"); + }); +} + +/// F5 with a directory selected copies it recursively. +#[test] +fn test_f5_copies_directory_recursively() { + warpui::App::test((), |mut app| async move { + initialize_app(&mut app); + let (view_a, _view_b, temp) = create_two_panes_sharing_fs( + &mut app, + &[("left/sub/deep.txt", b"deep"), ("right/.keep", b"")], + ); + let root = temp.path().to_path_buf(); + + // /left contains exactly one entry, the directory "sub" → cursor on it. + view_a.update(&mut app, |v, ctx| { + v.handle_action(&SftpBrowserAction::CopyToOtherPane, ctx); + }); + + assert!( + root.join("right/sub/deep.txt").exists(), + "directory copied recursively into /right" + ); + }); +} + +/// With no second pane, F5 is a no-op (reports, copies nothing). +#[test] +fn test_f5_without_second_pane_copies_nothing() { + warpui::App::test((), |mut app| async move { + initialize_app(&mut app); + let (_, view, temp) = create_connected_view(&mut app, &[("only.txt", b"x")]); + let root = temp.path().to_path_buf(); + + view.update(&mut app, |v, ctx| { + v.handle_action(&SftpBrowserAction::CopyToOtherPane, ctx); + }); + + // Nothing new appeared anywhere. + assert_eq!( + std::fs::read_dir(&root).unwrap().count(), + 1, + "no copy target → nothing created" + ); + }); +} + +/// An existing target is skipped, never silently overwritten. +#[test] +fn test_f5_skips_existing_target() { + warpui::App::test((), |mut app| async move { + initialize_app(&mut app); + let (view_a, _view_b, temp) = create_two_panes_sharing_fs( + &mut app, + &[("left/dup.txt", b"new"), ("right/dup.txt", b"original")], + ); + let root = temp.path().to_path_buf(); + + view_a.update(&mut app, |v, ctx| { + v.handle_action(&SftpBrowserAction::CopyToOtherPane, ctx); + }); + + // The pre-existing /right/dup.txt is untouched. + assert_eq!( + std::fs::read(root.join("right/dup.txt")).unwrap(), + b"original", + "existing target must not be overwritten" + ); + }); +} diff --git a/app/src/sftp_manager/browser_tests.rs b/app/src/sftp_manager/browser_tests.rs index d69e0c9caf..dfbd977c2a 100644 --- a/app/src/sftp_manager/browser_tests.rs +++ b/app/src/sftp_manager/browser_tests.rs @@ -30,6 +30,7 @@ fn initialize_app(app: &mut warpui::App) { app.add_singleton_model(|_| Appearance::mock()); app.add_singleton_model(|_| KeybindingChangedNotifier::mock()); app.add_singleton_model(|_| ToastStack); + app.add_singleton_model(|_| super::fm_registry::FileManagerRegistry::new()); // The SSH manager needs a SQLite path; use a temporary file so that failed queries don't panic let temp_db = std::env::temp_dir().join("warp_sftp_test.sqlite"); diff --git a/app/src/sftp_manager/fm_registry.rs b/app/src/sftp_manager/fm_registry.rs new file mode 100644 index 0000000000..ff9c93de99 --- /dev/null +++ b/app/src/sftp_manager/fm_registry.rs @@ -0,0 +1,149 @@ +//! Registry of live file-manager panes, so one pane can copy/move into another +//! (the MC F5/F6 verbs). Holds **plain data only** — never view handles — so +//! there is no cross-view borrow or handle-lifecycle risk: each browser pushes +//! a descriptor of itself when its directory changes and removes it on close. +//! +//! The registry is a singleton ([`SingletonEntity`]); it is registered at app +//! startup and in the file-manager test harnesses. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +use warpui::{Entity, SingletonEntity}; + +/// Process-unique id for a file-manager pane, handed out at construction. +static NEXT_FM_ID: AtomicU64 = AtomicU64::new(1); + +/// Allocate a fresh pane id. +pub fn next_fm_id() -> u64 { + NEXT_FM_ID.fetch_add(1, Ordering::Relaxed) +} + +/// Which filesystem namespace a pane browses. Copy/move between two panes is +/// only a same-namespace operation for now (local↔remote transfers are a later +/// increment); `Remote` carries the SSH node id so two panes on the *same* host +/// match but two different hosts do not. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FsNamespace { + Local, + Remote(String), +} + +/// A live snapshot of one file-manager pane, as seen by the others. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FmPaneDescriptor { + /// Stable, process-unique id of the pane. + pub id: u64, + /// Human label for the destination picker, e.g. `local:/home/u` or + /// `host:/var/www` (kept live with the pane's current directory). + pub label: String, + /// The filesystem this pane browses (for same-namespace matching). + pub fs: FsNamespace, + /// The pane's current directory — the copy/move destination. + pub current_path: PathBuf, +} + +/// The set of currently-open file-manager panes. +#[derive(Default)] +pub struct FileManagerRegistry { + panes: Vec, +} + +impl FileManagerRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Insert or update the descriptor for a pane (keyed by `id`). + pub fn upsert(&mut self, descriptor: FmPaneDescriptor) { + if let Some(existing) = self.panes.iter_mut().find(|p| p.id == descriptor.id) { + *existing = descriptor; + } else { + self.panes.push(descriptor); + } + } + + /// Remove a pane (on close). A no-op if it was never registered. + pub fn remove(&mut self, id: u64) { + self.panes.retain(|p| p.id != id); + } + + /// Every other pane that shares `fs` — the candidate copy/move targets for + /// the pane identified by `self_id`. + pub fn others_same_fs(&self, self_id: u64, fs: &FsNamespace) -> Vec { + self.panes + .iter() + .filter(|p| p.id != self_id && &p.fs == fs) + .cloned() + .collect() + } + + /// All registered panes (for tests/diagnostics). + pub fn panes(&self) -> &[FmPaneDescriptor] { + &self.panes + } +} + +impl Entity for FileManagerRegistry { + type Event = (); +} + +impl SingletonEntity for FileManagerRegistry {} + +#[cfg(test)] +mod tests { + use super::*; + + fn desc(id: u64, fs: FsNamespace, path: &str) -> FmPaneDescriptor { + FmPaneDescriptor { + id, + label: format!("pane{id}"), + fs, + current_path: PathBuf::from(path), + } + } + + #[test] + fn upsert_inserts_then_updates_in_place() { + let mut reg = FileManagerRegistry::new(); + reg.upsert(desc(1, FsNamespace::Local, "/a")); + reg.upsert(desc(2, FsNamespace::Local, "/b")); + assert_eq!(reg.panes().len(), 2); + + // Same id → replace, not append. + reg.upsert(desc(1, FsNamespace::Local, "/a2")); + assert_eq!(reg.panes().len(), 2); + assert_eq!( + reg.panes().iter().find(|p| p.id == 1).unwrap().current_path, + PathBuf::from("/a2") + ); + } + + #[test] + fn remove_is_idempotent() { + let mut reg = FileManagerRegistry::new(); + reg.upsert(desc(1, FsNamespace::Local, "/a")); + reg.remove(1); + reg.remove(1); // no panic, no-op + assert!(reg.panes().is_empty()); + } + + #[test] + fn others_same_fs_excludes_self_and_other_namespaces() { + let mut reg = FileManagerRegistry::new(); + reg.upsert(desc(1, FsNamespace::Local, "/a")); + reg.upsert(desc(2, FsNamespace::Local, "/b")); + reg.upsert(desc(3, FsNamespace::Remote("host1".into()), "/c")); + reg.upsert(desc(4, FsNamespace::Remote("host2".into()), "/d")); + + // From pane 1 (local): only pane 2 is a valid local target. + let targets = reg.others_same_fs(1, &FsNamespace::Local); + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].id, 2); + + // From pane 3 (host1): no other host1 pane exists. + assert!(reg + .others_same_fs(3, &FsNamespace::Remote("host1".into())) + .is_empty()); + } +} diff --git a/app/src/sftp_manager/mod.rs b/app/src/sftp_manager/mod.rs index 1738e5e219..80a1401da0 100644 --- a/app/src/sftp_manager/mod.rs +++ b/app/src/sftp_manager/mod.rs @@ -10,6 +10,7 @@ pub mod context_menu; pub mod dialogs; pub mod drop_target; pub mod file_list; +pub mod fm_registry; pub mod keynav; pub mod sftp_backend; pub mod sftp_ops; diff --git a/app/src/sftp_manager/sftp_backend.rs b/app/src/sftp_manager/sftp_backend.rs index 379280bede..48917b7156 100644 --- a/app/src/sftp_manager/sftp_backend.rs +++ b/app/src/sftp_manager/sftp_backend.rs @@ -56,6 +56,26 @@ pub trait SftpBackend: Send + Sync { progress_cb: Option<&ProgressCallback>, cancel_flag: Option<&AtomicBool>, ) -> Result<(), SftpOpsError>; + + /// Copies a single file *within this backend* (same filesystem namespace), + /// e.g. between two local file-manager panes or two panes on the same host. + /// Cross-connection copy (local↔remote) is a separate transfer path. + fn copy_file(&self, src: &Path, dst: &Path) -> Result<(), SftpOpsError>; + + /// Recursively copies a directory within this backend. The default walks + /// with `list_dir` + `create_dir` + `copy_file`; backends may override with + /// a native recursive copy. + fn copy_dir_recursive(&self, src: &Path, dst: &Path) -> Result<(), SftpOpsError> { + self.create_dir(dst)?; + for entry in self.list_dir(src)? { + let child_dst = dst.join(&entry.name); + match entry.file_type { + FileEntryType::Directory => self.copy_dir_recursive(&entry.path, &child_dst)?, + _ => self.copy_file(&entry.path, &child_dst)?, + } + } + Ok(()) + } } // ============================================================ @@ -158,6 +178,28 @@ impl SftpBackend for LiveSftpBackend { let flag = cancel_flag.unwrap_or(&NEVER_CANCEL); sftp_ops::download_file_streaming(&self.sftp, remote_path, local_path, progress_cb, flag) } + + fn copy_file(&self, src: &Path, dst: &Path) -> Result<(), SftpOpsError> { + // SFTP has no server-side copy, so round-trip the bytes through a local + // temp file using the proven streaming primitives. Same-host only (the + // caller guarantees source and destination share this session). Not the + // most efficient path, but copy is a rare, non-hot operation and this + // reuses fully-tested transfer code rather than a new protocol path. + let tmp = unique_temp_path("zaplex-fmcopy"); + self.download_file(src, &tmp, None, None)?; + let result = self.upload_file(&tmp, dst, None, None); + let _ = fs::remove_file(&tmp); + result + } +} + +/// A collision-free temp path (process id + monotonic counter; no wall clock so +/// it stays deterministic-friendly and needs no rng). +fn unique_temp_path(prefix: &str) -> PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("{prefix}-{}-{n}", std::process::id())) } @@ -378,6 +420,18 @@ impl SftpBackend for InMemorySftpBackend { })?; Ok(()) } + + fn copy_file(&self, src: &Path, dst: &Path) -> Result<(), SftpOpsError> { + let src_local = self.to_local(src); + let dst_local = self.to_local(dst); + if let Some(parent) = dst_local.parent() { + fs::create_dir_all(parent) + .map_err(|e| SftpOpsError::LocalIo(format!("Failed to create directory: {e}")))?; + } + fs::copy(&src_local, &dst_local) + .map_err(|e| SftpOpsError::LocalIo(format!("Failed to copy file: {e}")))?; + Ok(()) + } } /// Convenience method for creating Arc.