Desktop: multi-select library rows for bulk delete
Checkbox on every row; selecting any reveals a toolbar (N selected, Select all/Clear all, Clear, red Delete N). One confirmation dialog names every file (first 8 + count) before anything goes. deleteFiles() re-checks in-flight state at confirm time and skips anything the pump grabbed since selection (named in the notice) instead of yanking a live transcribe. Selection keys on absolute paths and prunes vanished files on rescan so the count never lies. Selection logic pure + unit-tested (28/28).
This commit is contained in:
parent
a5aedb2b0a
commit
fb1d0e4880
4 changed files with 152 additions and 0 deletions
|
|
@ -459,6 +459,12 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
},
|
||||
)
|
||||
}
|
||||
// Selection is user intent across rescans, but a file that no
|
||||
// longer exists must drop out (bulk count + dialog stay honest).
|
||||
if (_selected.value.isNotEmpty()) {
|
||||
val existing = files.map { it.absolutePath }.toSet()
|
||||
_selected.value = _selected.value.intersect(existing)
|
||||
}
|
||||
startWatcher()
|
||||
if (_autoTranscribe.value && _connected.value) pumpNewFiles()
|
||||
}
|
||||
|
|
@ -707,6 +713,41 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
private val _deleteWarning = MutableStateFlow<String?>(null)
|
||||
val deleteWarning: StateFlow<String?> = _deleteWarning.asStateFlow()
|
||||
|
||||
/**
|
||||
* Delete many recordings at once. Files the pump grabbed between
|
||||
* selection and confirm are skipped (named in the return) rather than
|
||||
* yanked mid-transcribe. Returns null when everything went, else a
|
||||
* summary of what was skipped/failed.
|
||||
*/
|
||||
fun deleteFiles(files: List<File>): String? {
|
||||
val (now, waiting) = LibraryQueue.planDelete(files, inFlight.toSet())
|
||||
val failed = now.mapNotNull { deleteFile(it) }
|
||||
val notes = buildList {
|
||||
waiting.forEach { add("\"${it.nameWithoutExtension}\" is transcribing — not deleted") }
|
||||
failed.forEach { add(it) }
|
||||
}
|
||||
_selected.value = emptySet()
|
||||
return notes.takeIf { it.isNotEmpty() }?.joinToString(" · ")
|
||||
}
|
||||
|
||||
/** Paths selected for bulk actions (rescan-proof: keyed by absolute path). */
|
||||
private val _selected = MutableStateFlow<Set<String>>(emptySet())
|
||||
val selected: StateFlow<Set<String>> = _selected.asStateFlow()
|
||||
|
||||
fun toggleSelected(path: String) {
|
||||
_selected.value = LibraryQueue.toggleSelection(_selected.value, path)
|
||||
}
|
||||
|
||||
fun selectAllOrClear(candidatePaths: List<String>) {
|
||||
_selected.value = LibraryQueue.selectOrClearAll(_selected.value, candidatePaths)
|
||||
}
|
||||
|
||||
fun clearSelection() { _selected.value = emptySet() }
|
||||
|
||||
/** Selected files that still exist on disk, in list order. */
|
||||
fun selectedFiles(): List<File> =
|
||||
_entries.value.filter { it.file.absolutePath in _selected.value }.map { it.file }
|
||||
|
||||
fun setQuery(q: String) {
|
||||
_query.value = q
|
||||
val needle = q.trim().lowercase()
|
||||
|
|
|
|||
|
|
@ -76,6 +76,25 @@ object LibraryQueue {
|
|||
fun deleteSet(audio: File, report: File, mapping: File): List<File> =
|
||||
listOf(audio, report, mapping).filter { it.exists() }
|
||||
|
||||
// ---- multi-select (paths as keys; survives rescans) ---------------------
|
||||
|
||||
fun toggleSelection(current: Set<String>, path: String): Set<String> =
|
||||
if (path in current) current - path else current + path
|
||||
|
||||
/** Select-all toggle: everything selected already → clear; else select
|
||||
* all [candidates]. */
|
||||
fun selectOrClearAll(current: Set<String>, candidates: List<String>): Set<String> =
|
||||
if (candidates.isNotEmpty() && current.containsAll(candidates)) emptySet()
|
||||
else candidates.toSet()
|
||||
|
||||
/** Split a selection into what can go now vs. files that must wait
|
||||
* because the pump has them in flight. */
|
||||
fun planDelete(
|
||||
selected: List<File>,
|
||||
inFlightNames: Set<String>,
|
||||
): Pair<List<File>, List<File>> =
|
||||
selected.partition { it.name !in inFlightNames }
|
||||
|
||||
/** Audio candidates: regular files, known extension, not hidden. */
|
||||
fun isAudioCandidate(f: File, audioExts: Set<String>): Boolean =
|
||||
f.isFile && !f.name.startsWith(".") &&
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
|
|
@ -93,8 +94,10 @@ fun LibraryScreen(state: DesktopState) {
|
|||
val connected by state.connected.collectAsState()
|
||||
var renaming by remember { mutableStateOf<File?>(null) }
|
||||
var deleting by remember { mutableStateOf<File?>(null) }
|
||||
var deletingMany by remember { mutableStateOf(false) }
|
||||
var deleteNotice by remember { mutableStateOf<String?>(null) }
|
||||
val deleteWarning by state.deleteWarning.collectAsState()
|
||||
val selected by state.selected.collectAsState()
|
||||
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
|
@ -133,6 +136,31 @@ fun LibraryScreen(state: DesktopState) {
|
|||
OutlinedTextField(query, { state.setQuery(it) },
|
||||
label = { Text("Search saved transcripts") },
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
if (selected.isNotEmpty() && query.isBlank()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("${selected.size} selected",
|
||||
style = MaterialTheme.typography.titleSmall)
|
||||
TextButton({
|
||||
state.selectAllOrClear(entries.map { it.file.absolutePath })
|
||||
}) {
|
||||
val allIn = entries.isNotEmpty() &&
|
||||
selected.containsAll(entries.map { it.file.absolutePath })
|
||||
Text(if (allIn) "Clear all" else "Select all")
|
||||
}
|
||||
TextButton({ state.clearSelection() }) { Text("Clear") }
|
||||
Spacer(Modifier.weight(1f))
|
||||
Button(
|
||||
onClick = { deletingMany = true },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
) {
|
||||
Text("Delete ${selected.size}")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (query.isNotBlank()) {
|
||||
if (hits.isEmpty()) {
|
||||
Text("No matches.", style = MaterialTheme.typography.bodySmall)
|
||||
|
|
@ -169,9 +197,14 @@ fun LibraryScreen(state: DesktopState) {
|
|||
LazyColumn(Modifier.fillMaxWidth().weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
items(entries, key = { it.file.absolutePath }) { e ->
|
||||
val isSelected = e.file.absolutePath in selected
|
||||
Card(Modifier.fillMaxWidth().clickable { state.openDetail(e.file) }) {
|
||||
Row(Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { state.toggleSelected(e.file.absolutePath) },
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(e.file.nameWithoutExtension,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
|
|
@ -300,6 +333,37 @@ fun LibraryScreen(state: DesktopState) {
|
|||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (deletingMany) {
|
||||
val victims = state.selectedFiles()
|
||||
val names = victims.take(8).joinToString("\n") {
|
||||
"• " + it.nameWithoutExtension
|
||||
} + (if (victims.size > 8) "\n• …and ${victims.size - 8} more" else "")
|
||||
AlertDialog(
|
||||
onDismissRequest = { deletingMany = false },
|
||||
title = { Text("Delete ${victims.size} recordings?") },
|
||||
text = {
|
||||
Column {
|
||||
Text(names, style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Saved transcripts go with them; transcribed " +
|
||||
"files are also removed from the engine. " +
|
||||
"Anything mid-transcribe is skipped, not killed.",
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton({
|
||||
deleteNotice = state.deleteFiles(victims)
|
||||
deletingMany = false
|
||||
}) { Text("Delete ${victims.size}",
|
||||
color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton({ deletingMany = false }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,34 @@ class LibraryQueueTest {
|
|||
).isEmpty())
|
||||
}
|
||||
|
||||
@Test fun toggleSelectionAddsThenRemoves() {
|
||||
val a = LibraryQueue.toggleSelection(emptySet(), "/x/a.m4a")
|
||||
assertEquals(setOf("/x/a.m4a"), a)
|
||||
assertEquals(emptySet<String>(), LibraryQueue.toggleSelection(a, "/x/a.m4a"))
|
||||
}
|
||||
|
||||
@Test fun selectAllOrClearToggles() {
|
||||
val all = listOf("/x/a.m4a", "/x/b.m4a")
|
||||
assertEquals(all.toSet(), LibraryQueue.selectOrClearAll(emptySet(), all))
|
||||
// everything already selected (plus a stale extra) -> clear
|
||||
assertEquals(emptySet<String>(),
|
||||
LibraryQueue.selectOrClearAll((all + "/gone.m4a").toSet(), all))
|
||||
// partial -> full select
|
||||
assertEquals(all.toSet(),
|
||||
LibraryQueue.selectOrClearAll(setOf("/x/a.m4a"), all))
|
||||
assertEquals(emptySet<String>(),
|
||||
LibraryQueue.selectOrClearAll(emptySet(), emptyList()))
|
||||
}
|
||||
|
||||
@Test fun planDeleteHoldsBackInFlightFiles() {
|
||||
val dir = createTempDirectory("lqp").toFile()
|
||||
val a = File(dir, "a.m4a").apply { writeText("x") }
|
||||
val b = File(dir, "b.m4a").apply { writeText("x") }
|
||||
val (now, waiting) = LibraryQueue.planDelete(listOf(a, b), setOf("b.m4a"))
|
||||
assertEquals(listOf(a), now)
|
||||
assertEquals(listOf(b), waiting)
|
||||
}
|
||||
|
||||
@Test fun autoQueueSkipsReportsInFlightAndFailed() {
|
||||
val dir = createTempDirectory("lq").toFile()
|
||||
val fresh = File(dir, "fresh.m4a").apply { writeText("x") }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue