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:
l0ng-ai
2026-08-10 12:41:17 +08:00
committed by GitHub
parent 35bbad5155
commit 1df43b72b5
10 changed files with 818 additions and 14 deletions
+12
View File
@@ -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")
+20
View 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
+374
View File
@@ -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
View File
@@ -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);
}
}
+12
View File
@@ -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}",
+12
View File
@@ -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}",
+10
View File
@@ -653,6 +653,16 @@ l10n_keys! {
FileTreeContextCopyPath,
FileTreeContextHideDotfiles,
FileTreeContextShowDotfiles,
FileDropIntoItself,
FileDropNotHere,
FileDropTooDeep,
FileDropTooLarge,
FileDropReplaceTitle,
FileDropReplaceManyTitle,
FileDropReplaceBody,
FileDropReplace,
FileDropFailed,
FileDropFailedMany,
SshPromptNewKey,
SshPromptOldKey,
EditorCantOpen,
+10
View File
@@ -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}",
+1
View File
@@ -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
View File
@@ -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");