Make Copy to… and Cut to… the selection bar's actions
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:
@@ -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
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
+18
-94
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user