From 29ca30ae8ae882b037dabb2ac486adb374b1c562 Mon Sep 17 00:00:00 2001 From: yqx1110 Date: Thu, 29 Jan 2026 19:30:40 +0800 Subject: [PATCH 1/7] Feat: Server path suggestion when inputting download path on AddTorrentScreen --- .../data/repositories/AddTorrentRepository.kt | 4 + .../qbitcontroller/network/TorrentService.kt | 5 ++ .../ui/addtorrent/AddTorrentScreen.kt | 84 +++++++++++++++---- .../ui/addtorrent/AddTorrentViewModel.kt | 27 ++++++ 4 files changed, 102 insertions(+), 18 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/data/repositories/AddTorrentRepository.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/data/repositories/AddTorrentRepository.kt index a19f49088..fb494217b 100644 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/data/repositories/AddTorrentRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/data/repositories/AddTorrentRepository.kt @@ -92,4 +92,8 @@ class AddTorrentRepository( suspend fun getDefaultSavePath(serverId: Int) = requestManager.request(serverId) { service -> service.getDefaultSavePath() } + + suspend fun getDirectoryContent(serverId: Int, path: String) = requestManager.request(serverId) { service -> + service.getDirectoryContent(path) + } } diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/network/TorrentService.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/network/TorrentService.kt index fa8434ff9..d10a34795 100755 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/network/TorrentService.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/network/TorrentService.kt @@ -109,6 +109,11 @@ class TorrentService( suspend fun getDefaultSavePath(): Response = get("app/defaultSavePath") + suspend fun getDirectoryContent(path: String): Response> = get( + "app/getDirectoryContent", + mapOf("dirPath" to path), + ) + suspend fun shutdown(): Response = post("app/shutdown") suspend fun getLog(): Response> = get("log/main") diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt index ce34b058d..1516fb87b 100644 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt @@ -869,24 +869,72 @@ fun AddTorrentScreen( } } - OutlinedTextField( - value = savePath, - onValueChange = { savePath = it }, - label = { - Text( - text = stringResource(Res.string.torrent_add_save_path), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - modifier = Modifier.fillMaxWidth(), - enabled = autoTmmIndex != 2, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Uri, - imeAction = ImeAction.Next, - ), - ) + val directorySuggestions by viewModel.directorySuggestions.collectAsStateWithLifecycle() + var savePathExpanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = savePathExpanded, + onExpandedChange = { savePathExpanded = it }, + ) { + OutlinedTextField( + value = savePath, + onValueChange = { + savePath = it + savePathExpanded = true + serverId?.let { id -> + viewModel.searchDirectories(id, it.text) + } + }, + label = { + Text( + text = stringResource(Res.string.torrent_add_save_path), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable), + enabled = autoTmmIndex != 2, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next, + ), + ) + + if (directorySuggestions.isNotEmpty()) { + ExposedDropdownMenu( + expanded = savePathExpanded, + onDismissRequest = { savePathExpanded = false }, + modifier = Modifier.fillMaxWidth(), + ) { + directorySuggestions.forEach { suggestion -> + DropdownMenuItem( + text = { + Text( + text = suggestion, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + onClick = { + savePath = TextFieldValue( + text = suggestion, + selection = androidx.compose.ui.text.TextRange(suggestion.length), + ) + savePathExpanded = true + // Trigger search again for the new path to load its subdirectories + serverId?.let { id -> + viewModel.searchDirectories(id, suggestion) + } + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } + } OutlinedTextField( value = torrentName, diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt index d3530adc4..27dcc17dd 100644 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt @@ -47,7 +47,11 @@ class AddTorrentViewModel( private val _isAdding = MutableStateFlow(false) val isAdding = _isAdding.asStateFlow() + private val _directorySuggestions = MutableStateFlow>(emptyList()) + val directorySuggestions = _directorySuggestions.asStateFlow() + private var loadCategoryTagJob: Job? = null + private var searchDirectoriesJob: Job? = null init { viewModelScope.launch { @@ -218,6 +222,29 @@ class AddTorrentViewModel( } } + fun searchDirectories(serverId: Int, path: String) { + searchDirectoriesJob?.cancel() + if (path.isBlank()) { + _directorySuggestions.value = emptyList() + return + } + + searchDirectoriesJob = viewModelScope.launch { + // Debounce + kotlinx.coroutines.delay(300) + + when (val result = repository.getDirectoryContent(serverId, path)) { + is RequestResult.Success -> { + _directorySuggestions.value = result.data.sorted() + } + is RequestResult.Error -> { + // Ignore errors for suggestions + _directorySuggestions.value = emptyList() + } + } + } + } + fun setServerId(serverId: Int?) { savedStateHandle["serverId"] = serverId } From 5200e0db52eb40e15ffdbfd4aa41ac0b92d73e32 Mon Sep 17 00:00:00 2001 From: yqx1110 Date: Thu, 29 Jan 2026 19:40:25 +0800 Subject: [PATCH 2/7] Feat: Refactor directory suggestion logic to filter by input path and bold matching prefixes in the UI. --- .../ui/addtorrent/AddTorrentScreen.kt | 16 ++++++++++++- .../ui/addtorrent/AddTorrentViewModel.kt | 24 ++++++++++++------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt index 1516fb87b..3bf034e5b 100644 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt @@ -69,10 +69,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -913,7 +917,17 @@ fun AddTorrentScreen( DropdownMenuItem( text = { Text( - text = suggestion, + text = buildAnnotatedString { + val matchLength = savePath.text.length + if (suggestion.startsWith(savePath.text, ignoreCase = true)) { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(suggestion.take(matchLength)) + } + append(suggestion.drop(matchLength)) + } else { + append(suggestion) + } + }, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt index 27dcc17dd..d9ecccd52 100644 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt @@ -233,15 +233,23 @@ class AddTorrentViewModel( // Debounce kotlinx.coroutines.delay(300) - when (val result = repository.getDirectoryContent(serverId, path)) { - is RequestResult.Success -> { - _directorySuggestions.value = result.data.sorted() - } - is RequestResult.Error -> { - // Ignore errors for suggestions - _directorySuggestions.value = emptyList() - } + val lastSeparatorIndex = path.lastIndexOfAny(charArrayOf('/', '\\')) + val parent = if (lastSeparatorIndex != -1) { + path.substring(0, lastSeparatorIndex + 1) + } else { + "" } + + if (parent.isEmpty()) { + _directorySuggestions.value = emptyList() + return@launch + } + + val suggestions = repository.getDirectoryContent(serverId, parent) + + _directorySuggestions.value = suggestions + .filter { it.startsWith(path, ignoreCase = true) } + .sorted() } } From e0105e4b4928252920b3b8c9a92731477304beb4 Mon Sep 17 00:00:00 2001 From: yqx1110 Date: Thu, 29 Jan 2026 20:48:53 +0800 Subject: [PATCH 3/7] Improve directory suggestions in Add Torrent screen --- .../ui/addtorrent/AddTorrentScreen.kt | 24 ++++++++++++-- .../ui/addtorrent/AddTorrentViewModel.kt | 32 +++++++++++-------- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt index 3bf034e5b..8bcd1e06a 100644 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt @@ -24,6 +24,8 @@ import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding @@ -32,6 +34,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -68,10 +71,10 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue @@ -876,6 +879,15 @@ fun AddTorrentScreen( val directorySuggestions by viewModel.directorySuggestions.collectAsStateWithLifecycle() var savePathExpanded by remember { mutableStateOf(false) } + // Hide suggestions when IME is folded + val density = androidx.compose.ui.platform.LocalDensity.current + val isImeVisible = WindowInsets.ime.getBottom(density) > 0 + LaunchedEffect(isImeVisible) { + if (!isImeVisible) { + savePathExpanded = false + } + } + ExposedDropdownMenuBox( expanded = savePathExpanded, onExpandedChange = { savePathExpanded = it }, @@ -905,13 +917,19 @@ fun AddTorrentScreen( keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next, ), + keyboardActions = KeyboardActions( + onNext = { + savePathExpanded = false + defaultKeyboardAction(ImeAction.Next) + } + ), ) if (directorySuggestions.isNotEmpty()) { ExposedDropdownMenu( expanded = savePathExpanded, onDismissRequest = { savePathExpanded = false }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.heightIn(max = 240.dp), ) { directorySuggestions.forEach { suggestion -> DropdownMenuItem( @@ -920,7 +938,7 @@ fun AddTorrentScreen( text = buildAnnotatedString { val matchLength = savePath.text.length if (suggestion.startsWith(savePath.text, ignoreCase = true)) { - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + withStyle(style = SpanStyle(background = Color.LightGray.copy(alpha = 0.5f))) { append(suggestion.take(matchLength)) } append(suggestion.drop(matchLength)) diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt index d9ecccd52..2fd9e1ba0 100644 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt @@ -224,29 +224,35 @@ class AddTorrentViewModel( fun searchDirectories(serverId: Int, path: String) { searchDirectoriesJob?.cancel() - if (path.isBlank()) { + if (path.isBlank() || !path.startsWith("/")) { _directorySuggestions.value = emptyList() return } searchDirectoriesJob = viewModelScope.launch { - // Debounce kotlinx.coroutines.delay(300) - val lastSeparatorIndex = path.lastIndexOfAny(charArrayOf('/', '\\')) - val parent = if (lastSeparatorIndex != -1) { - path.substring(0, lastSeparatorIndex + 1) - } else { - "" - } + val suggestions = ArrayList() - if (parent.isEmpty()) { - _directorySuggestions.value = emptyList() - return@launch + when (val result = repository.getDirectoryContent(serverId, path)) { + is RequestResult.Success -> { + suggestions.addAll(result.data) + } + else -> {} } - val suggestions = repository.getDirectoryContent(serverId, parent) - + // if path doesn't ends with a slash, we need to also check the parent directory for suggestions + // e.g. for /downloads/m, check if there's a directory's name under /downloads starts with "m" + if (!path.endsWith("/")) { + val lastSeparatorIndex = path.lastIndexOf('/') + val parent = path.take(lastSeparatorIndex + 1) + when (val result = repository.getDirectoryContent(serverId, parent)) { + is RequestResult.Success -> { + suggestions.addAll(result.data) + } + else -> {} + } + } _directorySuggestions.value = suggestions .filter { it.startsWith(path, ignoreCase = true) } .sorted() From e1c034d33058db844d867453e937c6843fadbb81 Mon Sep 17 00:00:00 2001 From: yqx1110 Date: Thu, 29 Jan 2026 22:53:51 +0800 Subject: [PATCH 4/7] reformat --- .../qbitcontroller/ui/addtorrent/AddTorrentScreen.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt index 8bcd1e06a..b837111cd 100644 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt @@ -921,7 +921,7 @@ fun AddTorrentScreen( onNext = { savePathExpanded = false defaultKeyboardAction(ImeAction.Next) - } + }, ), ) @@ -938,7 +938,9 @@ fun AddTorrentScreen( text = buildAnnotatedString { val matchLength = savePath.text.length if (suggestion.startsWith(savePath.text, ignoreCase = true)) { - withStyle(style = SpanStyle(background = Color.LightGray.copy(alpha = 0.5f))) { + withStyle( + style = SpanStyle(background = Color.LightGray.copy(alpha = 0.5f)), + ) { append(suggestion.take(matchLength)) } append(suggestion.drop(matchLength)) From 0ac544be3b223d8721a12a0f30b422a7263ba25e Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 05:09:20 +0000 Subject: [PATCH 5/7] Fix review issues in PR #257 - Inject RequestManager into AddTorrentViewModel and check qBittorrent version >= 5.0.0 - Add parallel directory content fetching in AddTorrentViewModel - Handle Windows paths in AddTorrentViewModel - Use imports for LocalDensity and TextRange in AddTorrentScreen - Remove fully qualified names in AddTorrentScreen - Fix delay import in AddTorrentViewModel Co-authored-by: yqx1110 <33057583+yqx1110@users.noreply.github.com> --- .../bartuzen/qbitcontroller/di/AppModule.kt | 2 +- .../ui/addtorrent/AddTorrentScreen.kt | 6 +- .../ui/addtorrent/AddTorrentViewModel.kt | 59 ++++++++++++++----- 3 files changed, 49 insertions(+), 18 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/di/AppModule.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/di/AppModule.kt index 1ff10de4d..d33c50ff8 100644 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/di/AppModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/di/AppModule.kt @@ -89,7 +89,7 @@ val appModule = module { viewModel { (serverId: Int, hash: String) -> TorrentPeersViewModel(serverId, hash, get(), get(), get()) } viewModel { (serverId: Int, hash: String) -> TorrentWebSeedsViewModel(serverId, hash, get(), get()) } - viewModel { (initialServerId: Int?) -> AddTorrentViewModel(initialServerId, get(), get(), get()) } + viewModel { (initialServerId: Int?) -> AddTorrentViewModel(initialServerId, get(), get(), get(), get()) } viewModel { (serverId: Int) -> RssFeedsViewModel(serverId, get()) } viewModel { (serverId: Int, feedPath: List, uid: String?) -> diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt index b837111cd..5139c8ff4 100644 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt @@ -73,7 +73,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -880,7 +882,7 @@ fun AddTorrentScreen( var savePathExpanded by remember { mutableStateOf(false) } // Hide suggestions when IME is folded - val density = androidx.compose.ui.platform.LocalDensity.current + val density = LocalDensity.current val isImeVisible = WindowInsets.ime.getBottom(density) > 0 LaunchedEffect(isImeVisible) { if (!isImeVisible) { @@ -955,7 +957,7 @@ fun AddTorrentScreen( onClick = { savePath = TextFieldValue( text = suggestion, - selection = androidx.compose.ui.text.TextRange(suggestion.length), + selection = TextRange(suggestion.length), ) savePathExpanded = true // Trigger search again for the new path to load its subdirectories diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt index 2fd9e1ba0..4445966ae 100644 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.bartuzen.qbitcontroller.data.ServerManager import dev.bartuzen.qbitcontroller.data.repositories.AddTorrentRepository +import dev.bartuzen.qbitcontroller.model.QBittorrentVersion +import dev.bartuzen.qbitcontroller.network.RequestManager import dev.bartuzen.qbitcontroller.network.RequestResult import dev.bartuzen.qbitcontroller.utils.getSerializableStateFlow import io.github.vinceglb.filekit.PlatformFile @@ -14,6 +16,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest @@ -29,6 +32,7 @@ class AddTorrentViewModel( private val savedStateHandle: SavedStateHandle, private val repository: AddTorrentRepository, private val serverManager: ServerManager, + private val requestManager: RequestManager, ) : ViewModel() { private val eventChannel = Channel() val eventFlow = eventChannel.receiveAsFlow() @@ -224,35 +228,60 @@ class AddTorrentViewModel( fun searchDirectories(serverId: Int, path: String) { searchDirectoriesJob?.cancel() - if (path.isBlank() || !path.startsWith("/")) { + + if (path.isBlank()) { _directorySuggestions.value = emptyList() return } + val version = requestManager.getQBittorrentVersion(serverId) + if (version < QBittorrentVersion(5, 0, 0)) { + return + } + searchDirectoriesJob = viewModelScope.launch { - kotlinx.coroutines.delay(300) + delay(300) val suggestions = ArrayList() - when (val result = repository.getDirectoryContent(serverId, path)) { - is RequestResult.Success -> { - suggestions.addAll(result.data) + val pathDeferred = async { + when (val result = repository.getDirectoryContent(serverId, path)) { + is RequestResult.Success -> { + result.data + } + is RequestResult.Error -> { + emptyList() + } } - else -> {} } - // if path doesn't ends with a slash, we need to also check the parent directory for suggestions - // e.g. for /downloads/m, check if there's a directory's name under /downloads starts with "m" - if (!path.endsWith("/")) { - val lastSeparatorIndex = path.lastIndexOf('/') - val parent = path.take(lastSeparatorIndex + 1) - when (val result = repository.getDirectoryContent(serverId, parent)) { - is RequestResult.Success -> { - suggestions.addAll(result.data) + val parentDeferred = async { + // if path doesn't ends with a slash, we need to also check the parent directory for suggestions + // e.g. for /downloads/m, check if there's a directory's name under /downloads starts with "m" + if (!path.endsWith("/") && !path.endsWith("\\")) { + val separator = if (path.contains('/')) '/' else '\\' + val lastSeparatorIndex = path.lastIndexOf(separator) + if (lastSeparatorIndex != -1) { + val parent = path.take(lastSeparatorIndex + 1) + when (val result = repository.getDirectoryContent(serverId, parent)) { + is RequestResult.Success -> { + result.data + } + is RequestResult.Error -> { + emptyList() + } + } + } else { + emptyList() } - else -> {} + } else { + emptyList() } } + + suggestions.addAll(pathDeferred.await()) + suggestions.addAll(parentDeferred.await()) + _directorySuggestions.value = suggestions .filter { it.startsWith(path, ignoreCase = true) } .sorted() From 70538305a2abad47ea90ce62f3cbba16d22c5334 Mon Sep 17 00:00:00 2001 From: yqx1110 Date: Mon, 16 Feb 2026 16:25:08 +0800 Subject: [PATCH 6/7] reformat --- .../qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt index 932e3ef9f..57f9d8c30 100644 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt @@ -5,9 +5,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.bartuzen.qbitcontroller.data.ServerManager import dev.bartuzen.qbitcontroller.data.repositories.AddTorrentRepository +import dev.bartuzen.qbitcontroller.model.Category import dev.bartuzen.qbitcontroller.model.QBittorrentVersion import dev.bartuzen.qbitcontroller.network.RequestManager -import dev.bartuzen.qbitcontroller.model.Category import dev.bartuzen.qbitcontroller.network.RequestResult import dev.bartuzen.qbitcontroller.utils.getSerializableStateFlow import io.github.vinceglb.filekit.PlatformFile From 8492d7d7cb63d54501def0a8621fe5e46c6aba01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartu=20=C3=96zen?= Date: Sat, 21 Feb 2026 22:18:50 +0300 Subject: [PATCH 7/7] Improvements --- .../ui/addtorrent/AddTorrentScreen.kt | 10 +- .../ui/addtorrent/AddTorrentViewModel.kt | 97 +++++++++++++------ 2 files changed, 68 insertions(+), 39 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt index 533c4ad6a..75ed2f5a0 100644 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentScreen.kt @@ -911,9 +911,7 @@ fun AddTorrentScreen( onValueChange = { savePath = it savePathExpanded = true - serverId?.let { id -> - viewModel.searchDirectories(id, it.text) - } + viewModel.setSavePath(it.text) }, label = { Text( @@ -972,12 +970,8 @@ fun AddTorrentScreen( selection = TextRange(suggestion.length), ) savePathExpanded = true - // Trigger search again for the new path to load its subdirectories - serverId?.let { id -> - viewModel.searchDirectories(id, suggestion) - } + viewModel.setSavePath(suggestion) }, - contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt index 57f9d8c30..a84349a30 100644 --- a/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/bartuzen/qbitcontroller/ui/addtorrent/AddTorrentViewModel.kt @@ -19,14 +19,19 @@ import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.io.files.FileNotFoundException import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import kotlin.math.max class AddTorrentViewModel( initialServerId: Int?, @@ -52,8 +57,13 @@ class AddTorrentViewModel( private val _isAdding = MutableStateFlow(false) val isAdding = _isAdding.asStateFlow() - private val _directorySuggestions = MutableStateFlow>(emptyList()) - val directorySuggestions = _directorySuggestions.asStateFlow() + private val savePath = MutableStateFlow("") + + private val directoryInfo = MutableStateFlow(null) + + val directorySuggestions = directoryInfo + .map { it?.content ?: emptyList() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) private var loadCategoryTagJob: Job? = null private var searchDirectoriesJob: Job? = null @@ -79,6 +89,16 @@ class AddTorrentViewModel( } } } + + viewModelScope.launch { + combine(savePath, serverId) { path, serverId -> + Pair(path, serverId) + }.collectLatest { (path, serverId) -> + if (serverId != null && path != null) { + searchDirectories(serverId, path) + } + } + } } fun addTorrent( @@ -226,11 +246,16 @@ class AddTorrentViewModel( } } - fun searchDirectories(serverId: Int, path: String) { + private fun searchDirectories(serverId: Int, path: String) { searchDirectoriesJob?.cancel() + val currentDirectoryInfo = directoryInfo.value + if (serverId != currentDirectoryInfo?.serverId) { + directoryInfo.value = null + } + if (path.isBlank()) { - _directorySuggestions.value = emptyList() + directoryInfo.value = currentDirectoryInfo?.copy(content = emptyList()) return } @@ -240,9 +265,9 @@ class AddTorrentViewModel( } searchDirectoriesJob = viewModelScope.launch { - delay(300) - - val suggestions = ArrayList() + if (directoryInfo.value != null) { + delay(300) + } val pathDeferred = async { when (val result = repository.getDirectoryContent(serverId, path)) { @@ -256,35 +281,36 @@ class AddTorrentViewModel( } val parentDeferred = async { - // if path doesn't ends with a slash, we need to also check the parent directory for suggestions - // e.g. for /downloads/m, check if there's a directory's name under /downloads starts with "m" - if (!path.endsWith("/") && !path.endsWith("\\")) { - val separator = if (path.contains('/')) '/' else '\\' - val lastSeparatorIndex = path.lastIndexOf(separator) - if (lastSeparatorIndex != -1) { - val parent = path.take(lastSeparatorIndex + 1) - when (val result = repository.getDirectoryContent(serverId, parent)) { - is RequestResult.Success -> { - result.data - } - is RequestResult.Error -> { - emptyList() - } - } - } else { + if (path.endsWith("/") || path.endsWith("\\")) { + return@async emptyList() + } + + val lastSeparatorIndex = max( + path.lastIndexOf('/'), + path.lastIndexOf('\\'), + ) + if (lastSeparatorIndex == -1) { + return@async emptyList() + } + + val parent = path.take(lastSeparatorIndex + 1) + when (val result = repository.getDirectoryContent(serverId, parent)) { + is RequestResult.Success -> { + result.data + } + is RequestResult.Error -> { emptyList() } - } else { - emptyList() } } - suggestions.addAll(pathDeferred.await()) - suggestions.addAll(parentDeferred.await()) - - _directorySuggestions.value = suggestions - .filter { it.startsWith(path, ignoreCase = true) } - .sorted() + directoryInfo.value = DirectoryInfo( + serverId = serverId, + content = (pathDeferred.await() + parentDeferred.await()) + .distinct() + .filter { it.startsWith(path, ignoreCase = true) } + .sortedWith(String.CASE_INSENSITIVE_ORDER), + ) } } @@ -300,6 +326,10 @@ class AddTorrentViewModel( savedStateHandle["isLoading"] = isLoading } + fun setSavePath(path: String) { + savePath.value = path + } + sealed class Event { data class Error(val error: RequestResult.Error) : Event() data object NoServersFound : Event() @@ -317,3 +347,8 @@ data class ServerData( val tagList: List, val defaultSavePath: String, ) + +private data class DirectoryInfo( + val serverId: Int, + val content: List, +)