mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
feat(files): copy dropped files into the folder they were dropped on (#458)
* feat(files): copy dropped files into the folder they were dropped on The Files panel has only ever been a drag *source* — a row dragged into a terminal inserts its path. Nothing on the tree ever registered a drop, so a file dragged in from the desktop did nothing at all, not even a highlight. Closes #453. The drop is the whole gesture: files land where the cursor was, not somewhere a dialog asks about afterwards. A folder row takes them itself, a file row stands in for the folder holding it — "next to this one" — and the space the rows do not cover belongs to the top of the tree. The placeholder inside an empty folder takes a drop too; it is the only thing drawn there, and letting it fall through to the root would put files somewhere the cursor never was. A row under the cursor wins over the column, which is what gpui's innermost-first dispatch already does. The copy itself goes through the `Host` the tree is listing, so a remote workspace reads here and writes there. Locally it is `fs::copy`, which is what keeps the executable bit that `write_file` would drop; remotely the bytes ride one control frame, and a file too big for that is refused with the advice to use SFTP rather than half-sent. Names already taken are asked about before anything is written, and the answer governs the whole drop — a half-done copy would have to be undone to honour a "no". Replacing a folder replaces it rather than merging into it. A drag let go where it started is a miss, not an error, so it says nothing. * fix(sftp): list the directory again once an upload lands An upload is written to `<name>.tty7-upload-<hex>` and renamed into place at the very end. The browser listed the directory the moment the transfer was handed to the daemon, so it caught that temporary name — and nothing ever listed again, so a finished upload sat on screen as a file with a hash glued to its name until the directory was navigated by hand. The premature listing is gone, and the panel now remembers the job ids it started: once one stops running — done, failed, cancelled, or dropped off the job list entirely — the directory is listed once more. Two uploads in flight settle independently, so the second one finishing does not depend on the first. * docs(changelog): note the SFTP upload listing fix * ci(host-boundary): allow the source side of a file drop, and stop scanning two files as empty The Files panel now copies dropped files in, and what the desktop hands over is by construction a path on the desktop's own machine: reading it is a local read even when the tree being dropped on is remote. The destination side goes through `Host`, and the one `std::fs::copy` that touches a destination sits inside a branch already gated on `host.id().is_local()`. While adding that entry: `attr` starts unset, which awk reads as 0, so a file whose first line is `mod something` matched `attr == NR - 1` and cut its body at line 0. `head -n -1` then errored and the file was scanned as empty — `src/terminal/mod.rs` and `src/ui/tray/mod.rs` both open that way, and the guard had been blind to both. Neither contains a violation, so seeing them is free.
This commit is contained in:
@@ -84,6 +84,13 @@ src/terminal/search.rs|.is_absolute()
|
||||
# can be handed a path. Always `std::env::temp_dir()` on this machine.
|
||||
src/terminal/view.rs|std::fs::create_dir_all
|
||||
src/terminal/view.rs|std::fs::write
|
||||
|
||||
# The source side of a file drop. What the desktop hands over is by
|
||||
# construction a path on the desktop's own machine, so reading it is a local
|
||||
# read even when the tree being dropped on is remote — the destination side of
|
||||
# that copy goes through `Host`, and the one `std::fs::copy` that touches a
|
||||
# destination sits inside a branch already gated on `host.id().is_local()`.
|
||||
src/ui/file_copy.rs|std::fs::
|
||||
EOF
|
||||
)
|
||||
|
||||
@@ -91,7 +98,12 @@ EOF
|
||||
# preserved because only the tail is dropped.
|
||||
body_of() {
|
||||
local file=$1 cut
|
||||
# `attr` starts unset, which awk reads as 0 — so a file whose *first* line
|
||||
# is `mod something` used to match `attr == NR - 1` and cut the body at
|
||||
# line 0, leaving `head -n -1` to error out and the file to be scanned as
|
||||
# empty. Two files opened that way, and the guard was blind to both.
|
||||
cut=$(awk '
|
||||
BEGIN { attr = -1 }
|
||||
/^#\[cfg\(test\)\]$/ { attr = NR }
|
||||
/^mod [A-Za-z_]/ { if (attr == NR - 1) { print NR - 1; exit } }
|
||||
' "$file")
|
||||
|
||||
@@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Drop files into the Files panel to copy them in** — the panel has always
|
||||
been a drag *source*; it now takes a drop as well. Files dragged in from the
|
||||
desktop land in the folder under the cursor: a folder row takes them itself,
|
||||
a file row stands in for the folder holding it, and the space below the tree
|
||||
belongs to the top of it. The landing is highlighted while the drag is in
|
||||
flight. Folders come in whole, the executable bit survives the copy, and a
|
||||
name already taken is asked about rather than silently replaced — a "no"
|
||||
leaves every file in the drop untouched. It works over a remote workspace
|
||||
too, reading here and writing there, up to the size one control frame can
|
||||
carry; past that the panel says to use SFTP.
|
||||
|
||||
- **Rearrange splits by dragging a pane** — hovering a pane now floats a small
|
||||
grip along its top edge; dragging it moves that pane elsewhere in the tab.
|
||||
A drop on a pane's **side** goes in beside it: facing a neighbour in the same
|
||||
@@ -39,6 +50,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- **An SFTP upload no longer sits in the browser under its temporary name** —
|
||||
an upload is written as `<name>.tty7-upload-<hex>` and renamed into place at
|
||||
the end, and the browser listed the directory the moment the transfer
|
||||
started, catching exactly that name. Nothing listed again afterwards, so a
|
||||
finished upload read as a file with a hash glued to its name until the
|
||||
directory was navigated by hand. The premature listing is gone and the
|
||||
directory is listed once the upload stops running — finished, failed or
|
||||
cancelled.
|
||||
|
||||
- **Drag cursors on Windows** — the grip on a pane's top edge, and a sidebar
|
||||
group being dragged, now change the pointer on Windows too. Win32 ships no
|
||||
open- or closed-hand cursor and gpui answers both with the plain arrow, so
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//! Copying dropped files into a directory of the Files panel's tree.
|
||||
//!
|
||||
//! The panel has always been a drag *source* — a row can be dragged into a
|
||||
//! terminal to insert its path. This is the other direction: whatever the
|
||||
//! desktop (or another row) drops on the tree gets copied into the directory
|
||||
//! under the cursor.
|
||||
//!
|
||||
//! Everything here runs on a `HostOps` worker thread, never on the UI thread,
|
||||
//! and the destination is reached through the [`Host`] the tree is listing —
|
||||
//! which is this machine for a local workspace and the far end of a control
|
||||
//! connection for a remote one. The *sources* are always local paths: a file
|
||||
//! drop from the desktop can only name files on the desktop's own machine.
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::ui::host_ops::Host;
|
||||
use crate::ui::i18n::{L10nKey, t, t_fmt};
|
||||
|
||||
/// How deep a dropped folder is followed before the copy gives up.
|
||||
///
|
||||
/// `metadata` follows symlinks, which is what makes a dropped alias copy the
|
||||
/// thing it points at rather than a broken link — and what makes a link back
|
||||
/// up its own tree an infinite walk. This is the floor that walk stops on.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// Ceiling on one file copied to a host that is not this machine.
|
||||
///
|
||||
/// `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;
|
||||
|
||||
/// What one drop did, in the terms the panel has to answer in: rows to
|
||||
/// refresh, names to ask about, failures to report.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct DropReport {
|
||||
pub copied: Vec<String>,
|
||||
/// Names that already exist in the destination. Non-empty only when the
|
||||
/// caller asked without `overwrite`, and then nothing at all was written —
|
||||
/// the answer to "replace?" governs every name in the drop, so a half-done
|
||||
/// copy would have to be undone to honour a "no".
|
||||
pub conflicts: Vec<String>,
|
||||
pub errors: Vec<(String, io::Error)>,
|
||||
}
|
||||
|
||||
impl DropReport {
|
||||
fn fail(&mut self, name: &str, message: String) {
|
||||
self.errors
|
||||
.push((name.to_string(), io::Error::other(message)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy `sources` into `dir` on `host`.
|
||||
///
|
||||
/// Two passes: name and vet every source first, then write. That is what lets
|
||||
/// the panel ask about name collisions before anything has been overwritten.
|
||||
pub(crate) fn copy_into_dir(
|
||||
host: &dyn Host,
|
||||
sources: &[PathBuf],
|
||||
dir: &Path,
|
||||
overwrite: bool,
|
||||
) -> DropReport {
|
||||
let mut report = DropReport::default();
|
||||
let mut planned: Vec<(PathBuf, PathBuf, String)> = Vec::new();
|
||||
|
||||
for src in sources {
|
||||
let Some(name) = src.file_name().map(|n| n.to_string_lossy().to_string()) else {
|
||||
// A root directory, or a path ending in `..`: nothing to name the
|
||||
// copy after.
|
||||
continue;
|
||||
};
|
||||
// A row dropped back where it already is: on the folder holding it, or
|
||||
// — since every row is both a drag source and a drop target — on
|
||||
// itself, which is where an abandoned drag lands. Both are misses, and
|
||||
// a miss is not worth a notification.
|
||||
if src.parent() == Some(dir) || dir == src {
|
||||
continue;
|
||||
}
|
||||
if dir.starts_with(src) {
|
||||
report.fail(&name, t(L10nKey::FileDropIntoItself).to_string());
|
||||
continue;
|
||||
}
|
||||
// The drag carries whatever `on_drag` put in it, and a row of a remote
|
||||
// tree carries a path on the *far* machine. Reading it here would
|
||||
// either fail or, worse, find a local file of the same name.
|
||||
if !src.exists() {
|
||||
report.fail(&name, t(L10nKey::FileDropNotHere).to_string());
|
||||
continue;
|
||||
}
|
||||
if host.exists(&host.join(dir, &name)) {
|
||||
report.conflicts.push(name.clone());
|
||||
}
|
||||
planned.push((src.clone(), host.join(dir, &name), name));
|
||||
}
|
||||
|
||||
if !overwrite && !report.conflicts.is_empty() {
|
||||
return report;
|
||||
}
|
||||
|
||||
for (src, dest, name) in planned {
|
||||
// Replace rather than merge: a folder dropped onto a folder of the
|
||||
// same name should end up as what was dropped, not as the union of the
|
||||
// two, which is what writing into it one file at a time would leave.
|
||||
if host.exists(&dest)
|
||||
&& let Err(e) = host.remove(&dest, true)
|
||||
{
|
||||
report.errors.push((name, e));
|
||||
continue;
|
||||
}
|
||||
match copy_tree(host, &src, &dest, 0) {
|
||||
Ok(()) => report.copied.push(name),
|
||||
Err(e) => report.errors.push((name, e)),
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
fn copy_tree(host: &dyn Host, src: &Path, dest: &Path, depth: usize) -> io::Result<()> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(io::Error::other(t_fmt(
|
||||
L10nKey::FileDropTooDeep,
|
||||
&[("n", &MAX_DEPTH.to_string())],
|
||||
)));
|
||||
}
|
||||
let meta = std::fs::metadata(src)?;
|
||||
if !meta.is_dir() {
|
||||
return copy_file(host, src, dest, meta.len());
|
||||
}
|
||||
host.create_dir(dest, true)?;
|
||||
for entry in std::fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
// `Host::join` builds paths out of `&str`, so a name that is not UTF-8
|
||||
// would land under a lossy spelling of itself. Locally there is no
|
||||
// reason to go through it at all; over the wire the path is a String
|
||||
// either way, and lossy is the best that can be done.
|
||||
let child = match host.id().is_local() {
|
||||
true => dest.join(&name),
|
||||
false => host.join(dest, &name.to_string_lossy()),
|
||||
};
|
||||
copy_tree(host, &entry.path(), &child, depth + 1)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_file(host: &dyn Host, src: &Path, dest: &Path, len: u64) -> io::Result<()> {
|
||||
// One syscall path locally, and the one that keeps the mode bits:
|
||||
// `write_file` would drop the executable bit off every script copied in.
|
||||
if host.id().is_local() {
|
||||
std::fs::copy(src, dest)?;
|
||||
return Ok(());
|
||||
}
|
||||
if len > REMOTE_FILE_MAX {
|
||||
return Err(io::Error::other(t_fmt(
|
||||
L10nKey::FileDropTooLarge,
|
||||
&[("limit", &(REMOTE_FILE_MAX / (1024 * 1024)).to_string())],
|
||||
)));
|
||||
}
|
||||
let bytes = std::fs::read(src)?;
|
||||
host.write_file(dest, &bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tty7_core::host::local::LocalHost;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("tty7-drop-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::canonicalize(&dir).unwrap()
|
||||
}
|
||||
|
||||
fn write(path: &Path, body: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
std::fs::write(path, body).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_file_lands_in_the_directory_it_was_dropped_on() {
|
||||
let root = scratch("one-file");
|
||||
let src = root.join("from/note.txt");
|
||||
write(&src, "hello");
|
||||
let dest_dir = root.join("into");
|
||||
std::fs::create_dir_all(&dest_dir).unwrap();
|
||||
|
||||
let report = copy_into_dir(&*LocalHost::shared(), &[src], &dest_dir, false);
|
||||
|
||||
assert_eq!(report.copied, vec!["note.txt".to_string()]);
|
||||
assert!(report.errors.is_empty());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dest_dir.join("note.txt")).unwrap(),
|
||||
"hello"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_folder_brings_everything_under_it() {
|
||||
let root = scratch("folder");
|
||||
write(&root.join("from/pkg/a.txt"), "a");
|
||||
write(&root.join("from/pkg/deep/b.txt"), "b");
|
||||
let dest_dir = root.join("into");
|
||||
std::fs::create_dir_all(&dest_dir).unwrap();
|
||||
|
||||
let report = copy_into_dir(
|
||||
&*LocalHost::shared(),
|
||||
&[root.join("from/pkg")],
|
||||
&dest_dir,
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(report.copied, vec!["pkg".to_string()]);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dest_dir.join("pkg/deep/b.txt")).unwrap(),
|
||||
"b"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_executable_stays_executable() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let root = scratch("mode");
|
||||
let src = root.join("from/run.sh");
|
||||
write(&src, "#!/bin/sh\n");
|
||||
std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let dest_dir = root.join("into");
|
||||
std::fs::create_dir_all(&dest_dir).unwrap();
|
||||
|
||||
copy_into_dir(&*LocalHost::shared(), &[src], &dest_dir, false);
|
||||
|
||||
let mode = std::fs::metadata(dest_dir.join("run.sh"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o111, 0o111, "the executable bit did not survive");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_already_there_is_reported_and_nothing_is_written() {
|
||||
let root = scratch("conflict");
|
||||
let src = root.join("from/note.txt");
|
||||
write(&src, "new");
|
||||
let dest_dir = root.join("into");
|
||||
write(&dest_dir.join("note.txt"), "old");
|
||||
let other = root.join("from/fresh.txt");
|
||||
write(&other, "fresh");
|
||||
|
||||
let report = copy_into_dir(
|
||||
&*LocalHost::shared(),
|
||||
&[src.clone(), other],
|
||||
&dest_dir,
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(report.conflicts, vec!["note.txt".to_string()]);
|
||||
assert!(
|
||||
report.copied.is_empty(),
|
||||
"the answer governs the whole drop"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dest_dir.join("note.txt")).unwrap(),
|
||||
"old"
|
||||
);
|
||||
assert!(!dest_dir.join("fresh.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_a_folder_leaves_what_was_dropped_and_not_the_union() {
|
||||
let root = scratch("replace");
|
||||
write(&root.join("from/pkg/new.txt"), "new");
|
||||
let dest_dir = root.join("into");
|
||||
write(&dest_dir.join("pkg/stale.txt"), "stale");
|
||||
|
||||
let report = copy_into_dir(
|
||||
&*LocalHost::shared(),
|
||||
&[root.join("from/pkg")],
|
||||
&dest_dir,
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(report.copied, vec!["pkg".to_string()]);
|
||||
assert!(dest_dir.join("pkg/new.txt").exists());
|
||||
assert!(
|
||||
!dest_dir.join("pkg/stale.txt").exists(),
|
||||
"replacing a folder must not merge into it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_dropped_back_on_its_own_folder_does_nothing() {
|
||||
let root = scratch("same-dir");
|
||||
let src = root.join("here/note.txt");
|
||||
write(&src, "hello");
|
||||
|
||||
let report = copy_into_dir(&*LocalHost::shared(), &[src], &root.join("here"), false);
|
||||
|
||||
assert!(report.copied.is_empty());
|
||||
assert!(report.errors.is_empty(), "a miss is not an error");
|
||||
assert!(report.conflicts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_abandoned_on_the_folder_it_started_from_says_nothing() {
|
||||
let root = scratch("abandoned");
|
||||
let pkg = root.join("pkg");
|
||||
std::fs::create_dir_all(&pkg).unwrap();
|
||||
|
||||
let report = copy_into_dir(&*LocalHost::shared(), &[pkg.clone()], &pkg, false);
|
||||
|
||||
assert!(report.copied.is_empty());
|
||||
assert!(
|
||||
report.errors.is_empty(),
|
||||
"letting go where you picked it up is not an error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_cannot_be_copied_into_itself() {
|
||||
let root = scratch("into-itself");
|
||||
write(&root.join("pkg/deep/a.txt"), "a");
|
||||
|
||||
let report = copy_into_dir(
|
||||
&*LocalHost::shared(),
|
||||
&[root.join("pkg")],
|
||||
&root.join("pkg/deep"),
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(report.errors.len(), 1);
|
||||
assert!(report.copied.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_is_not_on_this_machine_is_refused_rather_than_guessed_at() {
|
||||
let root = scratch("elsewhere");
|
||||
let dest_dir = root.join("into");
|
||||
std::fs::create_dir_all(&dest_dir).unwrap();
|
||||
|
||||
let report = copy_into_dir(
|
||||
&*LocalHost::shared(),
|
||||
&[PathBuf::from("/srv/on-the-far-end/note.txt")],
|
||||
&dest_dir,
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(report.errors.len(), 1);
|
||||
assert_eq!(report.errors[0].0, "note.txt");
|
||||
assert!(report.copied.is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_symlink_cycle_cannot_make_the_copy_walk_forever() {
|
||||
let root = scratch("cycle");
|
||||
let src = root.join("from/pkg");
|
||||
std::fs::create_dir_all(&src).unwrap();
|
||||
std::os::unix::fs::symlink(&src, src.join("loop")).unwrap();
|
||||
let dest_dir = root.join("into");
|
||||
std::fs::create_dir_all(&dest_dir).unwrap();
|
||||
|
||||
let report = copy_into_dir(&*LocalHost::shared(), &[src], &dest_dir, false);
|
||||
|
||||
assert_eq!(report.errors.len(), 1, "the walk has to stop and say so");
|
||||
assert!(report.copied.is_empty());
|
||||
}
|
||||
}
|
||||
+260
-7
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use crate::core::config::RightPanelTab;
|
||||
use crate::ui::app::Tty7App;
|
||||
use crate::ui::file_copy;
|
||||
use crate::ui::host_ops::{ByHost, HostId, HostOps, InFlight, SharedHost, WatchSub};
|
||||
use crate::ui::host_registry::HostRegistry;
|
||||
use crate::ui::i18n::{L10nKey, t, t_fmt};
|
||||
@@ -1214,6 +1215,98 @@ impl Tty7App {
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Copy what was dropped on the tree into `dir`.
|
||||
///
|
||||
/// The drop is the whole gesture: the panel does not ask where to put the
|
||||
/// files, it puts them where the cursor was. The one question it does ask
|
||||
/// is about replacing something already there, and that question is asked
|
||||
/// before anything has been written.
|
||||
fn file_tree_drop_paths(
|
||||
&mut self,
|
||||
sources: Vec<PathBuf>,
|
||||
dir: PathBuf,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.file_tree_copy_into(sources, dir, false, window, cx);
|
||||
}
|
||||
|
||||
fn file_tree_copy_into(
|
||||
&mut self,
|
||||
sources: Vec<PathBuf>,
|
||||
dir: PathBuf,
|
||||
overwrite: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if sources.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(host) = self.active_host(cx) else {
|
||||
return;
|
||||
};
|
||||
let id = host.id();
|
||||
let asked_for = sources.clone();
|
||||
let target = dir.clone();
|
||||
HostOps::run_in(
|
||||
host,
|
||||
window,
|
||||
cx,
|
||||
move |h| file_copy::copy_into_dir(h, &sources, &target, overwrite),
|
||||
move |app, report: file_copy::DropReport, window, cx| {
|
||||
if !report.copied.is_empty() {
|
||||
app.file_tree.invalidate_dir(id, &dir);
|
||||
}
|
||||
if !report.conflicts.is_empty() {
|
||||
app.file_tree_confirm_replace(asked_for, dir, report.conflicts, window, cx);
|
||||
} else if let Some((name, e)) = report.errors.first() {
|
||||
// One notification for the drop, not one per file: a folder
|
||||
// of unreadable files would otherwise bury the screen.
|
||||
let context = match report.errors.len() {
|
||||
1 => t_fmt(L10nKey::FileDropFailed, &[("name", name)]),
|
||||
n => t_fmt(
|
||||
L10nKey::FileDropFailedMany,
|
||||
&[("name", name), ("n", &(n - 1).to_string())],
|
||||
),
|
||||
};
|
||||
HostOps::notify_err(window, cx, &context, e);
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn file_tree_confirm_replace(
|
||||
&mut self,
|
||||
sources: Vec<PathBuf>,
|
||||
dir: PathBuf,
|
||||
conflicts: Vec<String>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let title = match conflicts.as_slice() {
|
||||
[one] => t_fmt(L10nKey::FileDropReplaceTitle, &[("name", one)]),
|
||||
many => t_fmt(
|
||||
L10nKey::FileDropReplaceManyTitle,
|
||||
&[("n", &many.len().to_string())],
|
||||
),
|
||||
};
|
||||
let answer = window.prompt(
|
||||
PromptLevel::Warning,
|
||||
&title,
|
||||
Some(t(L10nKey::FileDropReplaceBody)),
|
||||
&crate::ui::confirm_answers(t(L10nKey::FileDropReplace), t(L10nKey::Cancel)),
|
||||
cx,
|
||||
);
|
||||
cx.spawn_in(window, async move |app, cx| {
|
||||
let Ok(0) = answer.await else { return };
|
||||
let _ = app.update_in(cx, |app, window, cx| {
|
||||
app.file_tree_copy_into(sources, dir, true, window, cx);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn file_tree_cd(&mut self, dir: &Path, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(leaf) = self
|
||||
.tabs
|
||||
@@ -1316,7 +1409,21 @@ impl Tty7App {
|
||||
.children(
|
||||
rows.iter()
|
||||
.flat_map(|row| self.render_tree_row(row, window, cx)),
|
||||
);
|
||||
)
|
||||
// Everything the rows do not cover — the gap below the last one,
|
||||
// and the whole column while the tree is still empty — belongs to
|
||||
// the top of the tree. A row under the cursor wins: gpui hands a
|
||||
// drop to the innermost target first, and it stops there.
|
||||
.when_some(roots.first().cloned(), |d, root| {
|
||||
d.drag_over::<ExternalPaths>(|s, _, _, cx| {
|
||||
s.bg(cx.theme().drag_border.opacity(0.06))
|
||||
})
|
||||
.on_drop(cx.listener(
|
||||
move |this, paths: &ExternalPaths, window, cx| {
|
||||
this.file_tree_drop_paths(paths.paths().to_vec(), root.clone(), window, cx);
|
||||
},
|
||||
))
|
||||
});
|
||||
crate::ui::scrollbar::with_vertical_scrollbar(
|
||||
"right-panel-tree-scrollbar",
|
||||
column,
|
||||
@@ -1337,7 +1444,10 @@ impl Tty7App {
|
||||
|
||||
// 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
|
||||
// selection, no context menu, no drag.
|
||||
// selection, no context menu, no drag. It does take a drop: it is
|
||||
// drawn inside a folder and it is the only thing in an empty one, so
|
||||
// letting it fall through to the root would put files somewhere the
|
||||
// cursor never was.
|
||||
if let Some(note) = row.note {
|
||||
let (key, ink) = match note {
|
||||
TreeNote::Loading => (L10nKey::TreeDirLoading, muted),
|
||||
@@ -1361,6 +1471,24 @@ impl Tty7App {
|
||||
TreeNote::SearchCapped => t_fmt(key, &[("n", &SEARCH_LIMIT.to_string())]),
|
||||
_ => t(key).to_string(),
|
||||
})
|
||||
// Every note but the capped-search one stands for a real
|
||||
// directory, and carries its path; that one stands for the
|
||||
// rest of a search and has nowhere to put anything.
|
||||
.when(!path.as_os_str().is_empty(), |d| {
|
||||
d.drag_over::<ExternalPaths>(|s, _, _, cx| {
|
||||
s.bg(cx.theme().drag_border.opacity(0.14))
|
||||
})
|
||||
.on_drop(cx.listener(
|
||||
move |this, paths: &ExternalPaths, window, cx| {
|
||||
this.file_tree_drop_paths(
|
||||
paths.paths().to_vec(),
|
||||
path.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
},
|
||||
))
|
||||
})
|
||||
.into_any_element(),
|
||||
];
|
||||
}
|
||||
@@ -1447,6 +1575,19 @@ impl Tty7App {
|
||||
cx.new(|_| DragGhost { name })
|
||||
}
|
||||
})
|
||||
// The other direction: files dropped on this row are copied in.
|
||||
// A folder takes them itself; a file stands in for the folder it
|
||||
// is in, which is where "put it next to this one" lands.
|
||||
.drag_over::<ExternalPaths>(|s, _, _, cx| s.bg(cx.theme().drag_border.opacity(0.14)))
|
||||
.on_drop(cx.listener({
|
||||
let dir = match is_dir {
|
||||
true => path.clone(),
|
||||
false => path.parent().unwrap_or(&path).to_path_buf(),
|
||||
};
|
||||
move |this, paths: &ExternalPaths, window, cx| {
|
||||
this.file_tree_drop_paths(paths.paths().to_vec(), dir.clone(), window, cx);
|
||||
}
|
||||
}))
|
||||
.context_menu({
|
||||
let app = cx.entity().downgrade();
|
||||
let path = path.clone();
|
||||
@@ -2112,19 +2253,19 @@ mod render_idle_gpui_tests {
|
||||
|
||||
const BUDGET: u64 = 200;
|
||||
|
||||
fn serial() -> std::sync::MutexGuard<'static, ()> {
|
||||
pub(super) fn serial() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
pub(super) fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("tty7-idle-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::canonicalize(&dir).unwrap()
|
||||
}
|
||||
|
||||
fn files_panel_on(
|
||||
pub(super) fn files_panel_on(
|
||||
cx: &mut TestAppContext,
|
||||
root: &Path,
|
||||
) -> (
|
||||
@@ -2177,7 +2318,7 @@ mod render_idle_gpui_tests {
|
||||
(app, vcx, pane)
|
||||
}
|
||||
|
||||
fn rows(app: &Entity<Tty7App>, vcx: &mut VisualTestContext) -> usize {
|
||||
pub(super) fn rows(app: &Entity<Tty7App>, vcx: &mut VisualTestContext) -> usize {
|
||||
app.update_in(vcx, |app, _, _| {
|
||||
let code = app.tab_code().expect("panel state");
|
||||
app.file_tree
|
||||
@@ -2194,7 +2335,7 @@ mod render_idle_gpui_tests {
|
||||
});
|
||||
}
|
||||
|
||||
fn settle(app: &Entity<Tty7App>, vcx: &mut VisualTestContext, root: &Path) {
|
||||
pub(super) fn settle(app: &Entity<Tty7App>, vcx: &mut VisualTestContext, root: &Path) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while std::time::Instant::now() < deadline {
|
||||
vcx.background_executor.run_until_parked();
|
||||
@@ -2623,3 +2764,115 @@ mod render_idle_gpui_tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
/// The drop end of the panel, driven through the real app: the copy runs on a
|
||||
/// `HostOps` worker and the tree has to catch up with what it wrote.
|
||||
///
|
||||
/// What these cannot reach is the hit test — whether the row under the cursor
|
||||
/// is the one that gets the drop is decided by gpui's hitbox stack, and there
|
||||
/// is no headless way to put a cursor over a row.
|
||||
#[cfg(all(test, unix))]
|
||||
mod drop_gpui_tests {
|
||||
use super::render_idle_gpui_tests::{files_panel_on, rows, scratch, serial, settle};
|
||||
use super::*;
|
||||
use gpui::{TestAppContext, VisualTestContext};
|
||||
|
||||
/// The copy runs on a `HostOps` worker — a real OS thread the test
|
||||
/// executor does not own, so parking it proves nothing about whether the
|
||||
/// worker is done. This waits for the result instead of assuming it.
|
||||
fn wait_until(
|
||||
vcx: &mut VisualTestContext,
|
||||
what: &str,
|
||||
mut done: impl FnMut(&mut VisualTestContext) -> bool,
|
||||
) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
loop {
|
||||
vcx.background_executor.run_until_parked();
|
||||
if done(vcx) {
|
||||
return;
|
||||
}
|
||||
assert!(std::time::Instant::now() < deadline, "{what}");
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_dropped_file_is_copied_in_and_shows_up_in_the_tree(cx: &mut TestAppContext) {
|
||||
let _serial = serial();
|
||||
let root = scratch("drop-lands");
|
||||
let from = scratch("drop-source");
|
||||
std::fs::write(from.join("note.txt"), "hello").unwrap();
|
||||
let (app, mut vcx, _pane) = files_panel_on(cx, &root);
|
||||
let before = rows(&app, &mut vcx);
|
||||
|
||||
app.update_in(&mut vcx, |app, window, cx| {
|
||||
app.file_tree_drop_paths(vec![from.join("note.txt")], root.clone(), window, cx);
|
||||
});
|
||||
wait_until(&mut vcx, "the copy never landed", |_| {
|
||||
root.join("note.txt").exists()
|
||||
});
|
||||
settle(&app, &mut vcx, &root);
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("note.txt")).unwrap(),
|
||||
"hello"
|
||||
);
|
||||
assert_eq!(rows(&app, &mut vcx), before + 1, "the tree never caught up");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&from);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_drop_over_a_name_that_is_taken_asks_before_it_writes(cx: &mut TestAppContext) {
|
||||
let _serial = serial();
|
||||
let root = scratch("drop-conflict-no");
|
||||
std::fs::write(root.join("note.txt"), "old").unwrap();
|
||||
let from = scratch("drop-conflict-no-source");
|
||||
std::fs::write(from.join("note.txt"), "new").unwrap();
|
||||
let (app, mut vcx, _pane) = files_panel_on(cx, &root);
|
||||
|
||||
app.update_in(&mut vcx, |app, window, cx| {
|
||||
app.file_tree_drop_paths(vec![from.join("note.txt")], root.clone(), window, cx);
|
||||
});
|
||||
wait_until(&mut vcx, "it overwrote without asking", |vcx| {
|
||||
vcx.has_pending_prompt()
|
||||
});
|
||||
vcx.simulate_prompt_answer(t(L10nKey::Cancel));
|
||||
settle(&app, &mut vcx, &root);
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("note.txt")).unwrap(),
|
||||
"old",
|
||||
"answering no still replaced the file"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&from);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn answering_yes_replaces_what_was_there(cx: &mut TestAppContext) {
|
||||
let _serial = serial();
|
||||
let root = scratch("drop-conflict-yes");
|
||||
std::fs::write(root.join("note.txt"), "old").unwrap();
|
||||
let from = scratch("drop-conflict-yes-source");
|
||||
std::fs::write(from.join("note.txt"), "new").unwrap();
|
||||
let (app, mut vcx, _pane) = files_panel_on(cx, &root);
|
||||
|
||||
app.update_in(&mut vcx, |app, window, cx| {
|
||||
app.file_tree_drop_paths(vec![from.join("note.txt")], root.clone(), window, cx);
|
||||
});
|
||||
wait_until(&mut vcx, "it never asked", |vcx| vcx.has_pending_prompt());
|
||||
vcx.simulate_prompt_answer(t(L10nKey::FileDropReplace));
|
||||
wait_until(&mut vcx, "the replacement never landed", |_| {
|
||||
std::fs::read_to_string(root.join("note.txt")).is_ok_and(|s| s == "new")
|
||||
});
|
||||
settle(&app, &mut vcx, &root);
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("note.txt")).unwrap(),
|
||||
"new"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&from);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -855,6 +855,18 @@ pub fn translate_en(key: L10nKey) -> &'static str {
|
||||
L10nKey::FileTreeContextCopyPath => "Copy Path",
|
||||
L10nKey::FileTreeContextHideDotfiles => "Hide Dotfiles",
|
||||
L10nKey::FileTreeContextShowDotfiles => "Show Dotfiles",
|
||||
L10nKey::FileDropIntoItself => "A folder cannot be copied into itself.",
|
||||
L10nKey::FileDropNotHere => "Not on this machine.",
|
||||
L10nKey::FileDropTooDeep => "Nested more than {n} folders deep.",
|
||||
L10nKey::FileDropTooLarge => "Larger than {limit} MB — send it over SFTP instead.",
|
||||
L10nKey::FileDropReplaceTitle => "Replace \"{name}\"?",
|
||||
L10nKey::FileDropReplaceManyTitle => "Replace {n} items?",
|
||||
L10nKey::FileDropReplaceBody => {
|
||||
"This folder already has something by that name. Replacing it cannot be undone."
|
||||
}
|
||||
L10nKey::FileDropReplace => "Replace",
|
||||
L10nKey::FileDropFailed => "Could not copy {name}",
|
||||
L10nKey::FileDropFailedMany => "Could not copy {name}, and {n} more failed",
|
||||
L10nKey::SshPromptNewKey => "new {fingerprint}",
|
||||
L10nKey::SshPromptOldKey => "old {old_fingerprint}",
|
||||
L10nKey::EditorCantOpen => "Could not open {path}: {e}",
|
||||
|
||||
@@ -897,6 +897,18 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::FileTreeContextCopyPath => "パスをコピー",
|
||||
L10nKey::FileTreeContextHideDotfiles => "ドットファイルを非表示",
|
||||
L10nKey::FileTreeContextShowDotfiles => "ドットファイルを表示",
|
||||
L10nKey::FileDropIntoItself => "フォルダを自分自身の中にはコピーできません",
|
||||
L10nKey::FileDropNotHere => "このマシンにはありません",
|
||||
L10nKey::FileDropTooDeep => "フォルダの入れ子が {n} 階層を超えています",
|
||||
L10nKey::FileDropTooLarge => "{limit} MB を超えています。SFTP で転送してください",
|
||||
L10nKey::FileDropReplaceTitle => "「{name}」を置き換えますか?",
|
||||
L10nKey::FileDropReplaceManyTitle => "{n} 項目を置き換えますか?",
|
||||
L10nKey::FileDropReplaceBody => {
|
||||
"このフォルダには同じ名前のものがすでにあります。置き換えると元に戻せません"
|
||||
}
|
||||
L10nKey::FileDropReplace => "置き換える",
|
||||
L10nKey::FileDropFailed => "{name} をコピーできませんでした",
|
||||
L10nKey::FileDropFailedMany => "{name} をコピーできませんでした。他に {n} 件も失敗しました",
|
||||
L10nKey::SshPromptNewKey => "新しいキー {fingerprint}",
|
||||
L10nKey::SshPromptOldKey => "以前のキー {old_fingerprint}",
|
||||
L10nKey::EditorCantOpen => "{path} を開けません: {e}",
|
||||
|
||||
@@ -653,6 +653,16 @@ l10n_keys! {
|
||||
FileTreeContextCopyPath,
|
||||
FileTreeContextHideDotfiles,
|
||||
FileTreeContextShowDotfiles,
|
||||
FileDropIntoItself,
|
||||
FileDropNotHere,
|
||||
FileDropTooDeep,
|
||||
FileDropTooLarge,
|
||||
FileDropReplaceTitle,
|
||||
FileDropReplaceManyTitle,
|
||||
FileDropReplaceBody,
|
||||
FileDropReplace,
|
||||
FileDropFailed,
|
||||
FileDropFailedMany,
|
||||
SshPromptNewKey,
|
||||
SshPromptOldKey,
|
||||
EditorCantOpen,
|
||||
|
||||
@@ -820,6 +820,16 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
|
||||
L10nKey::FileTreeContextCopyPath => "复制路径",
|
||||
L10nKey::FileTreeContextHideDotfiles => "隐藏点文件",
|
||||
L10nKey::FileTreeContextShowDotfiles => "显示点文件",
|
||||
L10nKey::FileDropIntoItself => "文件夹不能复制到它自己里面。",
|
||||
L10nKey::FileDropNotHere => "不在这台机器上。",
|
||||
L10nKey::FileDropTooDeep => "文件夹嵌套超过 {n} 层。",
|
||||
L10nKey::FileDropTooLarge => "超过 {limit} MB,请改用 SFTP 传输。",
|
||||
L10nKey::FileDropReplaceTitle => "替换“{name}”?",
|
||||
L10nKey::FileDropReplaceManyTitle => "替换 {n} 个项目?",
|
||||
L10nKey::FileDropReplaceBody => "这个目录下已经有同名的东西了,替换后无法撤销。",
|
||||
L10nKey::FileDropReplace => "替换",
|
||||
L10nKey::FileDropFailed => "无法复制 {name}",
|
||||
L10nKey::FileDropFailedMany => "无法复制 {name},另有 {n} 个也失败了",
|
||||
L10nKey::SshPromptNewKey => "新 {fingerprint}",
|
||||
L10nKey::SshPromptOldKey => "旧 {old_fingerprint}",
|
||||
L10nKey::EditorCantOpen => "无法打开 {path}:{e}",
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod app;
|
||||
pub mod assets;
|
||||
pub mod code_editor;
|
||||
pub mod diff_overlay;
|
||||
pub mod file_copy;
|
||||
pub mod file_tree;
|
||||
pub mod forwards;
|
||||
pub mod hints;
|
||||
|
||||
+107
-7
@@ -143,6 +143,12 @@ pub(crate) struct SftpPanelState {
|
||||
pub(crate) filter_input: gpui::Entity<InputState>,
|
||||
pub(crate) error: Option<String>,
|
||||
pub(crate) jobs: Vec<SftpJobProgress>,
|
||||
/// Uploads this panel started whose landing it has not listed yet.
|
||||
///
|
||||
/// An upload is written to `<name>.tty7-upload-<hex>` and renamed into
|
||||
/// place at the very end, so any listing taken while one is in flight
|
||||
/// shows the temporary name. These are the jobs a listing is owed to.
|
||||
uploads_awaiting_listing: HashSet<u64>,
|
||||
/// Local names handed to a download that has not created its file yet.
|
||||
/// Two quick downloads of the same remote file would otherwise both find
|
||||
/// the name free and the second would write over the first.
|
||||
@@ -182,6 +188,7 @@ impl SftpPanelState {
|
||||
filter_input,
|
||||
error: None,
|
||||
jobs: Vec::new(),
|
||||
uploads_awaiting_listing: HashSet::new(),
|
||||
claimed_downloads: HashSet::new(),
|
||||
dismissed_jobs: HashSet::new(),
|
||||
show_history: false,
|
||||
@@ -200,6 +207,22 @@ impl SftpPanelState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Of the uploads a listing is owed to, the ones that are still writing.
|
||||
///
|
||||
/// A job that has dropped off the list entirely counts as done: whether it
|
||||
/// finished, failed or was trimmed from the history, it is not going to rename
|
||||
/// anything into place later.
|
||||
fn uploads_still_running(owed: &HashSet<u64>, jobs: &[SftpJobProgress]) -> HashSet<u64> {
|
||||
owed.iter()
|
||||
.copied()
|
||||
.filter(|id| {
|
||||
jobs.iter()
|
||||
.find(|job| job.job_id == *id)
|
||||
.is_some_and(|job| job.state == SftpJobState::Running)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_dir_like(e: &SftpEntry) -> bool {
|
||||
matches!(e.kind, SftpEntryKind::Dir)
|
||||
|| (matches!(e.kind, SftpEntryKind::Symlink) && e.target_is_dir)
|
||||
@@ -929,13 +952,18 @@ impl Tty7App {
|
||||
remote: remote_join(&cwd, &name),
|
||||
recursive,
|
||||
};
|
||||
if let Err(e) = self.sftp_route().transfer_start(spec) {
|
||||
self.sftp_panel.error = Some(e);
|
||||
match self.sftp_route().transfer_start(spec) {
|
||||
Ok(job_id) => {
|
||||
self.sftp_panel.uploads_awaiting_listing.insert(job_id);
|
||||
}
|
||||
Err(e) => self.sftp_panel.error = Some(e),
|
||||
}
|
||||
}
|
||||
self.sftp_poll_jobs(cx);
|
||||
self.sftp_start_polling(cx);
|
||||
self.sftp_refresh(cx);
|
||||
// No listing here on purpose. The upload has only just been handed to
|
||||
// the daemon, so a listing taken now catches the temporary name it
|
||||
// writes under; the one owed for it is taken when it settles.
|
||||
}
|
||||
|
||||
pub(crate) fn sftp_cancel_job(&mut self, job_id: u64, cx: &mut Context<Self>) {
|
||||
@@ -966,8 +994,28 @@ impl Tty7App {
|
||||
|
||||
fn sftp_poll_jobs(&mut self, cx: &mut Context<Self>) {
|
||||
if self.sftp_panel.open_pane_id.is_some() {
|
||||
self.sftp_panel.jobs = self.sftp_route().transfer_list();
|
||||
cx.notify();
|
||||
let jobs = self.sftp_route().transfer_list();
|
||||
self.sftp_apply_jobs(jobs, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Take a fresh job list, and list the directory again once the uploads
|
||||
/// that were running have stopped running.
|
||||
///
|
||||
/// Nothing used to ask for that listing. An upload lands under
|
||||
/// `<name>.tty7-upload-<hex>` and is renamed into place at the end, so the
|
||||
/// listing on screen was the one taken while the temporary name existed —
|
||||
/// and it stayed, so a finished upload read as a file with a hash glued to
|
||||
/// its name.
|
||||
fn sftp_apply_jobs(&mut self, jobs: Vec<SftpJobProgress>, cx: &mut Context<Self>) {
|
||||
let owed = &self.sftp_panel.uploads_awaiting_listing;
|
||||
let still_running = uploads_still_running(owed, &jobs);
|
||||
let settled = still_running.len() != owed.len();
|
||||
self.sftp_panel.uploads_awaiting_listing = still_running;
|
||||
self.sftp_panel.jobs = jobs;
|
||||
cx.notify();
|
||||
if settled {
|
||||
self.sftp_refresh(cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1004,8 +1052,7 @@ impl Tty7App {
|
||||
if this.sftp_panel.poll_gen != generation {
|
||||
return false;
|
||||
}
|
||||
this.sftp_panel.jobs = jobs;
|
||||
cx.notify();
|
||||
this.sftp_apply_jobs(jobs, cx);
|
||||
true
|
||||
})
|
||||
.unwrap_or(false);
|
||||
@@ -1774,6 +1821,59 @@ impl Tty7App {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn upload(job_id: u64, state: SftpJobState) -> SftpJobProgress {
|
||||
SftpJobProgress {
|
||||
job_id,
|
||||
pane_id: 1,
|
||||
kind: SftpTransferKind::Upload,
|
||||
state,
|
||||
current: String::new(),
|
||||
bytes_done: 0,
|
||||
bytes_total: 0,
|
||||
error: None,
|
||||
local: "/here/note.txt".into(),
|
||||
remote: "/there/note.txt".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upload_owes_a_listing_until_it_stops_running() {
|
||||
let owed = HashSet::from([7]);
|
||||
let running = uploads_still_running(&owed, &[upload(7, SftpJobState::Running)]);
|
||||
assert_eq!(
|
||||
running, owed,
|
||||
"a listing taken now shows the temporary name"
|
||||
);
|
||||
|
||||
for done in [
|
||||
SftpJobState::Done,
|
||||
SftpJobState::Error,
|
||||
SftpJobState::Cancelled,
|
||||
] {
|
||||
let running = uploads_still_running(&owed, &[upload(7, done)]);
|
||||
assert!(running.is_empty(), "{done:?} still owes the listing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_job_that_falls_off_the_list_is_not_waited_on_forever() {
|
||||
let owed = HashSet::from([7]);
|
||||
assert!(uploads_still_running(&owed, &[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_upload_finishing_does_not_settle_the_one_beside_it() {
|
||||
let owed = HashSet::from([7, 8]);
|
||||
let running = uploads_still_running(
|
||||
&owed,
|
||||
&[
|
||||
upload(7, SftpJobState::Done),
|
||||
upload(8, SftpJobState::Running),
|
||||
],
|
||||
);
|
||||
assert_eq!(running, HashSet::from([8]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_download_is_numbered_rather_than_written_over_the_first() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
Reference in New Issue
Block a user