diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index 160e59e..04d6381 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme @@ -43,7 +44,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.text.style.TextOverflow import java.io.File -import javax.swing.JFileChooser // ---- engine ----------------------------------------------------------------- @@ -83,16 +83,106 @@ fun EngineScreen(state: DesktopState) { // ---- library --------------------------------------------------------------- -private fun chooseFolder(): File? { - val chooser = JFileChooser().apply { - dialogTitle = "Choose your recordings folder" - fileSelectionMode = JFileChooser.DIRECTORIES_ONLY - isAcceptAllFileFilterUsed = false - // Without an explicit size the dialog comes up tiny on HiDPI - // compositors (and there is no resize handle), so pin a usable one. - preferredSize = java.awt.Dimension(1040, 700) +// ---- folder picker (in-app, themed, zoom-aware) ----------------------------- + +/** + * Pure folder-picker navigation (unit-tested, no UI). + * Children = non-hidden directories, sorted; parent walks the path back. + */ +object FolderPicker { + fun children(dir: File): List = + dir.listFiles()?.filter { + it.isDirectory && !it.name.startsWith(".") + }?.sortedBy { it.name.lowercase() }.orEmpty() + + /** Directory list starting at home, walking into [dir]'s chain if set. */ + fun pathFromHome(dir: File?, home: File): List { + val start = dir ?: return listOf(home) + val chain = ArrayDeque() + var cur: File? = start + while (cur != null) { + chain.addFirst(cur) + if (cur.absolutePath == home.absolutePath) break + cur = cur.parentFile + } + // If start isn't under home (external volume), show its own chain. + return chain.toList().ifEmpty { listOf(start) } } - return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) chooser.selectedFile else null +} + +@androidx.compose.runtime.Composable +fun FolderPickerDialog( + startDir: File?, + onResult: (File?) -> Unit, +) { + val home = remember { File(System.getProperty("user.home")) } + var current by remember { + mutableStateOf(startDir?.takeIf { it.isDirectory } ?: home) + } + var error by remember { mutableStateOf(null) } + val subs = remember(current) { FolderPicker.children(current) } + val crumbs = remember(current) { FolderPicker.pathFromHome(current, home) } + + AlertDialog( + onDismissRequest = { onResult(null) }, + title = { Text("Choose your recordings folder") }, + text = { + Column(Modifier.fillMaxWidth().heightIn(min = 320.dp, max = 460.dp), + verticalArrangement = Arrangement.spacedBy(6.dp)) { + // Breadcrumb row: home + each step; click to jump. + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton({ current = home }) { Text("Home") } + crumbs.dropWhile { it.absolutePath == home.absolutePath } + .forEach { d -> + Text("/", color = MaterialTheme.colorScheme.onSurfaceVariant) + TextButton({ current = d }) { + Text(d.name.ifEmpty { d.absolutePath }, maxLines = 1) + } + } + } + Text(current.absolutePath, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + HorizontalDivider() + if (current.parentFile != null) { + TextButton( + { current.parentFile?.let { current = it } }, + modifier = Modifier.fillMaxWidth(), + ) { Text("⋱ .. (go up)") } + } + if (subs.isEmpty()) { + Text("No subfolders here.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + LazyColumn(Modifier.weight(1f)) { + items(subs, key = { it.absolutePath }) { d -> + Card(Modifier.fillMaxWidth().clickable { current = d }) { + Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, + vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically) { + Text("▸", color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(8.dp)) + Text(d.name, style = MaterialTheme.typography.bodyMedium) + } + } + Spacer(Modifier.height(4.dp)) + } + } + error?.let { + Text(it, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall) + } + } + }, + confirmButton = { + TextButton({ + if (current.isDirectory) onResult(current) + else error = "That folder no longer exists." + }) { Text("Choose “${current.name.ifEmpty { current.absolutePath }}”") } + }, + dismissButton = { TextButton({ onResult(null) }) { Text("Cancel") } }, + ) } @androidx.compose.runtime.Composable @@ -103,6 +193,7 @@ fun LibraryScreen(state: DesktopState) { val hits by state.searchResults.collectAsState() val connected by state.connected.collectAsState() var renaming by remember { mutableStateOf(null) } + var showPicker by remember { mutableStateOf(false) } var deleting by remember { mutableStateOf(null) } var deletingMany by remember { mutableStateOf(false) } var deleteNotice by remember { mutableStateOf(null) } @@ -136,7 +227,7 @@ fun LibraryScreen(state: DesktopState) { style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) Spacer(Modifier.width(8.dp)) - OutlinedButton({ chooseFolder()?.let { state.pickFolder(it) } }) { + OutlinedButton({ showPicker = true }) { Text("Choose folder…") } Spacer(Modifier.width(8.dp)) @@ -300,6 +391,16 @@ fun LibraryScreen(state: DesktopState) { } } + if (showPicker) { + FolderPickerDialog( + startDir = folder ?: File(System.getProperty("user.home")), + onResult = { picked -> + showPicker = false + picked?.let { state.pickFolder(it) } + }, + ) + } + renaming?.let { target -> var text by remember(target) { mutableStateOf(target.nameWithoutExtension) } var error by remember(target) { mutableStateOf(null) } diff --git a/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt b/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt index 0ef112c..a34b24f 100644 --- a/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt +++ b/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt @@ -94,3 +94,34 @@ class LibraryQueueTest { assertEquals(listOf("fresh.m4a"), picked.map { it.name }) } } + +class FolderPickerTest { + @Test fun childrenListsOnlyVisibleDirsSorted() { + val dir = createTempDirectory("fp").toFile() + val b = File(dir, "Bee").apply { mkdir() } + val a = File(dir, "apple").apply { mkdir() } + File(dir, ".cache").apply { mkdir() } + File(dir, "file.txt").writeText("x") + assertEquals(listOf(a, b), FolderPicker.children(dir)) + } + + @Test fun pathFromHomeWalksChainUnderHome() { + val home = File("/home/x") + val deep = File("/home/x/Music/Recordings") + assertEquals( + listOf(File("/home/x"), File("/home/x/Music"), deep), + FolderPicker.pathFromHome(deep, home), + ) + assertEquals(listOf(home), FolderPicker.pathFromHome(null, home)) + assertEquals(listOf(home), FolderPicker.pathFromHome(home, home)) + } + + @Test fun pathFromHomeHandlesExternalVolume() { + val home = File("/home/x") + val ext = File("/mnt/vids/clips") + // Not under home: its own chain, rooted at '/', no crash. + val chain = FolderPicker.pathFromHome(ext, home) + assertEquals(ext, chain.last()) + assertTrue(chain.size >= 3) + } +}