From afe24f0afc191f205fe4ee733b8e5de09f843fbc Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:47:13 +0800 Subject: [PATCH] feat(terminal): upload pasted and dropped files to remote panes (#1004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copying a file in Finder and pasting it into an SSH pane, or dropping one onto it, used to paste this machine's path — which the remote program cannot open. Only clipboard images were uploaded first. Every local path a pane is handed now goes through one route: uploaded into its own directory under ~/.cache/tty7/clipboard on an SSH host (so it keeps its real name), rewritten for WSL, and pasted as-is locally. Folders upload recursively, a transfer is only abandoned once it stops moving, and staged pastes older than a week are pruned. Rows of a remote Files tree now drag as their own type instead of ExternalPaths, so no drop target mistakes a far-side path for a local file. --- src/terminal/view.rs | 528 ++++++++++++++++++++++++++++++++----------- src/ui/file_tree.rs | 28 ++- src/ui/i18n/en.rs | 4 +- src/ui/i18n/ja.rs | 4 +- src/ui/i18n/mod.rs | 2 +- src/ui/i18n/zh.rs | 2 +- 6 files changed, 421 insertions(+), 147 deletions(-) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index cde55b33..72c9a9d7 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -944,21 +944,25 @@ fn trim_trailing_spaces(text: &str) -> String { .join("\n") } -fn clipboard_paste_text(item: &ClipboardItem, shell: Option<&str>) -> Option { - let escaped: Vec = item - .entries() +/// The local files the clipboard carries — a Finder or Explorer Copy on a file +/// puts its path there, not its bytes. +fn clipboard_paths(item: &ClipboardItem) -> Vec { + item.entries() .iter() .filter_map(|e| match e { ClipboardEntry::ExternalPaths(paths) => Some(paths.paths()), _ => None, }) .flatten() - .map(|p| quote_for_shell(&p.to_string_lossy(), shell)) - .collect(); - if !escaped.is_empty() { - return Some(escaped.join(" ")); - } - item.text() + .cloned() + .collect() +} + +/// Paths as one line of shell words, trailing space included so whatever is +/// typed next starts a word of its own. +fn pasted_paths_text(paths: &[String], shell: Option<&str>) -> String { + let words: Vec = paths.iter().map(|p| quote_for_shell(p, shell)).collect(); + format!("{} ", words.join(" ")) } fn write_clipboard_image(img: &gpui::Image) -> Option { @@ -1128,30 +1132,178 @@ fn files_cwd( } } -/// The staged image's path as the pane's own filesystem spells it. +/// A local path — a staged image, a copied or dropped file — as the pane's own +/// filesystem spells it. /// /// A WSL pane shares this machine's disk but not its path syntax: an agent in /// there reads `/mnt/c/…` and cannot open `C:\…` at all, which is why the /// upload route skips WSL — there is nothing to copy, only a name to rewrite. /// A path with no mapping falls back to the Windows one, which at least tells /// the user where the file is. -fn staged_path_for_pane(local: &str, shares_localhost: bool) -> String { +fn local_path_for_pane(local: &str, shares_localhost: bool) -> String { if shares_localhost { return wsl_path(local).unwrap_or_else(|| local.to_string()); } local.to_string() } -/// Staging images under the SSH user's own home keeps them out of the +/// Staging pastes under the SSH user's own home keeps them out of the /// world-writable `/tmp`, where any local account could pre-create the -/// directory, read what lands in it, or swap a pasted screenshot for one of -/// its own before the pane's agent opens it. +/// directory, read what lands in it, or swap a pasted file for one of its own +/// before the pane's agent opens it. const REMOTE_CLIPBOARD_PATH: [&str; 3] = [".cache", "tty7", "clipboard"]; /// Owner-only, and *only* owner: a staging directory anyone else can enter is -/// one anyone else can read the pasted screenshots out of. +/// one anyone else can read the pasted files out of. const REMOTE_CLIPBOARD_MODE: u32 = 0o700; +/// How long a paste stays staged on the remote. Long enough to outlive any +/// conversation that still refers to it; a week of pasted spreadsheets is +/// what would otherwise pile up in the user's home forever. +const REMOTE_CLIPBOARD_TTL_SECS: u64 = 7 * 24 * 60 * 60; + +/// A fresh name for one paste's own directory under the staging directory. +/// +/// Each paste gets a directory so every file keeps its real name — which is +/// what the agent reads, and `report.xlsx` says far more than a hash — without +/// two pastes of the same name overwriting each other. +fn paste_dir_name() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos() as u64); + format!("paste-{nanos:x}{n:x}") +} + +/// Whether a staging entry has outlived [`REMOTE_CLIPBOARD_TTL_SECS`]. An +/// entry with no mtime (`0`) is left alone: its age is unknown, not great. +fn staged_entry_expired(mtime: u64, now: u64) -> bool { + mtime != 0 && now.saturating_sub(mtime) > REMOTE_CLIPBOARD_TTL_SECS +} + +/// Remove the staging directory's expired entries. Best effort: a failure +/// here costs disk space, never a paste, so nothing is reported. +fn prune_remote_clipboard_dir(route: &crate::ui::sftp::SftpRoute, dir: &str) { + let Ok(entries) = route.list(dir) else { + return; + }; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + for entry in entries { + if entry.name == "." || entry.name == ".." || !staged_entry_expired(entry.mtime, now) { + continue; + } + remove_remote_tree( + route, + &crate::daemon::ssh::sftp::remote_join(dir, &entry.name), + entry.kind, + 0, + ); + } +} + +/// Delete a remote file or directory tree. A symlink is removed as a link and +/// never followed — the tree is ours, where it points is not. +fn remove_remote_tree( + route: &crate::ui::sftp::SftpRoute, + path: &str, + kind: crate::daemon::protocol::SftpEntryKind, + depth: usize, +) { + use crate::daemon::protocol::{SftpEntryKind, SftpOp}; + // A pasted folder is as deep as it was on disk; this bounds a pathological + // one rather than any real one. + const MAX_DEPTH: usize = 64; + if kind != SftpEntryKind::Dir { + route.op(SftpOp::RemoveFile { + path: path.to_string(), + }); + return; + } + if depth < MAX_DEPTH + && let Ok(children) = route.list(path) + { + for child in children { + if child.name == "." || child.name == ".." { + continue; + } + let child_path = crate::daemon::ssh::sftp::remote_join(path, &child.name); + remove_remote_tree(route, &child_path, child.kind, depth + 1); + } + } + route.op(SftpOp::RemoveDir { + path: path.to_string(), + }); +} + +/// One local path handed to the remote: where it went and the transfer +/// carrying it, or why it never left. +struct PasteUpload { + local: std::path::PathBuf, + started: Result, +} + +struct StartedUpload { + remote: String, + job: u64, + is_dir: bool, +} + +/// Start uploading `sources` into a new directory of their own under the +/// staging directory `dir`. Blocking, like everything that talks to the +/// daemon, so this runs off the UI thread. `Err` means the paste's directory +/// could not be made and nothing was started; a source that could not be +/// started on its own is reported in its own entry. +fn start_paste_uploads( + route: &crate::ui::sftp::SftpRoute, + pane_id: u64, + dir: &str, + sources: &[std::path::PathBuf], +) -> Result, String> { + use crate::daemon::protocol::{SftpOp, SftpOpResult, SftpTransferKind, SftpTransferSpec}; + let paste_dir = crate::daemon::ssh::sftp::remote_join(dir, &paste_dir_name()); + if let SftpOpResult::Error(e) = route.op(SftpOp::Mkdir { + path: paste_dir.clone(), + }) { + return Err(e); + } + Ok(sources + .iter() + .map(|local| { + let started = (|| { + let name = local + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .filter(|n| crate::daemon::ssh::sftp::safe_local_name(n)) + .ok_or_else(|| format!("{} has no file name", local.display()))?; + let is_dir = std::fs::metadata(local) + .map_err(|e| format!("{}: {e}", local.display()))? + .is_dir(); + let remote = crate::daemon::ssh::sftp::remote_join(&paste_dir, &name); + let job = route.transfer_start(SftpTransferSpec { + pane_id, + kind: SftpTransferKind::Upload, + local: local.clone(), + remote: remote.clone(), + recursive: is_dir, + })?; + Ok(StartedUpload { + remote, + job, + is_dir, + }) + })(); + PasteUpload { + local: local.clone(), + started, + } + }) + .collect()) +} + /// Whether a prepared staging directory may be uploaded into. /// /// The mode is what a `stat` reported *after* a `chmod 0700` the daemon @@ -3335,7 +3487,12 @@ impl TerminalView { let Some(item) = cx.read_from_clipboard() else { return; }; - if let Some(text) = clipboard_paste_text(&item, self.shell_program().as_deref()) { + let paths = clipboard_paths(&item); + if !paths.is_empty() { + self.paste_local_paths(paths, cx); + return; + } + if let Some(text) = item.text() { self.paste(text, cx); return; } @@ -3351,17 +3508,50 @@ impl TerminalView { } fn drop_files(&mut self, paths: &ExternalPaths, cx: &mut Context) { - let shell = self.shell_program(); - let text = paths - .paths() - .iter() - .map(|p| quote_for_shell(&p.to_string_lossy(), shell.as_deref())) - .collect::>() - .join(" "); - if text.is_empty() { + self.paste_local_paths(paths.paths().to_vec(), cx); + } + + /// A row dragged out of a remote Files tree names a file on that remote, + /// not here: there is nothing to upload, and the path is pasted as the + /// tree spells it. + fn drop_remote_path( + &mut self, + drag: &crate::ui::file_tree::RemotePathDrag, + cx: &mut Context, + ) { + let text = pasted_paths_text( + &[drag.path.to_string_lossy().into_owned()], + self.shell_program().as_deref(), + ); + self.paste(text, cx); + } + + /// Paste files that live on this machine so the pane's program can open + /// them: uploaded first when the pane runs on an SSH host, renamed for a + /// WSL pane, and pasted as they are everywhere else. + fn paste_local_paths(&mut self, paths: Vec, cx: &mut Context) { + if paths.is_empty() { return; } - self.paste(format!("{text} "), cx); + // Every step of an upload is a blocking daemon round trip, which a + // keystroke handler must not make, so a remote pane pastes from a + // background task and this returns without touching the line. + if self.upload_for_remote(paths.clone(), cx) { + return; + } + // The upload declined: a local pane, a WSL pane, which needs a rewrite + // rather than a transfer, or a workspace with no SSH spec to + // piggyback on. + let shares_localhost = self + .workspace + .as_ref() + .is_some_and(|w| w.shares_localhost()); + let spelled: Vec = paths + .iter() + .map(|p| local_path_for_pane(&p.to_string_lossy(), shares_localhost)) + .collect(); + let text = pasted_paths_text(&spelled, self.shell_program().as_deref()); + self.paste(text, cx); } fn paste_clipboard_image(&mut self, img: &gpui::Image, cx: &mut Context) { @@ -3385,66 +3575,52 @@ impl TerminalView { let Some(path) = write_clipboard_image(img) else { return false; }; - // SSH panes can't see the local temp file, so the image is uploaded and - // the *remote* path pasted instead. Every step of that needs a blocking - // daemon round trip, which a keystroke handler must not do, so the - // remote pane pastes from a background task and this returns without - // touching the line. - if self.upload_image_for_remote(&path, cx) { - return true; - } - // The upload declined: a WSL pane, which needs a rewrite rather than a - // transfer, or a workspace with no SSH spec to piggyback on. A macOS - // pane only reaches this line when it is remote — a local one returned - // above — so pasting the path is right on every platform, and staying - // silent here would be the very no-op this route exists to avoid. - let shares_localhost = self - .workspace - .as_ref() - .is_some_and(|w| w.shares_localhost()); - let path = staged_path_for_pane(&path.to_string_lossy(), shares_localhost); - let text = quote_for_shell(&path, self.shell_program().as_deref()); - self.paste(format!("{text} "), cx); + // A macOS pane only gets here when it is remote — a local one returned + // above — so pasting a path is right on every platform, and staying + // silent would be the very no-op this route exists to avoid. + self.paste_local_paths(vec![path], cx); true } - /// Upload a locally staged clipboard image to the pane's remote host and - /// paste the remote path, all off the UI thread. Answers whether this pane - /// took the paste over; `false` means a local, WSL, or spec-less pane the - /// caller should paste the local path for. + /// Upload local files to the pane's remote host and paste their remote + /// paths, all off the UI thread. Answers whether this pane took the paste + /// over; `false` means a local, WSL, or spec-less pane the caller should + /// paste the local paths for. /// - /// The upload itself still outlives the paste — it has to, or Ctrl+V would - /// stall on the wire — so the job is watched to completion and a failure - /// at any point warns the user that the path they were handed is dangling. - fn upload_image_for_remote(&mut self, local: &std::path::Path, cx: &mut Context) -> bool { - use crate::daemon::protocol::{SftpTransferKind, SftpTransferSpec}; + /// The paste lands as soon as the transfers start — Ctrl+V must not stall + /// on the wire — and each file appears at its path only once it is whole, + /// since the upload writes a temporary name and renames it into place. The + /// transfers are watched to the end, and a failure warns the user that the + /// path they were handed is dangling. + fn upload_for_remote( + &mut self, + sources: Vec, + cx: &mut Context, + ) -> bool { let Some(spec) = remote_paste_spec(self.workspace.as_ref(), self.ssh_spec.as_deref()) else { return false; }; let host = format!("{}@{}", spec.user, spec.host); - // The only caller stages through `write_clipboard_image`, so this - // holds; a name that could not stand alone as a remote path component - // would be a bug worth failing on rather than joining blindly. - let name = local - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .filter(|n| crate::daemon::ssh::sftp::safe_local_name(n)); - let Some(name) = name else { - log::warn!("refusing to upload a clipboard image named {local:?}"); - return false; - }; let route = crate::ui::sftp::SftpRoute::new(self.pane_id, self.workspace.clone()); let cached = self.remote_clipboard_dir.clone(); - let local = local.to_path_buf(); let pane_id = self.pane_id; cx.spawn(async move |this, cx| { let prepared = match cached { Some(dir) => Ok(dir), None => { let route = route.clone(); - cx.background_spawn(async move { prepare_remote_clipboard_dir(&route) }) - .await + cx.background_spawn(async move { + let prepared = prepare_remote_clipboard_dir(&route); + // Only on the pane's first paste, which is the only + // time the directory is prepared: one listing, cheap + // next to the upload that follows it. + if let Ok(dir) = &prepared { + prune_remote_clipboard_dir(&route, dir); + } + prepared + }) + .await } }; let dir = match this.update(cx, |view, _| { @@ -3454,65 +3630,78 @@ impl TerminalView { Ok(Some(dir)) => dir, Ok(None) => { let reason = prepared.unwrap_or_else(|e| e); - Self::paste_local_image_path(&this, cx, &local, &host, &reason); + Self::paste_local_fallback(&this, cx, &sources, &host, &reason); return; } Err(_) => return, }; - let remote = crate::daemon::ssh::sftp::remote_join(&dir, &name); let started = { - let (route, remote, local) = (route.clone(), remote.clone(), local.clone()); - cx.background_spawn(async move { - route.transfer_start(SftpTransferSpec { - pane_id, - kind: SftpTransferKind::Upload, - local, - remote, - recursive: false, - }) - }) + let (route, sources) = (route.clone(), sources.clone()); + cx.background_spawn( + async move { start_paste_uploads(&route, pane_id, &dir, &sources) }, + ) .await }; - let job = match started { - Ok(job) => job, + let uploads = match started { + Ok(uploads) => uploads, Err(reason) => { - Self::paste_local_image_path(&this, cx, &local, &host, &reason); + Self::paste_local_fallback(&this, cx, &sources, &host, &reason); return; } }; - if this - .update(cx, |view, cx| { - let text = quote_for_shell(&remote, view.shell_program().as_deref()); - view.paste(format!("{text} "), cx) + // A source that never left is pasted by its local path, so the + // line still says which file was meant. + let spelled: Vec = uploads + .iter() + .map(|u| match &u.started { + Ok(s) => s.remote.clone(), + Err(_) => u.local.to_string_lossy().into_owned(), }) - .is_err() - { + .collect(); + let pasted = this.update_in(cx, |view, window, cx| { + let text = pasted_paths_text(&spelled, view.shell_program().as_deref()); + view.paste(text, cx); + for u in &uploads { + if let Err(reason) = &u.started { + view.warn_paste_upload_failed(&u.local, &host, reason, window, cx); + } + } + }); + if pasted.is_err() { return; } - if let Err(reason) = Self::watch_upload(route, job, &remote, cx).await { - let _ = this.update_in(cx, |view, window, cx| { - view.warn_image_upload_failed(&host, &reason, window, cx); - }); + for u in uploads { + let Ok(started) = u.started else { continue }; + if let Err(reason) = Self::watch_upload(route.clone(), &started, cx).await { + let _ = this.update_in(cx, |view, window, cx| { + view.warn_paste_upload_failed(&u.local, &host, &reason, window, cx); + }); + } } }) .detach(); true } - /// Fall back to the local path when the remote staging directory cannot be - /// prepared — the paste is never dropped — and say why it is local. - fn paste_local_image_path( + /// Fall back to the local paths when nothing could be staged on the remote + /// — the paste is never dropped — and say why they are local. + fn paste_local_fallback( this: &gpui::WeakEntity, cx: &mut gpui::AsyncApp, - local: &std::path::Path, + sources: &[std::path::PathBuf], host: &str, reason: &str, ) { - let local = local.to_string_lossy().into_owned(); + let spelled: Vec = sources + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); let _ = this.update_in(cx, |view, window, cx| { - let text = quote_for_shell(&local, view.shell_program().as_deref()); - view.paste(format!("{text} "), cx); - view.warn_image_upload_failed(host, reason, window, cx); + let text = pasted_paths_text(&spelled, view.shell_program().as_deref()); + view.paste(text, cx); + if let Some(first) = sources.first() { + view.warn_paste_upload_failed(first, host, reason, window, cx); + } }); } @@ -3522,16 +3711,18 @@ impl TerminalView { /// watching would otherwise fail in silence. async fn watch_upload( route: crate::ui::sftp::SftpRoute, - job: u64, - remote: &str, + upload: &StartedUpload, cx: &mut gpui::AsyncApp, ) -> Result<(), String> { use crate::daemon::protocol::{SftpJobState, SftpOp}; - // Long enough for a screenshot over a slow link, bounded so a wedged - // job cannot poll forever. const POLL: std::time::Duration = std::time::Duration::from_millis(500); - const POLLS: usize = 600; - for _ in 0..POLLS { + // A pasted file can be any size, so there is no deadline on the whole + // transfer — only on one that has stopped moving, which is wedged + // rather than slow. + const STALLED_POLLS: usize = 600; + let mut last_done = 0; + let mut still = 0; + while still < STALLED_POLLS { cx.background_executor().timer(POLL).await; let listed = { let route = route.clone(); @@ -3540,22 +3731,37 @@ impl TerminalView { }; // A poll that failed says nothing about the job — keep asking // until it answers or the budget above runs out. - let Ok(listed) = listed else { continue }; - let Some(progress) = listed.into_iter().find(|j| j.job_id == job) else { + let Ok(listed) = listed else { + still += 1; + continue; + }; + let Some(progress) = listed.into_iter().find(|j| j.job_id == upload.job) else { // Pruned after the retention window, or the daemon restarted: // there is nothing left to report either way. return Ok(()); }; match progress.state { - SftpJobState::Running => continue, + SftpJobState::Running => { + still = if progress.bytes_done == last_done { + still + 1 + } else { + 0 + }; + last_done = progress.bytes_done; + } SftpJobState::Done => { // The staging directory is already owner-only, so this is - // belt and braces against a wider umask on the remote. - let (route, path) = (route.clone(), remote.to_string()); - cx.background_spawn( - async move { route.op(SftpOp::Chmod { path, mode: 0o600 }) }, - ) - .await; + // belt and braces against a wider umask on the remote. A + // folder is left as it was: its contents are behind the + // same owner-only directory, and its own layout is the + // user's. + if !upload.is_dir { + let (route, path) = (route.clone(), upload.remote.clone()); + cx.background_spawn(async move { + route.op(SftpOp::Chmod { path, mode: 0o600 }) + }) + .await; + } return Ok(()); } SftpJobState::Cancelled => return Ok(()), @@ -3567,26 +3773,33 @@ impl TerminalView { Ok(()) } - /// One notification per failed paste — the pane's line already has a path + /// One notification per failed file — the pane's line already has a path /// in it, and the user is the only one who can tell whether it matters. - fn warn_image_upload_failed( + fn warn_paste_upload_failed( &self, + local: &std::path::Path, host: &str, reason: &str, window: &mut Window, cx: &mut Context, ) { - log::warn!("clipboard image upload to {host} failed: {reason}"); + log::warn!( + "pasted upload of {} to {host} failed: {reason}", + local.display() + ); + let name = local + .file_name() + .map_or_else(|| local.to_string_lossy(), |n| n.to_string_lossy()); window.push_notification( crate::ui::i18n::t_fmt( - crate::ui::i18n::L10nKey::SftpImagePasteUploadFailed, - &[("host", host), ("error", reason)], + crate::ui::i18n::L10nKey::SftpPasteUploadFailed, + &[("name", &name), ("host", host), ("error", reason)], ), cx, ); } - /// Same shape as `warn_image_upload_failed`: one toast per failed open, so + /// Same shape as `warn_paste_upload_failed`: one toast per failed open, so /// a broken `link_file_command` surfaces as a config problem instead of a /// "dead link" (#542). Spawn is all that is reported — a spawned opener /// that exits non-zero is nobody's to see. @@ -7272,6 +7485,15 @@ impl Render for TerminalView { window.focus(&this.focus_handle, cx); this.drop_files(paths, cx); })) + .drag_over::(|s, _, _, cx| { + s.bg(cx.theme().drag_border.opacity(0.12)) + }) + .on_drop(cx.listener( + |this, drag: &crate::ui::file_tree::RemotePathDrag, window, cx| { + window.focus(&this.focus_handle, cx); + this.drop_remote_path(drag, cx); + }, + )) .on_action(cx.listener(|this, _: &CopyText, _w, cx| { this.copy_contextual(false, cx); })) @@ -8357,13 +8579,12 @@ mod tests { } use super::{ COMPLETION_MENU_MAX_W, LoopbackPlan, PortRoute, RawInput, SelectEndCopy, Typeahead, - WheelRoute, clipboard_paste_text, compose_notification_title, cwd_is_on_host, - display_width, link_path_style, loopback_plan, observe_typeahead_for_owner, - typeahead_boundary, + WheelRoute, clipboard_paths, compose_notification_title, cwd_is_on_host, display_width, + link_path_style, loopback_plan, observe_typeahead_for_owner, typeahead_boundary, }; use super::{SCROLL_ANIM_FRAME, scroll_anim_step}; use super::{ - TitleSettle, files_cwd, remote_paste_spec, settle_title, staged_path_for_pane, + TitleSettle, files_cwd, local_path_for_pane, remote_paste_spec, settle_title, stages_clipboard_image, staging_cache, staging_dir_is_safe, wsl_path, wsl_share_distro, wsl_share_path, }; @@ -8847,7 +9068,7 @@ mod tests { // The staged file really is on the pane's own disk — only its name // differs — so this is a rewrite, not an upload. assert_eq!( - staged_path_for_pane( + local_path_for_pane( r"C:\Users\me\AppData\Local\Temp\tty7-clipboard\paste-1.png", true ), @@ -8858,17 +9079,17 @@ mod tests { // No automount mapping: the Windows path at least says where it went. let unc = r"\\server\share\paste-1.png"; assert_eq!(wsl_path(unc), None); - assert_eq!(staged_path_for_pane(unc, true), unc); + assert_eq!(local_path_for_pane(unc, true), unc); // Drive-relative, not absolute — `C:x` means "x under C:'s cwd". assert_eq!(wsl_path(r"C:paste-1.png"), None); // Every other pane keeps the path exactly as staged. assert_eq!( - staged_path_for_pane("/tmp/tty7-clipboard/paste-1.png", false), + local_path_for_pane("/tmp/tty7-clipboard/paste-1.png", false), "/tmp/tty7-clipboard/paste-1.png" ); assert_eq!( - staged_path_for_pane(r"C:\Temp\paste-1.png", false), + local_path_for_pane(r"C:\Temp\paste-1.png", false), r"C:\Temp\paste-1.png" ); } @@ -9658,7 +9879,7 @@ mod tests { } #[test] - fn clipboard_paste_text_quotes_and_space_joins_files() { + fn a_copied_file_is_pasted_by_path_quoted_and_space_joined() { let item = ClipboardItem { entries: vec![ClipboardEntry::ExternalPaths(ExternalPaths( vec![ @@ -9668,16 +9889,49 @@ mod tests { .into(), ))], }; + let paths = clipboard_paths(&item); assert_eq!( - clipboard_paste_text(&item, Some("zsh")).as_deref(), - Some("'/Users/me/My File.txt' /tmp/b.log") + paths, + [ + PathBuf::from("/Users/me/My File.txt"), + PathBuf::from("/tmp/b.log") + ] + ); + let spelled: Vec = paths + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + assert_eq!( + super::pasted_paths_text(&spelled, Some("zsh")), + "'/Users/me/My File.txt' /tmp/b.log " ); + // Text is text: nothing on the clipboard names a file. let text = ClipboardItem::new_string("echo hi".to_string()); - assert_eq!( - clipboard_paste_text(&text, Some("zsh")).as_deref(), - Some("echo hi") - ); + assert!(clipboard_paths(&text).is_empty()); + } + + /// Two pastes of `report.xlsx` in the same instant must not land in the + /// same directory, or the second overwrites the file the first pasted. + #[test] + fn every_paste_stages_into_a_directory_of_its_own() { + let a = super::paste_dir_name(); + let b = super::paste_dir_name(); + assert_ne!(a, b); + assert!(crate::daemon::ssh::sftp::safe_local_name(&a)); + } + + #[test] + fn staged_pastes_expire_after_a_week_and_never_on_an_unknown_age() { + let now = 1_800_000_000; + let week = super::REMOTE_CLIPBOARD_TTL_SECS; + assert!(!super::staged_entry_expired(now - 60, now)); + assert!(!super::staged_entry_expired(now - week, now)); + assert!(super::staged_entry_expired(now - week - 1, now)); + // No mtime from the server says nothing about age. + assert!(!super::staged_entry_expired(0, now)); + // A remote clock ahead of ours is young, not negative. + assert!(!super::staged_entry_expired(now + 3600, now)); } #[test] diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 19da9ed9..aa3da09e 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -1856,6 +1856,7 @@ impl Tty7App { let is_dir = row.entry.is_dir; let selected = self.tab_code().and_then(|c| c.selected.as_deref()) == Some(&*path); let muted = cx.theme().muted_foreground; + let paths_are_local = self.spawn_host(cx).is_local(); // A placeholder standing in for children that are not there. Not a // file, so it takes none of the row machinery below — no hover, no @@ -2057,11 +2058,26 @@ impl Tty7App { } }), ) - .on_drag(ExternalPaths(vec![path.clone()].into()), { + // A local tree's rows are this machine's files, as good as ones + // dragged in from Finder, and every drop target takes them that + // way. A remote tree's rows name files on the far machine: they + // travel as their own type, so no target that reads, uploads or + // pins local paths can mistake one for a file here. + .map(|d| { let name = row.entry.name.clone(); - move |_, _, _, cx| { + let ghost = move |cx: &mut App| { let name = name.clone(); cx.new(|_| DragGhost { name }) + }; + match paths_are_local { + true => d.on_drag( + ExternalPaths(vec![path.clone()].into()), + move |_, _, _, cx| ghost(cx), + ), + false => d + .on_drag(RemotePathDrag { path: path.clone() }, move |_, _, _, cx| { + ghost(cx) + }), } }) // The other direction: files dropped on this row are copied in. @@ -2082,7 +2098,6 @@ impl Tty7App { let path = path.clone(); let is_root = row.is_root; let show_hidden = self.file_tree.show_hidden; - let paths_are_local = self.spawn_host(cx).is_local(); move |menu, _window, cx| { Self::tree_row_context_menu( menu, @@ -2334,6 +2349,13 @@ struct DragGhost { name: String, } +/// A row dragged out of a remote Files tree: a path on that remote, which +/// nothing on this machine can open. See the row's `on_drag`. +#[derive(Clone)] +pub(crate) struct RemotePathDrag { + pub path: PathBuf, +} + impl gpui::Render for DragGhost { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { h_flex() diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index f6f6ac44..f7231719 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1093,9 +1093,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SftpTransferCancelled => "cancelled", L10nKey::SftpTransferError => "error", L10nKey::SftpTransferListFailed => "Could not check transfers: {error}", - L10nKey::SftpImagePasteUploadFailed => { - "Could not upload the pasted image to {host}: {error}" - } + L10nKey::SftpPasteUploadFailed => "Could not upload {name} to {host}: {error}", L10nKey::LinkFileOpenFailed => "Could not open {path}: {error}", L10nKey::ForwardDisconnected => "Disconnected", L10nKey::ForwardDisconnectedFrom => "Disconnected from {host}", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 9714507c..930f109f 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1142,8 +1142,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SftpTransferCancelled => "キャンセル済み", L10nKey::SftpTransferError => "エラー", L10nKey::SftpTransferListFailed => "転送状況を取得できませんでした: {error}", - L10nKey::SftpImagePasteUploadFailed => { - "貼り付けた画像を {host} にアップロードできませんでした: {error}" + L10nKey::SftpPasteUploadFailed => { + "{name} を {host} にアップロードできませんでした: {error}" } L10nKey::LinkFileOpenFailed => "{path} を開けませんでした: {error}", L10nKey::ForwardDisconnected => "切断済み", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index e4c4acad..1611f294 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -846,7 +846,7 @@ l10n_keys! { SftpTransferCancelled, SftpTransferError, SftpTransferListFailed, - SftpImagePasteUploadFailed, + SftpPasteUploadFailed, LinkFileOpenFailed, ForwardDisconnected, ForwardDisconnectedFrom, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index f523c724..d0875d20 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1026,7 +1026,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SftpTransferCancelled => "已取消", L10nKey::SftpTransferError => "错误", L10nKey::SftpTransferListFailed => "无法获取传输状态:{error}", - L10nKey::SftpImagePasteUploadFailed => "无法将粘贴的图片上传到 {host}:{error}", + L10nKey::SftpPasteUploadFailed => "无法将 {name} 上传到 {host}:{error}", L10nKey::LinkFileOpenFailed => "无法打开 {path}:{error}", L10nKey::ForwardDisconnected => "已断开", L10nKey::ForwardDisconnectedFrom => "与 {host} 的连接已断开",