From c7c3ae33e2c16269ccf78f36b1c2d00815165bba Mon Sep 17 00:00:00 2001 From: iret77 <63622643+iret77@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:37:33 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(fm):=20remote=20View/Edit=20=E2=80=94?= =?UTF-8?q?=20native=20editor=20on=20daemon=20hosts=20(F3/F4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SFTP file manager (F3/F4) now opens a file on a remote host in the native code editor when that host has a live persistent (daemon) session: the editor buffer syncs with the daemon and writes back safely — real Save, not a throwaway download. This is the devhost / resilient-host case the user actually works in. The file manager is keyed by the SSH `node_id`; the native editor needs a daemon `HostId`, which is opaque and only known after the daemon handshake completes. Bridge it pull-based: record `node_id → daemon SessionId` when a resilient host connects (`try_open_daemon_ssh_terminal`) or is adopted (`adopt_daemon_session`), and resolve a live `HostId` on demand via `RemoteServerManager::{host_id_for_session, client_for_host}` at open time (`daemon_host_for_node`). Re-checking the live manager on every lookup makes a stale entry harmless (session gone → graceful fallback), and since `SessionId`s are never reused an entry can only ever resolve to its own host or to nothing — never a wrong host. The daemon session/connect flow itself is untouched; the mapping is observe-only, so no blast radius on the persistence feature. Classic SSH-only hosts (no daemon) fall back to an honest toast instead of editing a copy that would never save back; SFTP working-copy save-back over classic SSH is the next increment. Verified: `cargo check -p warp` compiles clean (build.rs enables `local_tty` + `local_fs` = the shipping feature set; the native path is actually compiled, not cfg'd out). Runtime acceptance: F3/F4 on a file in a file-manager pane on a devhost (daemon) tab round-trips edit+save. Co-Authored-By: Claude Opus 4.8 --- app/src/workspace/view.rs | 105 +++++++++++++++++++++++++++++++++----- 1 file changed, 93 insertions(+), 12 deletions(-) diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 5472f0135c..1b9624568a 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -872,6 +872,17 @@ pub struct Workspace { /// host-scoped advisories (e.g. the multiplexer-nesting warning toast). #[cfg(unix)] daemon_session_hosts: std::collections::HashMap, + /// SSH host `node_id` → its daemon connection `SessionId`, recorded when a + /// resilient host opens (or adopts) a daemon-backed session. The SFTP file + /// manager is keyed by `node_id`; this lets it resolve a live daemon + /// `HostId` so it can open remote files in the *native* editor (buffer-sync + /// with safe save-back) instead of a download-only copy. Pull-based: + /// `daemon_host_for_node` re-checks the manager for a live client on every + /// lookup, so a stale entry (session since gone) is harmless — it just falls + /// back. `SessionId`s are never reused, so an entry can only ever resolve to + /// its own host or to nothing, never to a wrong host. + #[cfg(all(unix, feature = "local_tty"))] + daemon_node_sessions: std::collections::HashMap, /// Hosts already warned about multiplexer nesting this app run — one /// warning per host, not one per session/tab. #[cfg(unix)] @@ -2918,6 +2929,8 @@ impl Workspace { ssh_tab_nodes: std::collections::HashMap::new(), #[cfg(unix)] daemon_session_hosts: std::collections::HashMap::new(), + #[cfg(all(unix, feature = "local_tty"))] + daemon_node_sessions: std::collections::HashMap::new(), #[cfg(unix)] multiplexer_warned_hosts: std::collections::HashSet::new(), hovered_tab_index: None, @@ -5678,6 +5691,71 @@ impl Workspace { ); } + /// Opens a file that lives on a remote host — dispatched from the SFTP file + /// manager (F3/F4) via `WorkspaceAction::OpenFileInEditor` with a non-empty + /// `node_id`. On a host that has a live persistent (daemon) session the file + /// is opened *natively*: the editor buffer syncs with the daemon and writes + /// back safely, exactly like the sidebar server file browser. On a classic + /// SSH-only host (no daemon) native buffer-sync isn't wired yet; rather than + /// silently editing a throwaway download that never saves back, we say so. + fn open_file_in_editor_remote( + &mut self, + node_id: String, + path: PathBuf, + ctx: &mut ViewContext, + ) { + #[cfg(all(unix, feature = "local_tty"))] + if let Some(host_id) = self.daemon_host_for_node(&node_id, &*ctx) { + let path_str = path.to_string_lossy(); + match warp_util::standardized_path::StandardizedPath::try_new(&path_str) { + Ok(remote_path) => { + self.open_remote_file( + crate::code::buffer_location::RemotePath::new(host_id, remote_path), + ctx, + ); + return; + } + Err(error) => { + log::warn!( + "file manager: cannot open remote file {} on {node_id}: {error}", + path.display() + ); + } + } + } + + // Classic SSH-only host, or the daemon session isn't currently live: + // editing via an SFTP working copy (with save-back on save) is the next + // increment. Be honest instead of handing back a copy that won't persist. + let message = format!( + "Editing {} on {node_id} in the editor needs a persistent (daemon) session on that \ + host. Working-copy editing over classic SSH is coming — use the file manager's \ + Enter / Download for now.", + path.display() + ); + self.toast_stack.update(ctx, |view, ctx| { + view.add_ephemeral_toast(DismissibleToast::error(message), ctx); + }); + } + + /// Resolves an SFTP file-manager `node_id` to a live daemon `HostId`, if that + /// host currently has a connected daemon session with a usable client. The + /// SFTP file manager is keyed by `node_id`; the native remote editor needs a + /// `HostId`, which is opaque and only known once the daemon handshake + /// completes — hence this pull-based lookup against the live manager state. + /// Returns `None` for classic SSH hosts (no daemon session recorded) and for + /// daemon hosts whose session has since dropped, so the caller falls back. + #[cfg(all(unix, feature = "local_tty"))] + fn daemon_host_for_node(&self, node_id: &str, ctx: &AppContext) -> Option { + let session_id = *self.daemon_node_sessions.get(node_id)?; + let manager = crate::remote_server::manager::RemoteServerManager::as_ref(ctx); + let host_id = manager.host_id_for_session(session_id)?; + // Buffer-sync needs a live client for the host; if there is none the + // session is not usable and we fall back rather than open a dead editor. + manager.client_for_host(host_id)?; + Some(host_id.clone()) + } + /// Opens a new terminal pane in the current tab, automatically runs the `ssh ...` command, /// and spawns a SecretInjector that watches the PTY output and automatically injects the /// secret from the keychain when a `password:` / `passphrase:` prompt appears. @@ -5963,6 +6041,13 @@ impl Workspace { .ssh_tab_nodes .insert(pane_group.id(), node_id_owned.clone()); } + // Record node_id → daemon session so the SFTP file manager on this + // host can later resolve a live `HostId` and open files in the + // native editor (see `daemon_host_for_node`). + #[cfg(feature = "local_tty")] + workspace + .daemon_node_sessions + .insert(node_id_owned.clone(), session_id); match progress_tx { // Daemon already installed → connect right away. @@ -6173,6 +6258,11 @@ impl Workspace { self.ssh_tab_nodes .insert(pane_group_id, server.node_id.clone()); } + // Same node_id → daemon session mapping as the fresh-connect path, so the + // file manager on an adopted host can open files natively too. + #[cfg(feature = "local_tty")] + self.daemon_node_sessions + .insert(server.node_id.clone(), session_id); self.spawn_daemon_session_connect(server, session_id, ctx); } @@ -19738,18 +19828,9 @@ impl TypedActionView for Workspace { // Local file: open it directly in a code pane (view + edit). self.add_tab_for_code_file(path.clone(), None, ctx); } else { - // Remote file: native editing (buffer-sync via the SSH daemon) - // needs the host's daemon connection + HostId; not yet wired - // from the SFTP file manager. Report honestly rather than - // download-and-edit a local copy that wouldn't save back. - let message = format!( - "Opening remote files in the editor is coming — {} is on {node_id}. \ - Use the file manager's Download for now.", - path.display() - ); - self.toast_stack.update(ctx, |view, ctx| { - view.add_ephemeral_toast(DismissibleToast::error(message), ctx); - }); + // Remote file: open natively on daemon hosts (real buffer-sync + // save-back), honest fallback on classic SSH-only hosts. + self.open_file_in_editor_remote(node_id.clone(), path.clone(), ctx); } } FixSettingsWithOz { error_description } => { From 81ab7f62d55c06251b2ea1d6a8fd8310d65fcc6e Mon Sep 17 00:00:00 2001 From: iret77 <63622643+iret77@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:23:07 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(fm):=20remote=20View/Edit=20over=20cla?= =?UTF-8?q?ssic=20SSH=20=E2=80=94=20working=20copy=20+=20save-back=20(F3/F?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes remote View/Edit for the second host type: a classic SSH host with no persistent daemon. F3/F4 on such a host now downloads the file to a local working copy, opens it in the native editor, and uploads it back over SFTP on every save — real Save, end to end. Mechanism (additive, low blast radius — one new subscription, no editor surgery): - `FileManagerRegistry::backend_for_namespace` hands the workspace a live SFTP connection already open for the host, so there is no second connect. - `begin_remote_sftp_edit` downloads into a unique local working copy (original filename kept for language detection + tab title), opens it via `add_tab_for_code_file`, and records it in `Workspace::remote_sftp_edits` keyed by the canonical local path — holding its own backend `Arc` so save-back survives the file-manager pane closing. - A single subscription to `GlobalBufferModelEvent::FileSaved` drives `on_remote_working_copy_saved`: if the saved path is a tracked working copy, upload it back. Ordinary local saves return immediately. A save that lands mid-upload is coalesced (`resave_pending`) and re-run, so none is dropped. No-data-loss contract: the remote file is only ever written by a save-back upload (never on open); a failed upload keeps the working copy and shows a prominent, persistent error — the editor already flagged the local save as done, so a silent upload failure would be dangerous. Concurrent external edits are last-write-wins on the next save, inherent to editing over plain SFTP; daemon hosts get the version-checked native path instead. Working copies live under the OS temp dir in unique per-edit dirs (pid + counter, never reused); small text files, OS-reclaimed. There is no buffer-closed event to hook for eager deletion, and stale map entries are harmless: a closed buffer emits no further save event, so nothing uploads. Verified: `cargo check -p warp` clean (0 errors/warnings); `fm_registry` unit tests incl. new `backend_for_namespace` pass. Runtime acceptance: F3/F4 on a file in a file-manager pane connected to a non-daemon SSH host, edit + Save, confirm the change lands on the host. Co-Authored-By: Claude Opus 4.8 --- app/src/sftp_manager/fm_registry.rs | 39 ++++ app/src/workspace/view.rs | 285 ++++++++++++++++++++++++++-- 2 files changed, 313 insertions(+), 11 deletions(-) diff --git a/app/src/sftp_manager/fm_registry.rs b/app/src/sftp_manager/fm_registry.rs index f17cac3131..c463c34171 100644 --- a/app/src/sftp_manager/fm_registry.rs +++ b/app/src/sftp_manager/fm_registry.rs @@ -109,6 +109,18 @@ impl FileManagerRegistry { self.backends.get(&id).cloned() } + /// A live backend for *any* open pane browsing `fs`. Lets a non-file-manager + /// caller (e.g. the workspace opening a remote file for editing over classic + /// SSH) borrow an already-established SFTP connection for that host instead + /// of opening a second one. Returns `None` if no open pane browses `fs` yet + /// has a backend registered. + pub fn backend_for_namespace(&self, fs: &FsNamespace) -> Option> { + self.panes + .iter() + .filter(|p| &p.fs == fs) + .find_map(|p| self.backends.get(&p.id).cloned()) + } + /// Remove a pane (on close). A no-op if it was never registered. Drops the /// pane's backend handle too (releasing its session once nothing else holds it). pub fn remove(&mut self, id: u64) { @@ -227,6 +239,33 @@ mod tests { assert!(reg.backend_for(1).is_none()); } + #[test] + fn backend_for_namespace_finds_a_live_backend_for_the_host() { + use super::super::sftp_backend::InMemorySftpBackend; + let mut reg = FileManagerRegistry::new(); + let host = FsNamespace::Remote("h1".into()); + // Two panes on the same host; only the second has a backend registered. + reg.upsert(desc(1, host.clone(), "/a")); + reg.upsert(desc(2, host.clone(), "/b")); + reg.upsert(desc(3, FsNamespace::Local, "/c")); + assert!(reg.backend_for_namespace(&host).is_none()); + + let backend: Arc = + Arc::new(InMemorySftpBackend::new(PathBuf::from("/"))); + reg.set_backend(2, backend); + // A pane on the host now has a backend → resolves. + assert!(reg.backend_for_namespace(&host).is_some()); + // A different host / the local namespace does not. + assert!(reg + .backend_for_namespace(&FsNamespace::Remote("other".into())) + .is_none()); + assert!(reg.backend_for_namespace(&FsNamespace::Local).is_none()); + + // Dropping the only backed pane makes it unresolvable again. + reg.remove(2); + assert!(reg.backend_for_namespace(&host).is_none()); + } + #[test] fn plan_transfer_covers_every_direction() { let local = FsNamespace::Local; diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 1b9624568a..c2e3765f7a 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -852,6 +852,39 @@ pub struct TransferredTab { pub draggable_state: DraggableState, } +/// One remote file being edited over *classic* SSH (no daemon) via a local +/// working copy: the editor edits the copy, and each save uploads it back to +/// the host over SFTP. Held in [`Workspace::remote_sftp_edits`], keyed by the +/// working copy's canonical local path. +#[cfg(all(unix, feature = "local_tty"))] +struct RemoteSftpEdit { + /// SSH node id the file lives on (used in status/error toasts). + node_label: String, + /// Absolute path on the remote host to upload back to. + remote_path: PathBuf, + /// The SFTP connection to upload through. Held as an `Arc` so save-back keeps + /// working even after the file-manager pane that opened the file is closed. + backend: std::sync::Arc, + /// An upload is in flight; coalesce a save that lands during it. + uploading: bool, + /// A save arrived while an upload was in flight — re-upload once it finishes, + /// so no keystroke-run is silently dropped. + resave_pending: bool, +} + +/// A unique local directory for one classic-SSH remote-edit working copy. The +/// original filename is placed *inside* it (kept intact for the editor's +/// language detection + tab title). Lives under the OS temp dir; leftovers are +/// small text files reclaimed by the OS. Never reused (pid + monotonic counter), +/// so two edits — even of the same remote file — never collide. +#[cfg(all(unix, feature = "local_tty"))] +fn remote_sftp_edit_working_dir() -> 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!("zaplex-remote-edit-{}-{n}", std::process::id())) +} + pub struct Workspace { window_id: WindowId, pub(crate) tabs: Vec, @@ -883,6 +916,13 @@ pub struct Workspace { /// its own host or to nothing, never to a wrong host. #[cfg(all(unix, feature = "local_tty"))] daemon_node_sessions: std::collections::HashMap, + /// Remote files opened for editing over *classic* SSH (no daemon), keyed by + /// their local working-copy path. On a `GlobalBufferModelEvent::FileSaved` + /// for one of these paths, the working copy is uploaded back to the host + /// over SFTP. See [`RemoteSftpEdit`]. Stale entries (the editor tab was + /// closed) are harmless: a closed buffer emits no further save event. + #[cfg(all(unix, feature = "local_tty"))] + remote_sftp_edits: std::collections::HashMap, /// Hosts already warned about multiplexer nesting this app run — one /// warning per host, not one per session/tab. #[cfg(unix)] @@ -2635,6 +2675,28 @@ impl Workspace { Self::handle_session_settings_event, ); + // Classic-SSH remote editing (Type 3): when a local working copy of a + // remote file is saved, upload it back to its host over SFTP. Cheap for + // ordinary local saves — the handler returns immediately unless the + // saved path is a tracked remote working copy. + #[cfg(all(unix, feature = "local_tty"))] + ctx.subscribe_to_model( + &crate::code::global_buffer_model::GlobalBufferModel::handle(ctx), + |me, _handle, event, ctx| { + if let crate::code::global_buffer_model::GlobalBufferModelEvent::FileSaved { + file_id, + } = event + { + let saved = crate::code::global_buffer_model::GlobalBufferModel::as_ref(ctx) + .file_path(*file_id) + .map(|path| path.to_path_buf()); + if let Some(path) = saved { + me.on_remote_working_copy_saved(path, ctx); + } + } + }, + ); + // When a remote server session finishes its initialize handshake, re-run // update_active_session so navigate_to_directory fires now that the // client is connected (it may have been skipped on the initial pwd change @@ -2931,6 +2993,8 @@ impl Workspace { daemon_session_hosts: std::collections::HashMap::new(), #[cfg(all(unix, feature = "local_tty"))] daemon_node_sessions: std::collections::HashMap::new(), + #[cfg(all(unix, feature = "local_tty"))] + remote_sftp_edits: std::collections::HashMap::new(), #[cfg(unix)] multiplexer_warned_hosts: std::collections::HashSet::new(), hovered_tab_index: None, @@ -5693,11 +5757,12 @@ impl Workspace { /// Opens a file that lives on a remote host — dispatched from the SFTP file /// manager (F3/F4) via `WorkspaceAction::OpenFileInEditor` with a non-empty - /// `node_id`. On a host that has a live persistent (daemon) session the file - /// is opened *natively*: the editor buffer syncs with the daemon and writes - /// back safely, exactly like the sidebar server file browser. On a classic - /// SSH-only host (no daemon) native buffer-sync isn't wired yet; rather than - /// silently editing a throwaway download that never saves back, we say so. + /// `node_id`. Two paths, picked by whether the host has a live daemon: + /// - **Daemon host:** opened *natively* — the editor buffer syncs with the + /// daemon and writes back safely, like the sidebar server file browser. + /// - **Classic SSH-only host:** downloaded to a local working copy, edited + /// in the native editor, and uploaded back over SFTP on each save + /// (`begin_remote_sftp_edit` + `on_remote_working_copy_saved`). fn open_file_in_editor_remote( &mut self, node_id: String, @@ -5724,13 +5789,18 @@ impl Workspace { } } - // Classic SSH-only host, or the daemon session isn't currently live: - // editing via an SFTP working copy (with save-back on save) is the next - // increment. Be honest instead of handing back a copy that won't persist. + // Classic SSH-only host (no live daemon): download a local working copy, + // edit it in the native editor, and upload it back on each save. + #[cfg(all(unix, feature = "local_tty"))] + if self.begin_remote_sftp_edit(&node_id, &path, ctx) { + return; + } + + // Non-unix, or no live SFTP connection for this host to borrow: nothing + // to open. Be honest instead of silently doing nothing. let message = format!( - "Editing {} on {node_id} in the editor needs a persistent (daemon) session on that \ - host. Working-copy editing over classic SSH is coming — use the file manager's \ - Enter / Download for now.", + "Couldn't open {} from {node_id} in the editor — no live connection to that host. \ + Open a file manager on it and try again.", path.display() ); self.toast_stack.update(ctx, |view, ctx| { @@ -5738,6 +5808,199 @@ impl Workspace { }); } + /// Classic-SSH remote edit (no daemon): borrow a live SFTP connection for + /// `node_id` from an open file-manager pane, download `remote_path` into a + /// unique local working copy, open it in the native editor, and register it + /// in [`Workspace::remote_sftp_edits`] so each save uploads it back. Returns + /// `false` (caller falls back to a toast) when no live SFTP connection for + /// the host is available; `true` once the download has been kicked off. + /// + /// No-data-loss contract: the remote file is only ever *written* by a + /// save-back upload (never on open), and a failed upload keeps the working + /// copy and surfaces the error — the user's edits are never lost. Concurrent + /// external changes to the remote file are last-write-wins on the next save, + /// which is inherent to editing over plain SFTP (a daemon host gets the + /// version-checked native path instead). + #[cfg(all(unix, feature = "local_tty"))] + fn begin_remote_sftp_edit( + &mut self, + node_id: &str, + remote_path: &std::path::Path, + ctx: &mut ViewContext, + ) -> bool { + use crate::sftp_manager::fm_registry::{FileManagerRegistry, FsNamespace}; + + let Some(backend) = FileManagerRegistry::as_ref(ctx) + .backend_for_namespace(&FsNamespace::Remote(node_id.to_string())) + else { + return false; + }; + + let file_name = remote_path + .file_name() + .map(|name| name.to_os_string()) + .unwrap_or_else(|| std::ffi::OsString::from("remote-file")); + let display_name = file_name.to_string_lossy().to_string(); + let working_dir = remote_sftp_edit_working_dir(); + if let Err(error) = std::fs::create_dir_all(&working_dir) { + self.toast_stack.update(ctx, |view, ctx| { + view.add_ephemeral_toast( + DismissibleToast::error(format!( + "Couldn't prepare a local copy of {display_name}: {error}" + )), + ctx, + ); + }); + return true; + } + let working_copy = working_dir.join(&file_name); + + let node_label = node_id.to_string(); + let remote_path = remote_path.to_path_buf(); + let download_backend = backend.clone(); + let download_remote = remote_path.clone(); + let download_target = working_copy.clone(); + + ctx.spawn( + async move { + tokio::task::spawn_blocking(move || { + download_backend.download_file(&download_remote, &download_target, None, None) + }) + .await + }, + move |me, result, ctx| { + match result { + Ok(Ok(())) => { + // Canonicalize so the registry key matches the path that + // `GlobalBufferModel::file_path` reports back on save. + let key = std::fs::canonicalize(&working_copy) + .unwrap_or_else(|_| working_copy.clone()); + me.add_tab_for_code_file(key.clone(), None, ctx); + me.remote_sftp_edits.insert( + key, + RemoteSftpEdit { + node_label: node_label.clone(), + remote_path, + backend, + uploading: false, + resave_pending: false, + }, + ); + me.toast_stack.update(ctx, |view, ctx| { + view.add_ephemeral_toast( + DismissibleToast::success(format!( + "Editing {display_name} from {node_label} — each save uploads \ + it back over SFTP." + )), + ctx, + ); + }); + } + Ok(Err(error)) => { + let _ = std::fs::remove_dir_all(&working_dir); + me.toast_stack.update(ctx, |view, ctx| { + view.add_persistent_toast( + DismissibleToast::error(format!( + "Couldn't download {display_name} from {node_label}: {error}" + )), + ctx, + ); + }); + } + Err(join_error) => { + let _ = std::fs::remove_dir_all(&working_dir); + me.toast_stack.update(ctx, |view, ctx| { + view.add_persistent_toast( + DismissibleToast::error(format!( + "Couldn't download {display_name} from {node_label}: {join_error}" + )), + ctx, + ); + }); + } + } + }, + ); + true + } + + /// A local file was just saved. If it is a tracked classic-SSH remote working + /// copy, upload it back to its host over SFTP. A no-op for every ordinary + /// local save. A save that lands while an upload is already in flight is + /// coalesced (`resave_pending`) and re-run afterward, so no save is dropped. + #[cfg(all(unix, feature = "local_tty"))] + fn on_remote_working_copy_saved(&mut self, working_copy: PathBuf, ctx: &mut ViewContext) { + let Some(edit) = self.remote_sftp_edits.get_mut(&working_copy) else { + return; + }; + if edit.uploading { + edit.resave_pending = true; + return; + } + edit.uploading = true; + let backend = edit.backend.clone(); + let remote_path = edit.remote_path.clone(); + let node_label = edit.node_label.clone(); + let display_name = working_copy + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| node_label.clone()); + + let upload_source = working_copy.clone(); + let upload_remote = remote_path.clone(); + let upload_backend = backend.clone(); + ctx.spawn( + async move { + tokio::task::spawn_blocking(move || { + upload_backend.upload_file(&upload_source, &upload_remote, None, None) + }) + .await + }, + move |me, result, ctx| { + let error = match result { + Ok(Ok(())) => None, + Ok(Err(error)) => Some(error.to_string()), + Err(join_error) => Some(join_error.to_string()), + }; + // Clear the in-flight flag and see whether a save landed meanwhile. + let mut resave = false; + if let Some(edit) = me.remote_sftp_edits.get_mut(&working_copy) { + edit.uploading = false; + resave = std::mem::take(&mut edit.resave_pending); + } + match error { + None => { + me.toast_stack.update(ctx, |view, ctx| { + view.add_ephemeral_toast( + DismissibleToast::success(format!( + "Saved {display_name} to {node_label}." + )), + ctx, + ); + }); + } + Some(error) => { + // The editor already reported the *local* save as done, so + // a failed upload must be prominent — the host does NOT + // have the change. The working copy keeps the edits. + me.toast_stack.update(ctx, |view, ctx| { + view.add_persistent_toast( + DismissibleToast::error(format!( + "Couldn't save {display_name} to {node_label}: {error}. \ + Your changes are kept locally; retry with another save." + )), + ctx, + ); + }); + } + } + if resave { + me.on_remote_working_copy_saved(working_copy, ctx); + } + }, + ); + } + /// Resolves an SFTP file-manager `node_id` to a live daemon `HostId`, if that /// host currently has a connected daemon session with a usable client. The /// SFTP file manager is keyed by `node_id`; the native remote editor needs a