diff --git a/CHANGELOG.md b/CHANGELOG.md index f339112..3a54ff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserDialogs.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserDialogs.kt index 50127cb..525b0cc 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserDialogs.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserDialogs.kt @@ -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. * diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt index 5df4b7a..ea231ba 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt @@ -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) }, diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt index 76d4a58..245d4ac 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt @@ -36,6 +36,36 @@ data class Clipboard(val paths: List, 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, + val cut: Boolean, + /** The folder being shown, or `null` for the volumes. */ + val path: StoragePath? = null, + val folders: List = emptyList(), + /** The trail back to the volumes, ending with [path] when one is open. */ + val crumbs: List = 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, diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt index 3437ded..8d861c0 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt @@ -125,6 +125,16 @@ class BrowserViewModel( /** The unfiltered listing of the current directory; [recompute] derives what is shown. */ private var listing: List = 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 = 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 { + if (path == null) return emptyList() + val roots = storage.volumes().mapTo(HashSet()) { it.root } + val crumbs = ArrayList() + var current: StoragePath? = path + while (current != null) { + crumbs += current + if (current in roots) break + current = current.parent + } + return crumbs.reversed() + } + // ----------------------------------------------------------------- libraries /** diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt index 68aff62..57810f7 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBar.kt @@ -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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6bf3d07..185c4f3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -108,6 +108,16 @@ Copy Move Paste here + + Copy to… + Move to… + Copy to… + Move to… + Copy here + Move here + Up + Cancel + No folders here Rename Delete Share diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt index 4bd4ee9..862a964 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt @@ -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(), 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(), 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() diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBarTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBarTest.kt index 2a57f32..373e39e 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBarTest.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/SelectionBarTest.kt @@ -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) diff --git a/docs/CODE_MAP.md b/docs/CODE_MAP.md index 2cb7723..71aed83 100644 --- a/docs/CODE_MAP.md +++ b/docs/CODE_MAP.md @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b56ecb1..6dbc437 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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.