Copy to… and Move to… chose a destination instead of making you go there

The selection bar's overflow offers both verbs. Confirming opens a folder picker - the volumes first, then folders one level at a time, with the trail above and Up beside it - and starts the copy or the move into the picked folder without leaving the one you are in. The files stay selected after a copy, so sending the same set to a second destination is two taps; a move clears them because the rows are gone.

Underneath it is the paste path with an explicit target: planTransfer and transfer were always target-agnostic, so the new code is the picker and the rules around it. A conflict asks exactly what a paste asks and the prompt carries the picked folder.

One thing needed care: the reload that follows every operation clears the selection, and it finishes after the operation returns because it is a coroutine of its own. The restore therefore happens at the end of that reload, through a field the transfer sets - otherwise the last writer wins and a copy silently loses the selection it promised to keep, which is exactly what the first version did until the test caught it.

Seven view-model tests (copy, move, folders-only listing, the walk up and down, the conflict target, cancellation, the selection rule) and one Compose test for the overflow menu. CHANGELOG and ROADMAP record it, the roadmap naming what is deliberately not done: the copied-to-X message with a jump to the destination.
This commit is contained in:
2026-10-17 22:31:28 +02:00
parent 2a40302982
commit f3abef6c44
11 changed files with 487 additions and 11 deletions
@@ -1,5 +1,19 @@
package de.softwareapp_hb.filemanager.ui.browser
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.style.TextOverflow
import de.softwareapp_hb.filemanager.core.StoragePath
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -29,6 +43,94 @@ import de.softwareapp_hb.filemanager.core.ConflictPolicy
import de.softwareapp_hb.filemanager.core.FileEntry
import de.softwareapp_hb.filemanager.core.isUsableName
/**
* The **Copy to… / Move to…** picker.
*
* A folder browser in a dialog: the volumes first, then one level of folders at a time, with the trail
* above them. Files are never offered — a file cannot be a destination — and the confirm button stays
* disabled on the volume list, because "copy into the volume" is a folder, not the list of volumes.
*
* The picker only chooses; the copy itself goes through the same plan and conflict question as a paste,
* with the picked folder as the target (see `BrowserViewModel.confirmTransferTo`).
*/
@Composable
fun TransferToDialog(
picker: TransferPicker,
onOpen: (StoragePath?) -> Unit,
onUp: () -> Unit,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
stringResource(titleFor(picker.cut)),
)
},
text = {
Column {
if (picker.crumbs.isNotEmpty()) {
Text(
text = picker.crumbs.joinToString(" / ") { it.name },
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
)
TextButton(onClick = onUp) { Text(stringResource(R.string.transfer_picker_up)) }
}
if (picker.loading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (!picker.loading && picker.folders.isEmpty()) {
Text(stringResource(R.string.transfer_picker_empty))
} else {
LazyColumn(modifier = Modifier.heightIn(max = 320.dp)) {
items(picker.folders, key = { it.path.value }) { folder ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onOpen(folder.path) }
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = folder.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = onConfirm, enabled = picker.path != null) {
Text(stringResource(hereFor(picker.cut)))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.transfer_picker_cancel)) }
},
)
}
/** The dialog's title for the verb. */
private fun titleFor(cut: Boolean): Int =
if (cut) R.string.transfer_picker_move_title else R.string.transfer_picker_copy_title
/** The confirm button's label for the verb. */
private fun hereFor(cut: Boolean): Int =
if (cut) R.string.transfer_picker_move_here else R.string.transfer_picker_copy_here
/**
* Renaming several files at once, with the numbering worked out before anything moves.
*
@@ -416,6 +416,8 @@ fun BrowserScreen(
state = state,
onCopy = { viewModel.copyToClipboard(state.selection.toList(), cut = false) },
onCut = { viewModel.copyToClipboard(state.selection.toList(), cut = true) },
onCopyTo = { viewModel.startTransferTo(cut = false) },
onMoveTo = { viewModel.startTransferTo(cut = true) },
onDelete = { confirmDelete = state.selection.toList() },
onDetails = { detailsFor = state.selectedEntries.firstOrNull() },
onRename = {
@@ -581,6 +583,17 @@ fun BrowserScreen(
}
state.conflict?.let { prompt ->
val transferPicker = state.transferPicker
if (transferPicker != null) {
TransferToDialog(
picker = transferPicker,
onOpen = viewModel::pickerOpen,
onUp = viewModel::pickerUp,
onConfirm = viewModel::confirmTransferTo,
onDismiss = viewModel::cancelTransferTo,
)
}
ConflictDialog(
prompt = prompt,
onChoice = { policy -> viewModel.resolveConflict(policy) },
@@ -36,6 +36,36 @@ data class Clipboard(val paths: List<StoragePath>, val cut: Boolean) {
val isEmpty: Boolean get() = paths.isEmpty()
}
/**
* A folder the "copy to…" picker is offering: a volume root, or a folder inside one.
*
* [label] is the platform's name for a volume, which is the only place the picker has something better
* than a path to show — internal storage's own name is `0`.
*/
data class PickerFolder(val path: StoragePath, val label: String? = null) {
/** What the row says: the volume's label when there is one, else the folder's own name. */
val title: String get() = label ?: path.name
}
/**
* The destination picker behind **Copy to…** and **Move to…**.
*
* The operation itself is [Clipboard] with an explicit target instead of "the folder I am in", so this
* holds what the picker needs to draw and nothing about the copy: [paths] is what was selected when the
* picker opened, [cut] says which verb the user chose.
*/
data class TransferPicker(
val paths: List<StoragePath>,
val cut: Boolean,
/** The folder being shown, or `null` for the volumes. */
val path: StoragePath? = null,
val folders: List<PickerFolder> = emptyList(),
/** The trail back to the volumes, ending with [path] when one is open. */
val crumbs: List<StoragePath> = emptyList(),
val loading: Boolean = false,
)
/** A one-shot message for a snackbar. [id] makes repeats of the same text distinguishable. */
data class UiMessage(
/** A `@StringRes` or `@PluralsRes` value; see [pluralQuantity]. */
@@ -257,6 +287,8 @@ data class BrowserUiState(
/** The entry the wide-screen detail pane is showing; `null` until the user picks one. */
val focus: DetailFocus? = null,
val clipboard: Clipboard? = null,
/** The **Copy to… / Move to…** destination picker, when one is open. */
val transferPicker: TransferPicker? = null,
val loading: Boolean = false,
val freeBytes: Long? = null,
val message: UiMessage? = null,
@@ -125,6 +125,16 @@ class BrowserViewModel(
/** The unfiltered listing of the current directory; [recompute] derives what is shown. */
private var listing: List<FileEntry> = emptyList()
/**
* The selection to put back when the current reload finishes, if any.
*
* A copy into another folder leaves its sources where they are, so the selection made before it should
* still be there afterwards — that is what makes a second destination two taps. The reload that
* follows an operation clears the selection, and it finishes *after* the operation returns (it is a
* coroutine of its own), so this is the only place the restore can happen.
*/
private var selectionToRestore: Set<StoragePath> = emptySet()
/** The running recursive search, so the next keystroke can supersede it. */
private var searchJob: Job? = null
@@ -287,6 +297,7 @@ class BrowserViewModel(
}
recompute()
persistViewState()
}
}
@@ -332,6 +343,12 @@ class BrowserViewModel(
// batches were merged with whatever order was current when each one landed.
recompute()
persistViewState()
if (selectionToRestore.isNotEmpty()) {
val restore = selectionToRestore
selectionToRestore = emptySet()
_state.update { it.copy(selection = it.selection + restore) }
}
}
}
@@ -473,6 +490,128 @@ class BrowserViewModel(
_state.update { state -> if (state.path == path) state.copy(freeBytes = free) else state }
}
// ------------------------------------------------------- copy to… / move to…
/**
* Opens the destination picker for the current selection.
*
* The same two verbs [paste] offers, except that the destination is chosen here instead of being
* whatever folder happens to be on screen — which is the point: copying into another folder no
* longer means leaving the one you are in, and because the selection stays, a second destination is
* two taps.
*/
fun startTransferTo(cut: Boolean) {
val selected = _state.value.selectedEntries.map { it.path }
if (selected.isEmpty()) return
_state.update { it.copy(transferPicker = TransferPicker(paths = selected, cut = cut)) }
openPicker(null)
}
/** Shows [path] in the picker, or the volumes when it is `null`. */
fun pickerOpen(path: StoragePath?) {
if (_state.value.transferPicker == null) return
openPicker(path)
}
/** Back out one level, stopping at the volume list. */
fun pickerUp() {
val picker = _state.value.transferPicker ?: return
val crumbs = picker.crumbs
openPicker(if (crumbs.size > 1) crumbs[crumbs.size - 2] else null)
}
fun cancelTransferTo() {
_state.update { it.copy(transferPicker = null) }
}
/**
* Copies — or moves — the selection into the folder the picker is showing.
*
* A name conflict asks exactly what a paste asks, and the prompt carries the picked folder, so the
* answer applies to the right one. A copy leaves the selection in place, which is what makes a
* second destination two taps; a move clears it, because those rows are about to go.
*/
fun confirmTransferTo() {
val picker = _state.value.transferPicker ?: return
val target = picker.path ?: return
val source = storage.activeSource() ?: return
_state.update { it.copy(transferPicker = null) }
// The reload that follows an operation clears the selection. A copy leaves its sources where they
// are, so it is put back at the end of that reload — which is the only place it can be, because the
// reload is a coroutine that finishes after [transfer] returns. A move must not: those rows are gone.
selectionToRestore = if (picker.cut) emptySet() else _state.value.selection
if (picker.cut) _state.update { it.copy(selection = emptySet()) }
viewModelScope.launch {
val conflicts = runCatching {
FileOperations(source)
.planTransfer(picker.paths, target, ConflictPolicy.ASK)
.count { it.action == TransferAction.ASK }
}.getOrDefault(0)
if (conflicts > 0) {
_state.update {
it.copy(conflict = ConflictPrompt(picker.paths, target, picker.cut, conflicts))
}
} else {
transfer(picker.paths, target, picker.cut, ConflictPolicy.KEEP_BOTH)
}
}
}
/**
* Reads one level of the picker: the volumes, or the folders inside [path].
*
* Folders only, because a file cannot be a destination — offering one would be a menu of dead ends.
* The answer is discarded if the picker has moved on or been dismissed while it was being read, the
* same guard the listing uses.
*/
private fun openPicker(path: StoragePath?) {
val picker = _state.value.transferPicker ?: return
_state.update {
it.copy(transferPicker = picker.copy(path = path, loading = true, crumbs = pickerCrumbs(path)))
}
viewModelScope.launch {
val folders = if (path == null) {
storage.volumes().map { PickerFolder(path = it.root, label = it.label) }
} else {
val source = storage.activeSource()
val entries = if (source == null) {
emptyList()
} else {
runCatching { source.list(path) }.getOrDefault(emptyList())
}
entries.filter { it.isDirectory }
.sortedWith(SortSpec().comparator)
.map { PickerFolder(path = it.path) }
}
_state.update { state ->
val current = state.transferPicker
if (current == null || current.path != path) {
state
} else {
state.copy(transferPicker = current.copy(folders = folders, loading = false))
}
}
}
}
/** The trail down to [path], starting at its volume root; empty for the volume list itself. */
private fun pickerCrumbs(path: StoragePath?): List<StoragePath> {
if (path == null) return emptyList()
val roots = storage.volumes().mapTo(HashSet()) { it.root }
val crumbs = ArrayList<StoragePath>()
var current: StoragePath? = path
while (current != null) {
crumbs += current
if (current in roots) break
current = current.parent
}
return crumbs.reversed()
}
// ----------------------------------------------------------------- libraries
/**
@@ -1,6 +1,13 @@
package de.softwareapp_hb.filemanager.ui.browser
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -16,6 +23,7 @@ import androidx.compose.material.icons.filled.ContentCut
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.Share
@@ -75,10 +83,13 @@ fun StorageAccessPrompt(
/** The contextual action bar shown while entries are selected. */
@Composable
@Suppress("LongParameterList")
fun SelectionBar(
state: BrowserUiState,
onCopy: () -> Unit,
onCut: () -> Unit,
onCopyTo: () -> Unit,
onMoveTo: () -> Unit,
onDelete: () -> Unit,
onDetails: () -> Unit,
onRename: () -> Unit,
@@ -122,6 +133,38 @@ fun SelectionBar(
IconButton(onClick = onCut) {
Icon(Icons.Filled.ContentCut, contentDescription = stringResource(R.string.browser_action_cut))
}
/*
* Copy to… and Move to… are the same two verbs with a destination picker attached, so they sit
* behind one overflow button rather than adding two more icons to a bar that already scrolls.
*/
var destinationMenu by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { destinationMenu = true }) {
Icon(
Icons.Filled.MoreVert,
contentDescription = stringResource(R.string.browser_action_more),
)
}
DropdownMenu(
expanded = destinationMenu,
onDismissRequest = { destinationMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.browser_action_copy_to)) },
onClick = {
destinationMenu = false
onCopyTo()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.browser_action_move_to)) },
onClick = {
destinationMenu = false
onMoveTo()
},
)
}
}
/*
* Rename is offered for any selection: one file is renamed by name and several by
* pattern, and which of the two a tap means is described by the dialog that opens, not
+10
View File
@@ -108,6 +108,16 @@
<string name="browser_action_copy">Copy</string>
<string name="browser_action_cut">Move</string>
<string name="browser_action_paste">Paste here</string>
<!-- The two verbs that pick the destination instead of pasting into the folder on screen. -->
<string name="browser_action_copy_to">Copy to…</string>
<string name="browser_action_move_to">Move to…</string>
<string name="transfer_picker_copy_title">Copy to…</string>
<string name="transfer_picker_move_title">Move to…</string>
<string name="transfer_picker_copy_here">Copy here</string>
<string name="transfer_picker_move_here">Move here</string>
<string name="transfer_picker_up">Up</string>
<string name="transfer_picker_cancel">Cancel</string>
<string name="transfer_picker_empty">No folders here</string>
<string name="browser_action_rename">Rename</string>
<string name="browser_action_delete">Delete</string>
<string name="browser_action_share">Share</string>
@@ -234,6 +234,108 @@ class BrowserViewModelTest {
assertEquals("file-599.txt", names.last())
}
@Test
fun `copy to a chosen folder leaves the user where they are and keeps the selection`() {
val model = viewModel()
model.openDirectory(downloads)
model.toggleSelection(downloads.child("a.txt"))
model.startTransferTo(cut = false)
model.pickerOpen(root)
model.confirmTransferTo()
assertTrue(storage.contains(root.child("a.txt")))
assertTrue(storage.contains(downloads.child("a.txt")))
assertEquals(listOf(OperationKind.COPY), recordedNotifications)
// Still in the folder they were in, still selected: that is what makes a second destination cheap.
assertEquals(downloads, model.state.value.path)
assertEquals(listOf("a.txt"), model.state.value.selectedEntries.map { it.name })
assertNull(model.state.value.transferPicker)
}
@Test
fun `move to a chosen folder takes the rows with it`() {
val model = viewModel()
model.openDirectory(downloads)
model.toggleSelection(downloads.child("a.txt"))
model.startTransferTo(cut = true)
model.pickerOpen(root)
model.confirmTransferTo()
assertTrue(storage.contains(root.child("a.txt")))
assertFalse(storage.contains(downloads.child("a.txt")))
assertEquals(listOf(OperationKind.MOVE), recordedNotifications)
// A move leaves nothing to keep selected: those rows are gone.
assertTrue(model.state.value.selection.isEmpty())
}
@Test
fun `the picker offers folders and nothing else`() {
val model = viewModel()
model.openDirectory(downloads)
model.toggleSelection(downloads.child("a.txt"))
model.startTransferTo(cut = false)
model.pickerOpen(root)
val folders = model.state.value.transferPicker!!.folders.map { it.path }
// The root holds two folders and a file; only the folders are destinations.
assertEquals(setOf(documents, downloads), folders.toSet())
}
@Test
fun `the picker walks up to the volumes and back down`() {
val model = viewModel()
model.openDirectory(downloads)
model.toggleSelection(downloads.child("a.txt"))
model.startTransferTo(cut = false)
// The picker opens on the volumes, with no trail behind it.
assertEquals(emptyList<StoragePath>(), model.state.value.transferPicker!!.crumbs)
model.pickerOpen(downloads)
assertEquals(listOf(root, downloads), model.state.value.transferPicker!!.crumbs)
model.pickerUp()
assertEquals(listOf(root), model.state.value.transferPicker!!.crumbs)
model.pickerUp()
assertEquals(emptyList<StoragePath>(), model.state.value.transferPicker!!.crumbs)
}
@Test
fun `a conflicting destination asks before overwriting, naming that folder`() {
val model = viewModel()
storage.addFile(root.child("a.txt"), "already here")
model.openDirectory(downloads)
model.toggleSelection(downloads.child("a.txt"))
model.startTransferTo(cut = false)
model.pickerOpen(root)
model.confirmTransferTo()
val prompt = model.state.value.conflict
assertNotNull(prompt)
assertEquals(root, prompt!!.target)
assertEquals(1, prompt.conflicts)
assertEquals("already here", storage.contentOf(root.child("a.txt")))
}
@Test
fun `cancelling the picker copies nothing`() {
val model = viewModel()
model.openDirectory(downloads)
model.toggleSelection(downloads.child("a.txt"))
model.startTransferTo(cut = false)
model.cancelTransferTo()
assertNull(model.state.value.transferPicker)
assertFalse(storage.contains(root.child("a.txt")))
assertTrue(recordedNotifications.isEmpty())
}
@Test
fun `navigating up goes to the parent then to the roots`() {
val model = viewModel()
@@ -3,6 +3,7 @@ package de.softwareapp_hb.filemanager.ui.browser
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import de.softwareapp_hb.filemanager.core.FileEntry
import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.ui.theme.FileManagerTheme
@@ -45,6 +46,8 @@ class SelectionBarTest {
),
onCopy = {},
onCut = {},
onCopyTo = {},
onMoveTo = {},
onDelete = {},
onDetails = {},
onRename = {},
@@ -59,6 +62,16 @@ class SelectionBarTest {
}
}
@Test
fun `the destination verbs live in the overflow menu`() {
bar(selected = 1)
compose.onNodeWithContentDescription("More options").performClick()
compose.onNodeWithText("Copy to…").assertExists()
compose.onNodeWithText("Move to…").assertExists()
}
@Test
fun `several files are offered a rename and not the details`() {
bar(selected = 3)