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
@@ -31,7 +31,7 @@ data class TransferItem(
)
/**
* Turns "copy/paste these items into that folder" into a per-item decision, before a
* Turns "copy or move these items into that folder" into a per-item decision, before a
* single byte is written.
*
* Doing this as pure logic keeps the awkward cases — a name colliding with another
@@ -103,14 +103,14 @@ fun CompressDialog(
}
/**
* The **Copy to… / Move to…** picker.
* The **Copy to… / Cut 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`).
* The picker only chooses; the copy itself goes through the same plan and conflict question as any
* other transfer, with the picked folder as the target (see `BrowserViewModel.confirmTransferTo`).
*/
@Composable
fun TransferToDialog(
@@ -188,11 +188,11 @@ fun TransferToDialog(
/** 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
if (cut) R.string.transfer_picker_cut_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
if (cut) R.string.transfer_picker_cut_here else R.string.transfer_picker_copy_here
/**
* Renaming several files at once, with the numbering worked out before anything moves.
@@ -400,7 +400,7 @@ fun ConfirmDialog(
}
/**
* The prompt shown when a paste would overwrite something.
* The prompt shown when a transfer would overwrite something.
*
* The engine refuses to guess ([ConflictPolicy.ASK] is surfaced rather than resolved), so
* the choice is offered here and then applied to the whole batch.
@@ -397,7 +397,6 @@ fun BrowserScreen(
searchScope = state.searchScope,
// The choice only exists when there is a folder to make it about.
scopeChoosable = state.path != null,
canPaste = state.clipboard?.isEmpty == false,
onUp = viewModel::navigateUp,
onQuery = viewModel::setQuery,
onSearchOpen = { open ->
@@ -422,7 +421,6 @@ fun BrowserScreen(
)
},
onSort = { showSort = true },
onPaste = viewModel::paste,
onToggleBookmark = viewModel::toggleBookmark,
onRefresh = viewModel::refresh,
onUsage = viewModel::openUsage,
@@ -446,10 +444,8 @@ fun BrowserScreen(
if (state.isSelecting) {
SelectionBar(
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) },
onCutTo = { viewModel.startTransferTo(cut = true) },
onDelete = { confirmDelete = state.selection.toList() },
onDetails = { detailsFor = state.selectedEntries.firstOrNull() },
onRename = {
@@ -11,7 +11,6 @@ import androidx.compose.material.icons.filled.FileCopy
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PieChart
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Sort
@@ -42,6 +41,7 @@ import androidx.compose.ui.unit.dp
import de.softwareapp_hb.filemanager.R
/** The browser's app bar: title, up, the favourite star, and the search / sort / overflow actions. */
@Suppress("LongParameterList")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BrowserTopBar(
@@ -55,13 +55,11 @@ fun BrowserTopBar(
searchScope: SearchScope,
/** Whether the scope is the user's to choose at all; see the note beside the toggle. */
scopeChoosable: Boolean,
canPaste: Boolean,
onUp: () -> Unit,
onQuery: (String) -> Unit,
onSearchOpen: (Boolean) -> Unit,
onSearchScope: () -> Unit,
onSort: () -> Unit,
onPaste: () -> Unit,
onToggleBookmark: () -> Unit,
onRefresh: () -> Unit,
onUsage: () -> Unit,
@@ -101,58 +99,15 @@ fun BrowserTopBar(
},
actions = {
if (searchOpen) {
OutlinedTextField(
value = query,
onValueChange = onQuery,
// The hint says what the search will actually do, so the scope is legible
// without decoding the icon beside it.
placeholder = {
Text(
stringResource(
if (searchScope == SearchScope.EVERYWHERE) {
R.string.search_scope_everywhere
} else {
R.string.browser_search_hint
},
),
)
},
singleLine = true,
modifier = Modifier
.width(SEARCH_FIELD_WIDTH)
.focusRequester(searchField),
SearchFieldActions(
query = query,
searchScope = searchScope,
scopeChoosable = scopeChoosable,
searchField = searchField,
onQuery = onQuery,
onSearchScope = onSearchScope,
onSearchOpen = onSearchOpen,
)
// Which folder tree a search answers for is a property of the search, not of
// the folder being browsed, so it lives with the field rather than in the
// overflow menu — and while the field is open it is one tap from either
// answer to the other. It is absent when there is no folder to choose about
// (the volume list, or a library, neither of which is one): a control whose
// two settings do the same thing is worse than no control at all, because it
// implies a choice the user does not have.
if (scopeChoosable) {
IconButton(onClick = onSearchScope) {
Icon(
imageVector = if (searchScope == SearchScope.EVERYWHERE) {
Icons.Filled.Public
} else {
Icons.Filled.Folder
},
contentDescription = stringResource(
if (searchScope == SearchScope.EVERYWHERE) {
R.string.search_scope_folder
} else {
R.string.search_scope_everywhere
},
),
)
}
}
IconButton(onClick = { onSearchOpen(false); onQuery("") }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.browser_search_clear),
)
}
} else {
if (canBookmark) {
IconButton(onClick = onToggleBookmark) {
@@ -194,41 +149,119 @@ fun BrowserTopBar(
contentDescription = stringResource(R.string.browser_action_more),
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
if (canPaste) {
DropdownMenuItem(
text = { Text(stringResource(R.string.browser_action_paste)) },
leadingIcon = { Icon(Icons.Filled.ContentPaste, contentDescription = null) },
onClick = { menuOpen = false; onPaste() },
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.browser_action_duplicates)) },
leadingIcon = { Icon(Icons.Filled.FileCopy, contentDescription = null) },
onClick = { menuOpen = false; onDuplicates() },
)
DropdownMenuItem(
text = { Text(stringResource(R.string.browser_action_disk_usage)) },
leadingIcon = { Icon(Icons.Filled.PieChart, contentDescription = null) },
onClick = { menuOpen = false; onUsage() },
)
DropdownMenuItem(
text = { Text(stringResource(R.string.browser_action_trash)) },
leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) },
onClick = { menuOpen = false; onTrash() },
)
DropdownMenuItem(
text = { Text(stringResource(R.string.browser_action_refresh)) },
leadingIcon = { Icon(Icons.Filled.Refresh, contentDescription = null) },
onClick = { menuOpen = false; onRefresh() },
)
}
BrowserOverflowMenu(
expanded = menuOpen,
onDismiss = { menuOpen = false },
onDuplicates = onDuplicates,
onUsage = onUsage,
onTrash = onTrash,
onRefresh = onRefresh,
)
}
}
},
)
}
/** The app bar while the search field is open: the field, its scope toggle, and the close action. */
@Composable
private fun SearchFieldActions(
query: String,
searchScope: SearchScope,
scopeChoosable: Boolean,
searchField: FocusRequester,
onQuery: (String) -> Unit,
onSearchScope: () -> Unit,
onSearchOpen: (Boolean) -> Unit,
) {
OutlinedTextField(
value = query,
onValueChange = onQuery,
// The hint says what the search will actually do, so the scope is legible
// without decoding the icon beside it.
placeholder = {
Text(
stringResource(
if (searchScope == SearchScope.EVERYWHERE) {
R.string.search_scope_everywhere
} else {
R.string.browser_search_hint
},
),
)
},
singleLine = true,
modifier = Modifier
.width(SEARCH_FIELD_WIDTH)
.focusRequester(searchField),
)
// Which folder tree a search answers for is a property of the search, not of
// the folder being browsed, so it lives with the field rather than in the
// overflow menu — and while the field is open it is one tap from either
// answer to the other. It is absent when there is no folder to choose about
// (the volume list, or a library, neither of which is one): a control whose
// two settings do the same thing is worse than no control at all, because it
// implies a choice the user does not have.
if (scopeChoosable) {
IconButton(onClick = onSearchScope) {
Icon(
imageVector = if (searchScope == SearchScope.EVERYWHERE) {
Icons.Filled.Public
} else {
Icons.Filled.Folder
},
contentDescription = stringResource(
if (searchScope == SearchScope.EVERYWHERE) {
R.string.search_scope_folder
} else {
R.string.search_scope_everywhere
},
),
)
}
}
IconButton(onClick = { onSearchOpen(false); onQuery("") }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.browser_search_clear),
)
}
}
/** The app bar's overflow: the four actions that do not deserve a permanent icon. */
@Composable
private fun BrowserOverflowMenu(
expanded: Boolean,
onDismiss: () -> Unit,
onDuplicates: () -> Unit,
onUsage: () -> Unit,
onTrash: () -> Unit,
onRefresh: () -> Unit,
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
DropdownMenuItem(
text = { Text(stringResource(R.string.browser_action_duplicates)) },
leadingIcon = { Icon(Icons.Filled.FileCopy, contentDescription = null) },
onClick = { onDismiss(); onDuplicates() },
)
DropdownMenuItem(
text = { Text(stringResource(R.string.browser_action_disk_usage)) },
leadingIcon = { Icon(Icons.Filled.PieChart, contentDescription = null) },
onClick = { onDismiss(); onUsage() },
)
DropdownMenuItem(
text = { Text(stringResource(R.string.browser_action_trash)) },
leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) },
onClick = { onDismiss(); onTrash() },
)
DropdownMenuItem(
text = { Text(stringResource(R.string.browser_action_refresh)) },
leadingIcon = { Icon(Icons.Filled.Refresh, contentDescription = null) },
onClick = { onDismiss(); onRefresh() },
)
}
}
/**
* How wide the search field may be.
*
@@ -32,11 +32,6 @@ enum class SearchScope {
EVERYWHERE,
}
/** What the user has cut or copied, waiting to be pasted somewhere. */
data class Clipboard(val paths: List<StoragePath>, val cut: Boolean) {
val isEmpty: Boolean get() = paths.isEmpty()
}
/** A compress waiting for its two answers: the archive's name, and whether to compress it. */
data class CompressRequest(val paths: List<StoragePath>, val name: String)
@@ -58,11 +53,10 @@ data class PickerFolder(
}
/**
* The destination picker behind **Copy to…** and **Move to…**.
* The destination picker behind **Copy to…** and **Cut 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.
* It holds what the picker needs to draw and nothing about the operation: [paths] is what was
* selected when the picker opened, [cut] says whether the chosen verb is Cut or Copy.
*/
data class TransferPicker(
val paths: List<StoragePath>,
@@ -118,7 +112,7 @@ data class UiMessage(
}
/**
* A paste that hit existing names, waiting for the user to choose how to resolve it.
* A transfer that hit existing names, waiting for the user to choose how to resolve it.
*
* The engine deliberately does not answer conflicts ([ConflictPolicy.ASK] is surfaced
* rather than guessed), so this is where that decision lives.
@@ -173,7 +167,7 @@ data class DetailFocus(
*/
sealed interface Undo {
/**
* Move each item back where it came from. Covers a rename and a pasted cut alike,
* Move each item back where it came from. Covers a rename and a cut alike,
* because both are the same operation seen from different angles.
*/
data class Relocate(val moves: List<UndoMove>) : Undo
@@ -325,7 +319,6 @@ data class BrowserUiState(
val selection: Set<StoragePath> = emptySet(),
/** The entry the wide-screen detail pane is showing; `null` until the user picks one. */
val focus: DetailFocus? = null,
val clipboard: Clipboard? = null,
/**
* The rows an operation produced in this folder since it was last read, drawn as a dot.
*
@@ -333,7 +326,7 @@ data class BrowserUiState(
* cleared by opening the folder again and forgotten when the process ends.
*/
val changed: Set<StoragePath> = emptySet(),
/** The **Copy to… / Move to…** destination picker, when one is open. */
/** The **Copy to… / Cut to…** destination picker, when one is open. */
val transferPicker: TransferPicker? = null,
/** A compress waiting for its name and its compression choice. */
val compressRequest: CompressRequest? = null,
@@ -139,7 +139,7 @@ class BrowserViewModel(
/**
* What each folder gained since it was last read, waiting to be shown as a dot.
*
* In memory and per folder, for the same reason the clipboard is: the mark means "new since you last
* In memory and per folder, for the same reason the selection is: the mark means "new since you last
* looked here", so it is spent by the read that shows it, and a restart starts clean. Nothing here
* needs a timestamp, a timer or a background job — a row is new because an operation said so.
*/
@@ -515,12 +515,12 @@ class BrowserViewModel(
_state.update { state -> if (state.path == path) state.copy(freeBytes = free) else state }
}
// ------------------------------------------------------- copy to… / move to…
// ------------------------------------------------------- copy to… / cut 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
* The two verbs the selection bar offers, with the destination 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.
@@ -552,9 +552,9 @@ class BrowserViewModel(
/**
* 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.
* A name conflict asks before overwriting, 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
@@ -794,10 +794,9 @@ class BrowserViewModel(
*
* Deliberately narrow. A folder is worth coming back to — losing your place deep in a
* tree is the most annoying thing an Android file manager can do — and a sort order or
* view mode that resets on every process death is just noise. Three things are left
* view mode that resets on every process death is just noise. Two things are left
* out on purpose: the **search query**, because restoring one would hide the folder's
* contents on launch with no visible cause; the **selection**, which is transient; and
* the **clipboard**, because a paste that survived a reboot would be a nasty surprise.
* contents on launch with no visible cause; and the **selection**, which is transient.
*/
private fun restoredViewState(): BrowserUiState {
val savedSortKey = savedState.get<String>(KEY_SORT_KEY)
@@ -1925,40 +1924,6 @@ class BrowserViewModel(
_state.update { it.copy(scratch = null) }
}
// ---------------------------------------------------------------- clipboard
fun copyToClipboard(paths: List<StoragePath>, cut: Boolean) {
_state.update { it.copy(clipboard = Clipboard(paths, cut), selection = emptySet()) }
}
/**
* Paste the clipboard into the current directory.
*
* A clash is not resolved here: the plan is checked first and, if anything would be
* overwritten, the user is asked through [BrowserUiState.conflict].
*/
fun paste() {
val clipboard = _state.value.clipboard ?: return
val target = _state.value.path ?: return
val source = storage.activeSource() ?: return
viewModelScope.launch {
val conflicts = runCatching {
FileOperations(source)
.planTransfer(clipboard.paths, target, ConflictPolicy.ASK)
.count { it.action == TransferAction.ASK }
}.getOrDefault(0)
if (conflicts > 0) {
_state.update {
it.copy(conflict = ConflictPrompt(clipboard.paths, target, clipboard.cut, conflicts))
}
} else {
transfer(clipboard.paths, target, clipboard.cut, ConflictPolicy.KEEP_BOTH)
}
}
}
fun resolveConflict(policy: ConflictPolicy) {
val prompt = _state.value.conflict ?: return
_state.update { it.copy(conflict = null) }
@@ -2000,7 +1965,7 @@ class BrowserViewModel(
notifier.stop()
}
_state.update { it.copy(loading = false, clipboard = if (cut) null else it.clipboard) }
_state.update { it.copy(loading = false) }
if (result.succeeded) {
val landedNow = targetNames(source, target)
markChanged(target, (landedNow - landedBefore).map { target.child(it) })
@@ -2048,7 +2013,7 @@ class BrowserViewModel(
* Save the shared files into [folder] — wherever the user navigated to.
*
* Names come from the sending app where it offers one, and clashes go through the same
* [NameAllocator] a paste does, so sharing the same file twice cannot overwrite the
* [NameAllocator] a copy does, so sharing the same file twice cannot overwrite the
* first copy.
*/
fun receiveInto(folder: StoragePath) {
@@ -1,13 +1,6 @@
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
@@ -23,7 +16,6 @@ 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
@@ -86,10 +78,8 @@ fun StorageAccessPrompt(
@Suppress("LongParameterList")
fun SelectionBar(
state: BrowserUiState,
onCopy: () -> Unit,
onCut: () -> Unit,
onCopyTo: () -> Unit,
onMoveTo: () -> Unit,
onCutTo: () -> Unit,
onDelete: () -> Unit,
onDetails: () -> Unit,
onRename: () -> Unit,
@@ -127,43 +117,17 @@ fun SelectionBar(
.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onCopy) {
Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.browser_action_copy))
IconButton(onClick = onCopyTo) {
Icon(
Icons.Filled.ContentCopy,
contentDescription = stringResource(R.string.browser_action_copy_to),
)
}
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()
},
)
}
IconButton(onClick = onCutTo) {
Icon(
Icons.Filled.ContentCut,
contentDescription = stringResource(R.string.browser_action_cut_to),
)
}
/*
* Rename is offered for any selection: one file is renamed by name and several by
+4 -7
View File
@@ -105,9 +105,6 @@
<string name="browser_action_new_folder">New folder</string>
<string name="browser_action_sort">Sort</string>
<string name="browser_action_details">Details</string>
<string name="browser_action_copy">Copy</string>
<string name="browser_action_cut">Move</string>
<string name="browser_action_paste">Paste here</string>
<!-- Compressing: the name, and whether to squeeze the contents. -->
<string name="compress_dialog_title">Compress</string>
<string name="compress_dialog_name">Archive name</string>
@@ -115,13 +112,13 @@
<string name="compress_dialog_hint">Off stores the files as they are: faster to build, and ready to copy off the phone.</string>
<string name="compress_dialog_confirm">Compress</string>
<string name="compress_dialog_cancel">Cancel</string>
<!-- The two verbs that pick the destination instead of pasting into the folder on screen. -->
<!-- The two verbs that pick the destination directly. -->
<string name="browser_action_copy_to">Copy to…</string>
<string name="browser_action_move_to">Move to…</string>
<string name="browser_action_cut_to">Cut to…</string>
<string name="transfer_picker_copy_title">Copy to…</string>
<string name="transfer_picker_move_title">Move to…</string>
<string name="transfer_picker_cut_title">Cut to…</string>
<string name="transfer_picker_copy_here">Copy here</string>
<string name="transfer_picker_move_here">Move here</string>
<string name="transfer_picker_cut_here">Cut 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>
@@ -43,13 +43,11 @@ class BrowserTopBarTest {
searchOpen = searchOpen,
searchScope = SearchScope.FOLDER,
scopeChoosable = true,
canPaste = false,
onUp = {},
onQuery = {},
onSearchOpen = {},
onSearchScope = {},
onSort = {},
onPaste = {},
onToggleBookmark = {},
onRefresh = {},
onUsage = {},
@@ -418,23 +418,6 @@ class BrowserViewModelTest {
assertTrue(storage.contains(documents.child("a.txt")))
}
@Test
fun `a paste into the folder on screen names no destination and offers no action`() {
val model = viewModel()
model.openDirectory(root)
model.copyToClipboard(listOf(downloads.child("a.txt")), cut = false)
model.openDirectory(documents)
model.paste()
val message = model.state.value.message
assertNotNull(message)
assertEquals(R.plurals.message_copied, message!!.textRes)
assertNull(message.destination)
assertNull(message.action)
assertNull(message.actionLabelRes)
}
@Test
fun `the message's undo action is the undo`() {
val model = viewModel()
@@ -469,17 +452,6 @@ class BrowserViewModelTest {
assertEquals(R.string.browser_action_undo, message.actionLabelRes)
}
@Test
fun `a copy into the folder on screen marks the copy`() {
val model = viewModel()
model.openDirectory(documents)
model.copyToClipboard(listOf(downloads.child("a.txt")), cut = false)
model.paste()
assertEquals(setOf(documents.child("a.txt")), model.state.value.changed)
}
@Test
fun `a copy into another folder marks it there, not here`() {
val model = viewModel()
@@ -500,9 +472,13 @@ class BrowserViewModelTest {
@Test
fun `reading the folder again spends its marks`() {
val model = viewModel()
model.openDirectory(downloads)
model.toggleSelection(downloads.child("a.txt"))
model.startTransferTo(cut = false)
model.pickerOpen(documents)
model.confirmTransferTo()
model.openDirectory(documents)
model.copyToClipboard(listOf(downloads.child("a.txt")), cut = false)
model.paste()
assertTrue(model.state.value.changed.isNotEmpty())
model.openDirectory(root)
@@ -1596,57 +1572,6 @@ class BrowserViewModelTest {
assertTrue(recordedNotifications.isEmpty())
}
@Test
fun `copy then paste duplicates into the current folder`() {
val model = viewModel()
model.openDirectory(root)
model.copyToClipboard(listOf(downloads.child("a.txt")), cut = false)
assertNotNull(model.state.value.clipboard)
model.openDirectory(documents)
model.paste()
assertTrue(storage.contains(documents.child("a.txt")))
assertTrue(storage.contains(downloads.child("a.txt")))
assertEquals(listOf(OperationKind.COPY), recordedNotifications)
assertNotNull(model.state.value.clipboard)
}
@Test
fun `cut then paste moves and clears the clipboard`() {
val model = viewModel()
model.openDirectory(root)
model.copyToClipboard(listOf(downloads.child("a.txt")), cut = true)
model.openDirectory(documents)
model.paste()
assertTrue(storage.contains(documents.child("a.txt")))
assertFalse(storage.contains(downloads.child("a.txt")))
assertEquals(listOf(OperationKind.MOVE), recordedNotifications)
assertNull(model.state.value.clipboard)
}
@Test
fun `a pasted name that already exists asks before overwriting`() {
val model = viewModel()
storage.addFile(documents.child("a.txt"), "already here")
model.openDirectory(root)
model.copyToClipboard(listOf(downloads.child("a.txt")), cut = false)
model.openDirectory(documents)
model.paste()
val prompt = model.state.value.conflict
assertNotNull(prompt)
assertEquals(1, prompt!!.conflicts)
assertEquals("already here", storage.contentOf(documents.child("a.txt")))
model.resolveConflict(ConflictPolicy.REPLACE)
assertEquals("first", storage.contentOf(documents.child("a.txt")))
assertNull(model.state.value.conflict)
}
@Test
fun `without any grant the browser asks for a folder`() {
location.setSource(null)
@@ -1792,18 +1717,16 @@ class BrowserViewModelTest {
}
@Test
fun `a selection and a clipboard do not survive a restart`() {
fun `a selection does not survive a restart`() {
val saved = SavedStateHandle()
viewModel(savedState = saved).apply {
openDirectory(downloads)
toggleSelection(downloads.child("a.txt"))
copyToClipboard(listOf(downloads.child("a.txt")), cut = false)
}
val relaunched = viewModel(savedState = saved)
assertFalse(relaunched.state.value.isSelecting)
assertNull(relaunched.state.value.clipboard)
}
@Test
@@ -2010,12 +1933,13 @@ class BrowserViewModelTest {
}
@Test
fun `undoing a cut-paste moves the item back`() {
fun `undoing a cut to a chosen folder moves the item back`() {
val model = viewModel()
model.openDirectory(downloads)
model.copyToClipboard(listOf(downloads.child("a.txt")), cut = true)
model.openDirectory(documents)
model.paste()
model.toggleSelection(downloads.child("a.txt"))
model.startTransferTo(cut = true)
model.pickerOpen(documents)
model.confirmTransferTo()
assertTrue(storage.contains(documents.child("a.txt")))
model.undo()
@@ -2025,13 +1949,13 @@ class BrowserViewModelTest {
}
@Test
fun `a copy is not undoable, because nothing was changed`() {
fun `a copy to a chosen folder is not undoable, because nothing was changed`() {
val model = viewModel()
model.openDirectory(root)
model.copyToClipboard(listOf(downloads.child("a.txt")), cut = false)
model.openDirectory(documents)
model.paste()
model.openDirectory(downloads)
model.toggleSelection(downloads.child("a.txt"))
model.startTransferTo(cut = false)
model.pickerOpen(documents)
model.confirmTransferTo()
assertNull(model.state.value.undo)
}
@@ -3,7 +3,6 @@ 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
@@ -44,10 +43,8 @@ class SelectionBarTest {
entries = entries,
selection = entries.map { it.path }.toSet(),
),
onCopy = {},
onCut = {},
onCopyTo = {},
onMoveTo = {},
onCutTo = {},
onDelete = {},
onDetails = {},
onRename = {},
@@ -63,13 +60,11 @@ class SelectionBarTest {
}
@Test
fun `the destination verbs live in the overflow menu`() {
fun `the destination verbs are on the bar`() {
bar(selected = 1)
compose.onNodeWithContentDescription("More options").performClick()
compose.onNodeWithText("Copy to…").assertExists()
compose.onNodeWithText("Move to…").assertExists()
compose.onNodeWithContentDescription("Copy to…").assertExists()
compose.onNodeWithContentDescription("Cut to…").assertExists()
}
@Test