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")