feat(file-tree): download a remote file from the tree's context menu

A remote workspace's Files tree took uploads by drag-and-drop but had no
way to bring a file back. Right-click a file -> Download now reads it
through Host::read_file and saves it into this machine's Downloads
folder, named and numbered the way the SFTP panel's Download does.

Capped at the same ~63 MB as uploads (one control frame); an oversized
file is refused up front with the limit and nothing is transferred.
Local trees keep Reveal in that slot; folders are not offered.

Closes #723
This commit is contained in:
l0ng-ai
2026-09-23 15:22:57 +08:00
parent d534c085b9
commit 43d2b902bb
8 changed files with 201 additions and 6 deletions
+5 -3
View File
@@ -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
+117 -1
View File
@@ -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<PathBuf> {
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());
}
}
+57
View File
@@ -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<Self>) {
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<PathBuf>, 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));
+5
View File
@@ -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",
+5
View File
@@ -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 => "ターミナルにパスを挿入",
+3
View File
@@ -756,6 +756,9 @@ l10n_keys! {
FileTreeDeleteFailed,
FileTreeCreateFailed,
FileTreeRenameFailed,
FileTreeDownloadFailed,
FileTreeDownloaded,
FileTreeDownloadTooLarge,
FileTreeContextOpen,
FileTreeContextCdHere,
FileTreeContextInsertPath,
+3
View File
@@ -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 => "在终端中插入路径",
+6 -2
View File
@@ -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<PathBuf>) -> Option<PathBuf> {
pub(crate) fn free_local_path(
dir: &Path,
name: &str,
claimed: &HashSet<PathBuf>,
) -> Option<PathBuf> {
let taken = |p: &PathBuf| p.exists() || claimed.contains(p);
let first = dir.join(name);
if !taken(&first) {