diff --git a/docs/remote/workspaces.mdx b/docs/remote/workspaces.mdx index bd70c5eb..7417c18e 100644 --- a/docs/remote/workspaces.mdx +++ b/docs/remote/workspaces.mdx @@ -118,9 +118,11 @@ Two dialogs you may meet: - **Fork an agent session.** The fork command would run against the *local* agent, so tty7 does not offer it. -- **Move very large files through the Files panel.** Drag-and-drop across the - link is capped at what one control frame can carry; past that the panel tells - you to use [SFTP](/remote/sftp). +- **Move very large files through the Files panel.** Drag a file onto the tree + to upload it; right-click a file → **Download** to copy it into this machine's + Downloads folder (numbered, never overwritten, as in [SFTP](/remote/sftp)). + Both directions are capped at what one control frame can carry, about 63 MB; + past that the panel says so and points you at SFTP, `scp` or `rsync`. ## From the CLI diff --git a/src/ui/file_copy.rs b/src/ui/file_copy.rs index 49bee6e7..22784575 100644 --- a/src/ui/file_copy.rs +++ b/src/ui/file_copy.rs @@ -29,7 +29,7 @@ const MAX_DEPTH: usize = 64; /// `write_file` puts the whole file in a single control frame, alongside the /// JSON naming the path, and the frame as a whole has to fit in `MAX_FRAME`. /// The megabyte of slack is for that JSON and the framing around it. -const REMOTE_FILE_MAX: u64 = (crate::daemon::protocol::MAX_FRAME - 1024 * 1024) as u64; +pub(crate) const REMOTE_FILE_MAX: u64 = (crate::daemon::protocol::MAX_FRAME - 1024 * 1024) as u64; /// How many working names beside a destination are tried before a replacement /// gives up. @@ -280,6 +280,79 @@ fn copy_file(host: &dyn Host, src: &Path, dest: &Path, len: u64) -> io::Result<( Ok(()) } +/// Fetch `src` from `host` into a new file in the local directory `dir`. +/// +/// The counterpart of a drop, for the direction a drop cannot go: the tree's +/// rows name files on the host, and this is how one comes back to this +/// machine. Named the way the SFTP panel's Download names things — the remote +/// name, numbered rather than written over when it is taken. +/// +/// `max` is the ceiling on the file; over it, nothing is transferred. For a +/// remote host that is [`REMOTE_FILE_MAX`], since `read_file` answers in a +/// single control frame just as `write_file` asks in one. +pub(crate) fn download_file( + host: &dyn Host, + src: &Path, + dir: &Path, + max: u64, +) -> io::Result { + use std::io::Write as _; + + let name = src + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + // A remote name is whatever the far side's filesystem allows, which can be + // a path on this one. + if !crate::daemon::ssh::sftp::safe_local_name(&name) { + return Err(io::Error::other(t_fmt( + L10nKey::SftpErrorUnsafeRemoteName, + &[("name", &format!("{name:?}"))], + ))); + } + let meta = host.stat(src)?; + if meta.is_dir { + return Err(io::Error::from(io::ErrorKind::IsADirectory)); + } + // Asked up front so the answer is the limit, not whatever the far end + // says when `read_file` refuses — and so nothing crosses the wire first. + if meta.len > max { + return Err(io::Error::other(t_fmt( + L10nKey::FileTreeDownloadTooLarge, + &[("limit", &(max / (1024 * 1024)).to_string())], + ))); + } + let bytes = host.read_file(src, max)?; + std::fs::create_dir_all(dir)?; + // The name is chosen only once the bytes are here, and taken with + // `create_new`: two downloads of the same file racing each other each get + // their own number instead of one landing on the other. + for _ in 0..WORKING_NAME_TRIES { + let Some(dest) = crate::ui::sftp::free_local_path(dir, &name, &Default::default()) else { + break; + }; + let mut file = match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&dest) + { + Ok(file) => file, + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + }; + if let Err(e) = file.write_all(&bytes) { + drop(file); + let _ = std::fs::remove_file(&dest); + return Err(e); + } + return Ok(dest); + } + Err(io::Error::other(t_fmt( + L10nKey::SftpErrorNoFreeLocalName, + &[("name", &name)], + ))) +} + #[cfg(test)] mod tests { use super::*; @@ -600,4 +673,47 @@ mod tests { assert_eq!(report.errors.len(), 1, "the walk has to stop and say so"); assert!(report.copied.is_empty()); } + + #[test] + fn a_download_lands_under_its_name_and_numbers_a_second_copy() { + let root = scratch("download"); + let src = root.join("far/report.log"); + write(&src, "hello"); + let into = root.join("Downloads"); + + let first = download_file(&*LocalHost::shared(), &src, &into, 1024).unwrap(); + let second = download_file(&*LocalHost::shared(), &src, &into, 1024).unwrap(); + + assert_eq!(first, into.join("report.log")); + assert_eq!(second, into.join("report (2).log")); + assert_eq!(std::fs::read_to_string(&first).unwrap(), "hello"); + assert_eq!(std::fs::read_to_string(&second).unwrap(), "hello"); + } + + #[test] + fn a_download_over_the_limit_says_so_and_writes_nothing() { + let root = scratch("download-big"); + let src = root.join("far/big.bin"); + write(&src, &"x".repeat(4096)); + let into = root.join("Downloads"); + + let err = download_file(&*LocalHost::shared(), &src, &into, 1024).unwrap_err(); + + assert_eq!( + err.to_string(), + t_fmt(L10nKey::FileTreeDownloadTooLarge, &[("limit", "0")]) + ); + assert!(!into.join("big.bin").exists()); + } + + #[test] + fn a_folder_is_not_downloaded_as_a_file() { + let root = scratch("download-dir"); + let src = root.join("far/pkg"); + std::fs::create_dir_all(&src).unwrap(); + let into = root.join("Downloads"); + + assert!(download_file(&*LocalHost::shared(), &src, &into, 1024).is_err()); + assert!(!into.join("pkg").exists()); + } } diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index cf33ad58..ff8f63e2 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -1313,6 +1313,52 @@ impl Tty7App { cx.notify(); } + /// Copy a file of a remote tree into this machine's Downloads folder, + /// named and numbered the way the SFTP panel's Download does it. + fn file_tree_download(&mut self, path: &Path, window: &mut Window, cx: &mut Context) { + let Some(host) = self.active_host(cx) else { + return; + }; + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.display().to_string()); + let src = path.to_path_buf(); + HostOps::run_in( + host, + window, + cx, + move |h| { + file_copy::download_file( + h, + &src, + &crate::ui::sftp::local_download_dir(), + file_copy::REMOTE_FILE_MAX, + ) + }, + move |_app, result: std::io::Result, window, cx| match result { + Ok(local) => window.push_notification( + t_fmt( + L10nKey::FileTreeDownloaded, + &[( + "path", + &crate::ui::path_display::native_separators(&local) + .display() + .to_string(), + )], + ), + cx, + ), + Err(e) => HostOps::notify_err( + window, + cx, + &t_fmt(L10nKey::FileTreeDownloadFailed, &[("name", &name)]), + &e, + ), + }, + ); + } + fn file_tree_delete( &mut self, path: PathBuf, @@ -2081,6 +2127,17 @@ impl Tty7App { } }), ); + } else if !is_dir { + // Reveal's place on a remote tree: the file is not here to show in + // Finder, so the item that makes sense is the one that brings it + // here. A local file is already on this machine and gets Reveal. + menu = menu.item(PopupMenuItem::new(t(L10nKey::Download)).on_click({ + let app = app.clone(); + let p = p.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.file_tree_download(&p, window, cx)); + } + })); } menu = menu.separator().item(dotfiles_menu_item(show_hidden, app)); diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index c6de1a62..8d77a7de 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1035,6 +1035,11 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::FileTreeDeleteFailed => "Could not delete {name}", L10nKey::FileTreeCreateFailed => "Could not create {name}", L10nKey::FileTreeRenameFailed => "Could not rename {name}", + L10nKey::FileTreeDownloadFailed => "Could not download {name}", + L10nKey::FileTreeDownloaded => "Downloaded to {path}", + L10nKey::FileTreeDownloadTooLarge => { + "Larger than {limit} MB — fetch it with scp or rsync instead." + } L10nKey::FileTreeContextOpen => "Open", L10nKey::FileTreeContextCdHere => "cd Here", L10nKey::FileTreeContextInsertPath => "Insert Path in Terminal", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 7b7eb124..0506d3cf 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1101,6 +1101,11 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::FileTreeDeleteFailed => "{name} を削除できませんでした", L10nKey::FileTreeCreateFailed => "{name} を作成できませんでした", L10nKey::FileTreeRenameFailed => "{name} の名前を変更できませんでした", + L10nKey::FileTreeDownloadFailed => "{name} をダウンロードできませんでした", + L10nKey::FileTreeDownloaded => "{path} にダウンロードしました", + L10nKey::FileTreeDownloadTooLarge => { + "{limit} MB を超えています。scp または rsync でダウンロードしてください" + } L10nKey::FileTreeContextOpen => "開く", L10nKey::FileTreeContextCdHere => "ここで cd", L10nKey::FileTreeContextInsertPath => "ターミナルにパスを挿入", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index f3d6b4c7..8f801143 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -756,6 +756,9 @@ l10n_keys! { FileTreeDeleteFailed, FileTreeCreateFailed, FileTreeRenameFailed, + FileTreeDownloadFailed, + FileTreeDownloaded, + FileTreeDownloadTooLarge, FileTreeContextOpen, FileTreeContextCdHere, FileTreeContextInsertPath, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index fe153ff8..829a7e4c 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -988,6 +988,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::FileTreeDeleteFailed => "无法删除 {name}", L10nKey::FileTreeCreateFailed => "无法创建 {name}", L10nKey::FileTreeRenameFailed => "无法重命名 {name}", + L10nKey::FileTreeDownloadFailed => "无法下载 {name}", + L10nKey::FileTreeDownloaded => "已下载到 {path}", + L10nKey::FileTreeDownloadTooLarge => "超过 {limit} MB,请改用 scp 或 rsync 下载。", L10nKey::FileTreeContextOpen => "打开", L10nKey::FileTreeContextCdHere => "cd 到此处", L10nKey::FileTreeContextInsertPath => "在终端中插入路径", diff --git a/src/ui/sftp.rs b/src/ui/sftp.rs index 660811d1..20418211 100644 --- a/src/ui/sftp.rs +++ b/src/ui/sftp.rs @@ -331,7 +331,7 @@ fn local_home() -> PathBuf { .unwrap_or_else(|| PathBuf::from(".")) } -fn local_download_dir() -> PathBuf { +pub(crate) fn local_download_dir() -> PathBuf { local_home().join("Downloads") } @@ -342,7 +342,11 @@ fn local_download_dir() -> PathBuf { /// yet, so the filesystem alone would hand the same name out twice. `None` /// means every name in range is spoken for — better to say so than to return /// one of them and quietly overwrite it. -fn free_local_path(dir: &Path, name: &str, claimed: &HashSet) -> Option { +pub(crate) fn free_local_path( + dir: &Path, + name: &str, + claimed: &HashSet, +) -> Option { let taken = |p: &PathBuf| p.exists() || claimed.contains(p); let first = dir.join(name); if !taken(&first) {