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:
avi 2026-09-15 05:37:56 -05:00
commit fb1d0e4880
4 changed files with 152 additions and 0 deletions

View file

@ -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()

View file

@ -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(".") &&

View file

@ -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") }
},
)
}
}
}

View file

@ -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") }