diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index 7289cca..8ba64bb 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -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(null) val deleteWarning: StateFlow = _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): 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>(emptySet()) + val selected: StateFlow> = _selected.asStateFlow() + + fun toggleSelected(path: String) { + _selected.value = LibraryQueue.toggleSelection(_selected.value, path) + } + + fun selectAllOrClear(candidatePaths: List) { + _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 = + _entries.value.filter { it.file.absolutePath in _selected.value }.map { it.file } + fun setQuery(q: String) { _query.value = q val needle = q.trim().lowercase() diff --git a/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt b/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt index 4c33394..0064e9b 100644 --- a/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt +++ b/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt @@ -76,6 +76,25 @@ object LibraryQueue { fun deleteSet(audio: File, report: File, mapping: File): List = listOf(audio, report, mapping).filter { it.exists() } + // ---- multi-select (paths as keys; survives rescans) --------------------- + + fun toggleSelection(current: Set, path: String): Set = + if (path in current) current - path else current + path + + /** Select-all toggle: everything selected already → clear; else select + * all [candidates]. */ + fun selectOrClearAll(current: Set, candidates: List): Set = + 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, + inFlightNames: Set, + ): Pair, List> = + selected.partition { it.name !in inFlightNames } + /** Audio candidates: regular files, known extension, not hidden. */ fun isAudioCandidate(f: File, audioExts: Set): Boolean = f.isFile && !f.name.startsWith(".") && diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index 557f19b..86d8db3 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -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(null) } var deleting by remember { mutableStateOf(null) } + var deletingMany by remember { mutableStateOf(false) } var deleteNotice by remember { mutableStateOf(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") } + }, + ) + } } } diff --git a/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt b/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt index 24744c9..0ef112c 100644 --- a/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt +++ b/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt @@ -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(), 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(), + LibraryQueue.selectOrClearAll((all + "/gone.m4a").toSet(), all)) + // partial -> full select + assertEquals(all.toSet(), + LibraryQueue.selectOrClearAll(setOf("/x/a.m4a"), all)) + assertEquals(emptySet(), + 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") }