Search the whole folder tree, not just the folder being browsed

The query box filtered the current listing, which only ever answered is-it-here; the usual question is where-did-I-put-it. domain/FileSearch walks down from the current folder and streams matches as they are found, since a whole-volume walk takes tens of seconds and a blank screen for that long reads as a hang. Hits show where they were found, and the app bar shows progress while the walk runs.

Bounds, all deliberate: a matching folder is still descended into; a hidden folder is skipped whole rather than merely filtered, so the preference cannot be leaked around; the walk stops at 200 matches; each keystroke cancels the previous walk instead of queueing; and navigating away abandons the search.
This commit is contained in:
2026-10-15 09:09:40 +02:00
parent 02780c97e5
commit ffed22c1da
8 changed files with 339 additions and 8 deletions
@@ -0,0 +1,79 @@
package de.softwareapp_hb.filemanager.domain
import de.softwareapp_hb.filemanager.core.FileEntry
import de.softwareapp_hb.filemanager.core.NameMatcher
import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.data.StorageSource
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
/**
* Recursive name search.
*
* This is what makes search useful on a real storage tree. An in-folder filter can only
* answer "is it here?", and the usual question is "where did I put it?" — so the walk goes
* *down* from where the user is and reports what it finds.
*
* Three properties worth stating:
*
* - **Results stream.** [onMatch] is called the moment something matches, because walking a
* phone's whole internal storage takes tens of seconds and a blank screen for that long
* reads as a hang. The caller appends and re-sorts.
* - **The walk stops at [limit].** A search that returns ten thousand rows is not more
* useful than one that returns two hundred, and it costs the user battery to produce.
* - **A matching folder is still descended into**, so a folder named `holiday` does not hide
* the files inside it that also match.
*/
class FileSearch(private val source: StorageSource) {
/**
* Every entry at or below [root] whose name matches [query], depth-first.
*
* [showHidden] is passed in rather than read from anywhere, so the search honours the
* same preference the listing does.
*/
suspend fun find(
root: StoragePath,
query: String,
showHidden: Boolean,
limit: Int = DEFAULT_LIMIT,
onMatch: suspend (FileEntry) -> Unit = {},
): List<FileEntry> {
val found = ArrayList<FileEntry>()
walk(root, query, showHidden, limit, found, onMatch)
return found
}
private suspend fun walk(
directory: StoragePath,
query: String,
showHidden: Boolean,
limit: Int,
found: MutableList<FileEntry>,
onMatch: suspend (FileEntry) -> Unit,
) {
if (found.size >= limit || !currentCoroutineContext().isActive) return
for (child in source.list(directory)) {
if (found.size >= limit || !currentCoroutineContext().isActive) return
// A hidden folder is skipped whole, exactly as the listing would skip its
// contents — searching into it would leak what the preference is hiding.
if (!showHidden && child.isHidden) continue
if (NameMatcher.matches(child.name, query)) {
found += child
onMatch(child)
}
if (child.isDirectory) {
walk(child.path, query, showHidden, limit, found, onMatch)
}
}
}
companion object {
/** Enough for any real hunt through a folder tree, and bounded on purpose. */
const val DEFAULT_LIMIT = 200
}
}
@@ -96,6 +96,8 @@ fun EntryRow(
entry: FileEntry,
selected: Boolean,
modifier: Modifier = Modifier,
/** Set for a search hit, to say which folder below the search root it was found in. */
location: String? = null,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
@@ -115,7 +117,17 @@ fun EntryRow(
},
leadingContent = { EntryIcon(entry = entry, size = 28.dp) },
headlineContent = { Text(text = entry.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
supportingContent = { Text(text = entry.subtitle(), style = MaterialTheme.typography.bodySmall) },
supportingContent = {
val description = entry.subtitle()
Text(
// Where it is matters more than the size for a search hit, so a hit keeps
// both: "Folder · Documents/2026".
text = if (location == null) description else "$description · $location",
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
trailingContent = if (selected) {
{ Icon(imageVector = Icons.Filled.Check, contentDescription = null) }
} else {
@@ -131,6 +143,8 @@ fun EntryGridCell(
entry: FileEntry,
selected: Boolean,
modifier: Modifier = Modifier,
/** Set for a search hit, to say which folder below the search root it was found in. */
location: String? = null,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
@@ -164,8 +178,9 @@ fun EntryGridCell(
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(4.dp))
val description = entry.subtitle()
Text(
text = entry.subtitle(),
text = if (location == null) description else "$description · $location",
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -140,7 +140,7 @@ fun BrowserContent(
)
}
if (state.loading) {
if (state.loading || state.searching) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter))
}
}
@@ -205,6 +205,7 @@ private fun DirectoryView(
EntryRow(
entry = entry,
selected = entry.isHighlighted(state),
location = entry.searchLocation(state.searchRoot),
onClick = if (wide) highlight else open,
onLongClick = { onLongPressEntry(entry.path) },
onDoubleClick = if (wide) open else null,
@@ -225,6 +226,7 @@ private fun DirectoryView(
EntryGridCell(
entry = entry,
selected = entry.isHighlighted(state),
location = entry.searchLocation(state.searchRoot),
onClick = if (wide) highlight else open,
onLongClick = { onLongPressEntry(entry.path) },
onDoubleClick = if (wide) open else null,
@@ -238,3 +240,17 @@ private fun DirectoryView(
/** Highlighted for either reason a row can be: multi-selected, or driving the detail pane. */
private fun FileEntry.isHighlighted(state: BrowserUiState): Boolean =
path in state.selection || path == state.focus?.entry?.path
/**
* Where a search hit was found, relative to the folder the search started from.
*
* `null` when no search is running, and when the hit sits directly in the folder searched —
* in both cases the row keeps its usual "Folder" or size line, because there is nothing
* extra to say.
*/
private fun FileEntry.searchLocation(searchRoot: StoragePath?): String? {
val root = searchRoot ?: return null
val parent = path.parent ?: return null
if (parent == root) return null
return parent.value.removePrefix(root.value).trimStart('/').ifEmpty { null }
}
@@ -119,6 +119,10 @@ data class BrowserUiState(
val entries: List<FileEntry> = emptyList(),
val breadcrumbs: List<StoragePath> = emptyList(),
val query: String = "",
/** The folder a running recursive search started from; `null` when not searching. */
val searchRoot: StoragePath? = null,
/** `true` while a search is still finding things. */
val searching: Boolean = false,
val sort: SortSpec = SortSpec(),
val showHidden: Boolean = false,
val viewMode: ViewMode = ViewMode.LIST,
@@ -16,7 +16,6 @@ import de.softwareapp_hb.filemanager.core.ConflictPolicy
import de.softwareapp_hb.filemanager.core.FileEntry
import de.softwareapp_hb.filemanager.core.FileKind
import de.softwareapp_hb.filemanager.core.NameAllocator
import de.softwareapp_hb.filemanager.core.NameMatcher
import de.softwareapp_hb.filemanager.core.SortDirection
import de.softwareapp_hb.filemanager.core.SortKey
import de.softwareapp_hb.filemanager.core.SortSpec
@@ -28,6 +27,7 @@ import de.softwareapp_hb.filemanager.data.StorageLocation
import de.softwareapp_hb.filemanager.data.StorageSource
import de.softwareapp_hb.filemanager.domain.ArchiveEngine
import de.softwareapp_hb.filemanager.domain.FileOperations
import de.softwareapp_hb.filemanager.domain.FileSearch
import de.softwareapp_hb.filemanager.domain.OperationFailure
import de.softwareapp_hb.filemanager.domain.OperationKind
import de.softwareapp_hb.filemanager.domain.OperationNotifier
@@ -36,6 +36,7 @@ import de.softwareapp_hb.filemanager.domain.Trash
import de.softwareapp_hb.filemanager.domain.UsageAnalysis
import de.softwareapp_hb.filemanager.domain.UsageNode
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -72,6 +73,9 @@ class BrowserViewModel(
/** The unfiltered listing of the current directory; [recompute] derives what is shown. */
private var listing: List<FileEntry> = emptyList()
/** The running recursive search, so the next keystroke can supersede it. */
private var searchJob: Job? = null
init {
refresh()
}
@@ -112,6 +116,7 @@ class BrowserViewModel(
}
private fun loadRoots() {
searchJob?.cancel()
viewModelScope.launch {
_state.update { it.copy(loading = true) }
listing = emptyList()
@@ -122,6 +127,8 @@ class BrowserViewModel(
selection = emptySet(),
focus = null,
query = "",
searchRoot = null,
searching = false,
freeBytes = null,
loading = false,
)
@@ -133,6 +140,8 @@ class BrowserViewModel(
private fun load(path: StoragePath) {
val source = storage.activeSource() ?: return
// Navigating abandons any search: its results belonged to the folder being left.
searchJob?.cancel()
viewModelScope.launch {
_state.update { it.copy(loading = true) }
listing = runCatching { source.list(path) }.getOrDefault(emptyList())
@@ -142,6 +151,9 @@ class BrowserViewModel(
breadcrumbs = breadcrumbsOf(path),
selection = emptySet(),
focus = null,
query = "",
searchRoot = null,
searching = false,
freeBytes = storage.space(path)?.freeBytes,
loading = false,
)
@@ -151,11 +163,16 @@ class BrowserViewModel(
}
}
/**
* Derive what is shown from the unfiltered listing.
*
* The query is deliberately *not* applied here. A query runs a recursive [FileSearch]
* that streams its own matches into [BrowserUiState.entries]; this is only ever the
* "what is in this folder" view.
*/
private fun recompute() {
val current = _state.value
val shown = listing
.filter { current.showHidden || !it.isHidden }
.filter { NameMatcher.matches(it.name, current.query) }
val shown = listing.filter { current.showHidden || !it.isHidden }
_state.update { it.copy(entries = current.sort.sort(shown)) }
}
@@ -243,9 +260,39 @@ class BrowserViewModel(
// ------------------------------------------------------- view preferences
/**
* Set the search query.
*
* A non-blank query searches recursively from the current folder, so "holiday" finds
* the file three levels down rather than only one. Anything still running is cancelled
* first, which makes typing what it should be: each keystroke supersedes the last
* instead of queueing behind it. An empty query means "show me this folder again".
*/
fun setQuery(query: String) {
searchJob?.cancel()
_state.update { it.copy(query = query) }
recompute()
val root = _state.value.path
val source = storage.activeSource()
if (query.isBlank() || root == null || source == null) {
_state.update { it.copy(searchRoot = null, searching = false) }
recompute()
return
}
_state.update { it.copy(searchRoot = root, searching = true, entries = emptyList()) }
searchJob = viewModelScope.launch {
FileSearch(source).find(root, query, _state.value.showHidden) { match ->
_state.update { current ->
current.copy(entries = current.sort.sort(current.entries + match))
}
}
_state.update { current ->
// A newer search may have taken over while this one was finishing.
if (current.query != query) current else current.copy(searching = false)
}
}
}
fun setSort(key: SortKey) {