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
+9
View File
@@ -12,6 +12,15 @@ upload needs a **higher `versionCode`** than every previous upload.
### Added
- **Copy to… and Move to… chose a destination instead of making you go there.** The selection bar's
overflow now offers both verbs: a folder picker opens — the volumes first, then folders one level at a
time, with the trail above and Up beside it — and confirming starts the copy (or the move) into that
folder **without leaving the one you are in**. The files stay selected afterwards, so sending the same
set to a second destination is two taps. A name that already exists there asks exactly what Paste asks,
naming that folder; progress belongs to the same operation notification, and a move clears the selection
because those rows are gone. Underneath it is the paste path with an explicit target: the engine was
always target-agnostic, so the new part is the picker and the rules around it.
- **Several files can be renamed at once, numbered in a series that carries on.** Selecting more
than one file now offers Rename in the action bar, which opens a **pattern** dialog instead of the
single-file one: a name for the batch, the number to start at, and a live preview of the names it
@@ -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)
+15 -8
View File
@@ -2,7 +2,7 @@
Generated from the source by `tools/make-code-map.py` - do not edit by hand. Regenerate it after a change (`python3 tools/make-code-map.py`); `--check` exits non-zero when this file is stale.
79 source files, 13040 lines - 35 test files, 396 tests. The behaviour index at the end lists every test name, and in this project a test name is a sentence about what the app does, so it doubles as the specification.
79 source files, 13369 lines - 35 test files, 403 tests. The behaviour index at the end lists every test name, and in this project a test name is a sentence about what the app does, so it doubles as the specification.
Start at `AGENTS.md`. What is done, and why: `docs/ROADMAP.md` and `CHANGELOG.md`. What was verified by hand rather than by a test: `docs/EDGE_CASE_TESTING.md`.
@@ -98,17 +98,17 @@ The browser screen: view model, state, list and grid, dialogs, selection bar
| `BrowserComponents.kt` | 317 | The thumbnail an entry's leading visual gets in a list row. ListThumbnailSize, GridThumbnailSize, GridCellMinSize, EntryIcon(), FileEntry(), FileEntry(), VIDEO_FRAME_MILLIS, iconFor(), iconFor(), EntryRow(), EntryGridCell(), FileEntry() | - |
| `BrowserContent.kt` | 463 | Everything below the app bar: the permission prompt, the roots, or a directory. WIDE_LAYOUT_MIN_WIDTH, MasterPaneWidth, BrowserContent(), LazyListScope(), LIBRARY_SECTION_KEY, LazyListScope(), BOOKMARK_SECTION_KEY, DirectoryView(), FileEntry(), IndexStatusRow(), FileEntry() | - |
| `BrowserDetailPane.kt` | 131 | The second pane of the wide layout: what is inside the folder the user picked, or a preview and the details of the file they picked. DetailPane(), FolderPreview(), ImagePreview() | - |
| `BrowserDialogs.kt` | 279 | Renaming several files at once, with the numbering worked out before anything moves. BatchRenameDialog(), PREVIEW_NAMES, batchRenameNote(), NameDialog(), ConfirmDialog(), ConflictDialog() | `BrowserDialogsTest` (5) |
| `BrowserDialogs.kt` | 381 | The Copy to… / Move to… picker. TransferToDialog(), titleFor(), hereFor(), BatchRenameDialog(), PREVIEW_NAMES, batchRenameNote(), NameDialog(), ConfirmDialog(), ConflictDialog() | `BrowserDialogsTest` (5) |
| `BrowserRoots.kt` | 270 | A row on the root screen: a volume, with its free space when the platform reports it. RootRow(), itemCountLabel(), EmptyState(), LibrarySectionHeader(), BookmarkSectionHeader(), SectionHeader(), BookmarkRow(), LibraryRow(), Breadcrumbs() | `BrowserRootsTest` (4) |
| `BrowserScreen.kt` | 591 | BrowserScreen() | - |
| `BrowserScreen.kt` | 604 | BrowserScreen() | - |
| `BrowserTopBar.kt` | 238 | The browser's app bar: title, up, the favourite star, and the search / sort / overflow actions. BrowserTopBar(), SEARCH_FIELD_WIDTH | `BrowserTopBarTest` (4) |
| `BrowserUiState.kt` | 311 | One immutable description of everything the browser shows. ViewMode, SearchScope, Clipboard, UiMessage, ConflictPrompt, RootItem, Bookmark, DetailFocus, Undo, UndoMove, Overlay, BrowserUiState | - |
| `BrowserViewModel.kt` | 2237 | Drives the browser screen. BrowserViewModel, ArchivePreview | `BrowserViewModelTest` (90) |
| `BrowserUiState.kt` | 343 | One immutable description of everything the browser shows. ViewMode, SearchScope, Clipboard, PickerFolder, TransferPicker, UiMessage, ConflictPrompt, RootItem, Bookmark, DetailFocus, Undo, UndoMove, Overlay, BrowserUiState | - |
| `BrowserViewModel.kt` | 2376 | Drives the browser screen. BrowserViewModel, ArchivePreview | `BrowserViewModelTest` (96) |
| `DetailsSheet.kt` | 109 | Name, location, size, type and modified date for one entry. DetailsSheet(), DetailLines(), DetailLine() | - |
| `EntryIntents.kt` | 58 | Wraps an entry's path in a content:// URI another app may read. contentUriFor(), viewIntent(), shareIntent() | - |
| `IncomingBar.kt` | 60 | The bar shown while another app is waiting on this one. IncomingBar() | - |
| `IncomingRequest.kt` | 46 | What another app asked this one to do. IncomingRequest | - |
| `SelectionBar.kt` | 176 | The contextual action bar shown while entries are selected. StorageAccessPrompt(), SelectionBar() | `SelectionBarTest` (2) |
| `SelectionBar.kt` | 219 | The contextual action bar shown while entries are selected. StorageAccessPrompt(), SelectionBar() | `SelectionBarTest` (3) |
| `SortSheet.kt` | 128 | Sort key, direction, hidden files and list/grid — all in one sheet. sortKeyLabel(), SortSheet() | - |
## ui/viewer/
@@ -581,7 +581,7 @@ Every test name in the suite, by file: the fastest way to see whether something
- a library or the root list has nothing to star
- the star gives way to the search field
### `ui/browser/BrowserViewModelTest.kt` (90)
### `ui/browser/BrowserViewModelTest.kt` (96)
- starts on the root list
- opening a folder lists it with directories first and natural order
@@ -589,6 +589,12 @@ Every test name in the suite, by file: the fastest way to see whether something
- search narrows the listing
- sorting by size reorders the listing
- a folder of six hundred files is still listed in natural order
- copy to a chosen folder leaves the user where they are and keeps the selection
- move to a chosen folder takes the rows with it
- the picker offers folders and nothing else
- the picker walks up to the volumes and back down
- a conflicting destination asks before overwriting, naming that folder
- cancelling the picker copies nothing
- navigating up goes to the parent then to the roots
- selection survives toggling and clears on navigation
- a file with no preview of its own is handed to the system
@@ -674,8 +680,9 @@ Every test name in the suite, by file: the fastest way to see whether something
- while providing, tapping a file chooses it instead of opening it
- while providing, a folder still navigates so the file can actually be reached
### `ui/browser/SelectionBarTest.kt` (2)
### `ui/browser/SelectionBarTest.kt` (3)
- the destination verbs live in the overflow menu
- several files are offered a rename and not the details
- a single file keeps both
+9 -3
View File
@@ -1,8 +1,8 @@
# Roadmap
File Manager is built against a fixed milestone plan (**M0–M15**). This file is the
single source of truth for what is done and what remains. M16 is specified below but **not scheduled** —
it is the first item of the post-1.0 queue that is a feature rather than a safeguard.
single source of truth for what is done and what remains. **M16** (Copy to… / Move to…) has since been
built and is specified below; the queue records what is left.
Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (proposed, not scheduled)
@@ -81,7 +81,13 @@ release should not go out without.
still unmeasured, and jank on this screen needs a device that reports frame durations.
3. **The scaling numbers** — `./tools/benchmark-fixture.sh --count 20000` and the existing benchmarks:
answers whether a full SD card is a performance problem before a user with one finds out.
4. **Copy to… / Move to… (M16)** — the comfort feature, specified below.
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
tests cover the verbs, the picker's walk, folders-only listing, the conflict target, cancellation, and
the selection rule. **Not done**: the "copied to X — Show" message with a jump to the destination; the
copy reports itself the way a paste does.
5. **A CI run of the benchmarks on an emulator** — removes the phone from the loop and reports
`frameDurationCpuMs`, which this device cannot.