Make Copy to… and Cut to… the selection bar's actions
verify / gate (push) Has been cancelled
verify / release (push) Has been cancelled

The selection bar offered Copy and Move, which only filled an in-app clipboard; the
destination picker then lived behind an overflow menu, and Paste in the top bar. That
made a copy a two-step detour through another folder - the thing M16's picker exists to
avoid.

- The bar's two verbs are now Copy to… and Cut to…, both opening the destination picker.
- The clipboard state, Paste here, and the overflow's duplicate Copy to… / Move to… are
  removed. The picker's cut title and confirm are Cut to… / Cut here.
- A copy still keeps the selection for a second destination; a cut clears it. Conflicts
  still ask before overwriting, naming the picked folder.
- Five clipboard tests are removed; the mark, restart and undo tests now use the picker.
  BrowserTopBar's search and overflow sections are extracted so its two new detekt
  findings are fixed rather than baselined.

Driven on the Xiaomi phone: Copy to… copied 120 x 16 MB through Internal storage ->
AADemo -> Target and reported Copied 121 items to Target; Cut to… moved file001.bin and
offered Undo; a name in the way raised Items already exist here; the top-bar overflow
has no Paste. The foreground-service video was re-recorded against the new bar at 16.1 s.

Verified: ./tools/verify.sh green, the code map current, 426 unit tests pass, and the
video decodes as H.264 1080x2400 with no audio.
This commit is contained in:
2026-10-18 15:07:35 +02:00
parent f9305d1070
commit ddcd68b4b7
23 changed files with 322 additions and 417 deletions
+16 -16
View File
@@ -26,7 +26,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo
| M13 | Video thumbnails | ✅ | A video row shows **a frame of the video** instead of a film icon: images and videos take the same path through Coil, with the video request asking for a frame a second in. One dependency — `coil-video` at the Coil version already pinned, whose POM adds only coil-core/stdlib/core-ktx — the platform's own `MediaMetadataRetriever` behind it, **no new permission** (no `READ_MEDIA_VIDEO`), and the decoder registered **explicitly** against an `ImageLoader` in `FileManagerApplication` — because R8 strips `coil-video`'s ServiceLoader entry from a release build, which would otherwise have left frames decoding in debug and quietly falling back to icons for real users. A decode that fails settles on the icon rather than an empty square. Verified on the phone against two purpose-made clips and a real video of the device's own, with the demo library's stand-ins falling back as intended, and **re-verified on the release APK**. **324 JVM tests** |
| M14 | Batch rename with sequence continuation | ✅ | Selecting more than one file and choosing Rename opens a **pattern** dialog: a name, a start number, and a live preview of the names it will produce. `core/BatchRename` plans the whole batch before a single file moves and returns one of three answers — **Ready**, **Blocked** (naming the target the folder already holds) or **NothingToDo** — with the deliberate absence of a fourth: a batch that renamed nine of twelve and stopped would leave the user to work out which nine. The numbering **continues the folder's own series** `nextNumber` from the highest `base (n)` it finds, which is the market survey's most-helpful complaint answered by name; the extension is kept per file, the series is counted across extensions, and continuing beats filling the first gap. `SelectionBar` now offers Rename to any selection (one file keeps the name dialog, several get the pattern one), and the batch's renames are **one** `Undo.Relocate` step. The same commit hardens the single rename: `core/FileNames.isUsableName` refuses a name that is not one path segment, because `notes/final.txt` was being resolved as a path and **moved** the file instead of renaming it. **350 JVM tests** (26 added) |
| M15 | Favourites | ✅ | A star in the app bar of any folder, and a **Favourites** section on the home screen above the libraries — absent while empty, so a first-time user still meets the libraries first. `data/BookmarkStore` + `SqliteBookmarkStore` keep one path per row in their own database: a **reference, never a copy**, so the list costs one query and no filesystem work. `BrowserUiState.Bookmark` carries an `exists` flag, and a folder that has gone is **kept and marked** rather than deleted — an unmounted card and a deleted folder are the same thing from here — with opening re-checked at that moment so the answer is a message instead of an empty listing. The row's filled star is the removal (the mark that says "favourite" is the thing you tap to stop it being one), there is no star in a library or on the root screen, and no `Undo` for a bookmark because that type is "the last change to the filesystem" and a favourite is not one. **371 JVM tests** (16 added) |
| M16 | Copy to… / Move to… | ✅ | A destination picker behind the selection bar's overflow — volumes, then folders one level at a time, with Up and the trail above them — that copies or moves **without leaving the folder you are in**. The selection survives a copy, so a second destination is two taps; a move clears it. Conflicts ask what a paste asks, naming the picked folder; a copy answers "Copied to *folder*" with **Show**. Detailed below |
| M16 | Copy to… / Cut to… | ✅ | A destination picker behind the selection bar's **Copy to…** and **Cut to…** actions — volumes, then folders one level at a time, with Up and the trail above them — that copies or moves **without leaving the folder you are in**. The selection survives a copy, so a second destination is two taps; a cut clears it. Conflicts ask before overwriting, naming the picked folder; a copy answers "Copied to *folder*" with **Show**. Detailed below |
| M17 | The mark of a change | ✅ | A dot on the rows an operation produced, spent by the next read of that folder. In memory, per folder: no timer, no watcher, no badge. `BrowserUiState.changed` + `markChanged` on every operation that creates something; the dot is drawn by `MarkOfChange` in both list and grid, with a content description TalkBack reads. **7 tests** (4 view model, 3 Compose) |
| M18 | Store-only archives | ✅ | Compressing asks two things: the name, and whether to compress. Off stores the files as they are — `Deflater.NO_COMPRESSION`, one pass — which is what a bulk copy off the phone actually wants. A `STORED` entry would need each file's size and CRC before its header, i.e. reading everything twice. **4 tests** |
| M19 | The trash says what it does | ✅ | The trash states its window ("Deleted files are kept for 24 hours") and offers **Empty trash** behind one confirmation; opening the trash sweeps expired batches, so the 24-hour retention no longer waits for the next delete to run. **4 tests** |
@@ -63,7 +63,7 @@ duplicate finder (M12), batch rename with sequence continuation (M14) and favour
The last one standing there — the post-paste **"copy again"** and **"return to source folder"** — is
**closed as superseded**, not deferred: both exist only because that app's flow makes you leave the
source folder to paste. `Copy to…` / `Move to…` (in the queue below) removes that constraint, and with it
source folder to paste. `Copy to…` / `Cut to…` (in the queue below) removes that constraint, and with it
both features: you never leave the source, so the selection is still there for the next destination, and
there is no "back to source" to offer. Keeping them would be building a workaround for a limitation we
would no longer have.
@@ -71,7 +71,7 @@ would no longer have.
## The queue after 1.0
Ordered by what it protects, not by what is fun. **Four of the five items are done** — thumbnails, the
scaling numbers (which turned into progressive listing), Copy to… / Move to…, and the CI benchmark. What is
scaling numbers (which turned into progressive listing), Copy to… / Cut to…, and the CI benchmark. What is
left is the baseline profile's *measurement*, which needs a physical API 33+ device and cannot be finished
on this machine; everything else in the list says what it produced.
@@ -96,10 +96,11 @@ deliberately not started: its acceptance needs a TV device pass this machine can
outlier; the outlier was investigated and the real fix was progressive listing (200-entry batches,
O(n+k) merge), after which 20 000 files opens in **10–16 frames** with a 32–48 max across two runs. The
whole suite at that size, and what search costs there, is in [PERFORMANCE](PERFORMANCE.md).
4. **Copy to… / Move to… (M16)** — **done**: the selection bar's overflow opens a folder picker (volumes,
then folders one level at a time, Up and the trail above them) and confirming copies or moves into the
picked folder without leaving the current one. The selection survives a copy so a second destination is
two taps; a move clears it. Conflicts ask the same question as a paste, naming the picked folder. Seven
4. **Copy to… / Cut to… (M16)** — **done**: the selection bar's **Copy to…** and **Cut to…** actions
open a folder picker (volumes, then folders one level at a time, Up and the trail above them) and
confirming copies or moves into the picked folder without leaving the current one. The selection
survives a copy so a second destination is two taps; a cut clears it. Conflicts ask before
overwriting, naming the picked folder. Seven
tests cover the verbs, the picker's walk, folders-only listing, the conflict target, cancellation, and
the selection rule. Driven on the phone for both verbs (see `EDGE_CASE_TESTING.md`), which is what caught
the picker naming internal storage by its raw name `0`. A copy into a picked folder says **"Copied to
@@ -115,24 +116,23 @@ deliberately not started: its acceptance needs a TV device pass this machine can
`frameOverrunMs` after all (P50 13.9 ms / 19.0 ms for the 1 000-file open), so `PERFORMANCE.md`'s "counts
only" finding was a property of the earlier runs, not of the device; corrected there.
### M16 — Copy to… / Move to…
### M16 — Copy to… / Cut to…
**What**: from a selection, choose a destination folder and start the copy (or move) immediately, without
**What**: from a selection, choose a destination folder and start the copy (or cut) immediately, without
leaving the folder you are in.
**Why this shape**: `BrowserViewModel.paste()` already calls `planTransfer(paths, target, policy)` with the
current folder as `target`; the engine is target-agnostic, so the operation half needs no new machinery —
`FileOpService` and the conflict prompt work regardless of what is on screen. What is missing is a
destination picker.
**Why this shape**: the transfer engine already takes an explicit target — `planTransfer(paths, target,
policy)` — and is otherwise independent of what is on screen; `FileOpService` and the conflict prompt
work regardless. What was missing was a destination picker.
**Acceptance criteria**
- Selecting files and choosing *Copy to…* opens a folder picker: folders only, one level at a time, with
Up and breadcrumbs, every root the app can see (internal and SD card), and the granted tree in SAF mode.
- Confirming copies into the chosen folder and **leaves the user where they were**, with the selection
still intact — so a second destination is two taps. `Move to…` is the same picker with `cut = true`.
- A name conflict asks the same question as a paste does, with the picked folder named in it, and resolve
applies to that folder.
still intact — so a second destination is two taps. `Cut to…` is the same picker with `cut = true`.
- A name conflict asks before overwriting, with the picked folder named in it, and resolve applies to
that folder.
- Progress belongs to the existing operation notification, not to the screen the user is on.
- After a copy, a message names the destination and offers to show it. After a move the selection is
cleared and the listing refreshed, because the rows are gone.