mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-27 08:02:36 +00:00
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:
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 => "ターミナルにパスを挿入",
|
||||
|
||||
@@ -756,6 +756,9 @@ l10n_keys! {
|
||||
FileTreeDeleteFailed,
|
||||
FileTreeCreateFailed,
|
||||
FileTreeRenameFailed,
|
||||
FileTreeDownloadFailed,
|
||||
FileTreeDownloaded,
|
||||
FileTreeDownloadTooLarge,
|
||||
FileTreeContextOpen,
|
||||
FileTreeContextCdHere,
|
||||
FileTreeContextInsertPath,
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user