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
+15
View File
@@ -38,6 +38,21 @@ upload needs a **higher `versionCode`** than every previous upload.
### Added ### Added
- **Recursive search.** The query box used to filter the folder you were standing in, which
can only ever answer "is it here?" — and the usual question is "where did I put it?". It
now walks *down* from the current folder (`domain/FileSearch`) and streams matches as they
are found, because walking a whole volume takes tens of seconds and a blank screen for
that long reads as a hang. Rows say where the hit was found (`Folder · Documents/2026`) and
the app bar shows progress while the walk is still going.
Three deliberate bounds: a matching folder is still descended into, so a folder called
`holiday` cannot hide the matching files inside it; a hidden folder is skipped *whole*
rather than merely filtered out, so the preference cannot be leaked around; and the walk
stops at 200 matches, because a search returning ten thousand rows is not more useful than
one returning two hundred. Each keystroke cancels the walk in progress instead of queueing
behind it, and navigating away abandons the search — its results belonged to the folder
being left.
- **Undo for delete, rename and move.** A delete is now a *move* into a hidden - **Undo for delete, rename and move.** A delete is now a *move* into a hidden
`.plainfiles-trash` folder on the same volume rather than a removal, so the snackbar can `.plainfiles-trash` folder on the same volume rather than a removal, so the snackbar can
offer to put it back; a rename and a pasted cut record how to reverse themselves. Four offer to put it back; a rename and a pasted cut record how to reverse themselves. Four
@@ -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, entry: FileEntry,
selected: Boolean, selected: Boolean,
modifier: Modifier = Modifier, 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, onClick: () -> Unit,
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null,
@@ -115,7 +117,17 @@ fun EntryRow(
}, },
leadingContent = { EntryIcon(entry = entry, size = 28.dp) }, leadingContent = { EntryIcon(entry = entry, size = 28.dp) },
headlineContent = { Text(text = entry.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, 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) { trailingContent = if (selected) {
{ Icon(imageVector = Icons.Filled.Check, contentDescription = null) } { Icon(imageVector = Icons.Filled.Check, contentDescription = null) }
} else { } else {
@@ -131,6 +143,8 @@ fun EntryGridCell(
entry: FileEntry, entry: FileEntry,
selected: Boolean, selected: Boolean,
modifier: Modifier = Modifier, 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, onClick: () -> Unit,
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null,
@@ -164,8 +178,9 @@ fun EntryGridCell(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
val description = entry.subtitle()
Text( Text(
text = entry.subtitle(), text = if (location == null) description else "$description · $location",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -140,7 +140,7 @@ fun BrowserContent(
) )
} }
if (state.loading) { if (state.loading || state.searching) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter)) LinearProgressIndicator(modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter))
} }
} }
@@ -205,6 +205,7 @@ private fun DirectoryView(
EntryRow( EntryRow(
entry = entry, entry = entry,
selected = entry.isHighlighted(state), selected = entry.isHighlighted(state),
location = entry.searchLocation(state.searchRoot),
onClick = if (wide) highlight else open, onClick = if (wide) highlight else open,
onLongClick = { onLongPressEntry(entry.path) }, onLongClick = { onLongPressEntry(entry.path) },
onDoubleClick = if (wide) open else null, onDoubleClick = if (wide) open else null,
@@ -225,6 +226,7 @@ private fun DirectoryView(
EntryGridCell( EntryGridCell(
entry = entry, entry = entry,
selected = entry.isHighlighted(state), selected = entry.isHighlighted(state),
location = entry.searchLocation(state.searchRoot),
onClick = if (wide) highlight else open, onClick = if (wide) highlight else open,
onLongClick = { onLongPressEntry(entry.path) }, onLongClick = { onLongPressEntry(entry.path) },
onDoubleClick = if (wide) open else null, 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. */ /** Highlighted for either reason a row can be: multi-selected, or driving the detail pane. */
private fun FileEntry.isHighlighted(state: BrowserUiState): Boolean = private fun FileEntry.isHighlighted(state: BrowserUiState): Boolean =
path in state.selection || path == state.focus?.entry?.path 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 entries: List<FileEntry> = emptyList(),
val breadcrumbs: List<StoragePath> = emptyList(), val breadcrumbs: List<StoragePath> = emptyList(),
val query: String = "", 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 sort: SortSpec = SortSpec(),
val showHidden: Boolean = false, val showHidden: Boolean = false,
val viewMode: ViewMode = ViewMode.LIST, 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.FileEntry
import de.softwareapp_hb.filemanager.core.FileKind import de.softwareapp_hb.filemanager.core.FileKind
import de.softwareapp_hb.filemanager.core.NameAllocator 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.SortDirection
import de.softwareapp_hb.filemanager.core.SortKey import de.softwareapp_hb.filemanager.core.SortKey
import de.softwareapp_hb.filemanager.core.SortSpec 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.data.StorageSource
import de.softwareapp_hb.filemanager.domain.ArchiveEngine import de.softwareapp_hb.filemanager.domain.ArchiveEngine
import de.softwareapp_hb.filemanager.domain.FileOperations 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.OperationFailure
import de.softwareapp_hb.filemanager.domain.OperationKind import de.softwareapp_hb.filemanager.domain.OperationKind
import de.softwareapp_hb.filemanager.domain.OperationNotifier 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.UsageAnalysis
import de.softwareapp_hb.filemanager.domain.UsageNode import de.softwareapp_hb.filemanager.domain.UsageNode
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -72,6 +73,9 @@ class BrowserViewModel(
/** The unfiltered listing of the current directory; [recompute] derives what is shown. */ /** The unfiltered listing of the current directory; [recompute] derives what is shown. */
private var listing: List<FileEntry> = emptyList() private var listing: List<FileEntry> = emptyList()
/** The running recursive search, so the next keystroke can supersede it. */
private var searchJob: Job? = null
init { init {
refresh() refresh()
} }
@@ -112,6 +116,7 @@ class BrowserViewModel(
} }
private fun loadRoots() { private fun loadRoots() {
searchJob?.cancel()
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(loading = true) } _state.update { it.copy(loading = true) }
listing = emptyList() listing = emptyList()
@@ -122,6 +127,8 @@ class BrowserViewModel(
selection = emptySet(), selection = emptySet(),
focus = null, focus = null,
query = "", query = "",
searchRoot = null,
searching = false,
freeBytes = null, freeBytes = null,
loading = false, loading = false,
) )
@@ -133,6 +140,8 @@ class BrowserViewModel(
private fun load(path: StoragePath) { private fun load(path: StoragePath) {
val source = storage.activeSource() ?: return val source = storage.activeSource() ?: return
// Navigating abandons any search: its results belonged to the folder being left.
searchJob?.cancel()
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(loading = true) } _state.update { it.copy(loading = true) }
listing = runCatching { source.list(path) }.getOrDefault(emptyList()) listing = runCatching { source.list(path) }.getOrDefault(emptyList())
@@ -142,6 +151,9 @@ class BrowserViewModel(
breadcrumbs = breadcrumbsOf(path), breadcrumbs = breadcrumbsOf(path),
selection = emptySet(), selection = emptySet(),
focus = null, focus = null,
query = "",
searchRoot = null,
searching = false,
freeBytes = storage.space(path)?.freeBytes, freeBytes = storage.space(path)?.freeBytes,
loading = false, 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() { private fun recompute() {
val current = _state.value val current = _state.value
val shown = listing val shown = listing.filter { current.showHidden || !it.isHidden }
.filter { current.showHidden || !it.isHidden }
.filter { NameMatcher.matches(it.name, current.query) }
_state.update { it.copy(entries = current.sort.sort(shown)) } _state.update { it.copy(entries = current.sort.sort(shown)) }
} }
@@ -243,9 +260,39 @@ class BrowserViewModel(
// ------------------------------------------------------- view preferences // ------------------------------------------------------- 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) { fun setQuery(query: String) {
searchJob?.cancel()
_state.update { it.copy(query = query) } _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) { fun setSort(key: SortKey) {
@@ -0,0 +1,98 @@
package de.softwareapp_hb.filemanager.domain
import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.domain.testutil.InMemoryStorageSource
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The point of a recursive search is that it finds things the user cannot see from where
* they are, so most of these tests are about *reaching* — depth, hidden folders, and the
* limit that keeps a whole-volume walk from running forever.
*/
class FileSearchTest {
private val root = StoragePath.of("/storage/emulated/0")
private val docs = root.child("Documents")
private val deep = docs.child("2026")
private val hidden = root.child(".private")
private fun storage() = InMemoryStorageSource().apply {
addDirectory(root)
addDirectory(docs)
addDirectory(deep)
addDirectory(hidden)
addFile(root.child("notes.txt"), "top")
addFile(docs.child("holiday-notes.txt"), "one level down")
addFile(deep.child("holiday-2026.txt"), "two levels down")
addFile(hidden.child("holiday-secret.txt"), "hidden")
}
@Test
fun `finds a match however deep it is`() = runBlocking {
val found = FileSearch(storage()).find(root, "holiday", showHidden = false)
assertEquals(
setOf("holiday-notes.txt", "holiday-2026.txt"),
found.map { it.name }.toSet(),
)
}
@Test
fun `a hidden folder is skipped whole, not just hidden from the results`() = runBlocking {
val found = FileSearch(storage()).find(root, "holiday", showHidden = false)
assertFalse(found.any { it.name == "holiday-secret.txt" })
}
@Test
fun `hidden entries are searched when the preference asks for them`() = runBlocking {
val found = FileSearch(storage()).find(root, "holiday", showHidden = true)
assertTrue(found.any { it.name == "holiday-secret.txt" })
}
@Test
fun `a matching folder is still descended into`() = runBlocking {
// "Documents" itself matches, and so does a file inside it — finding the folder must
// not stop the walk.
val found = FileSearch(storage()).find(root, "holiday", showHidden = false)
assertTrue(found.any { it.isDirectory.not() && it.name == "holiday-notes.txt" })
}
@Test
fun `matches stream out as they are found`() = runBlocking {
val seen = mutableListOf<String>()
val found = FileSearch(storage()).find(root, "holiday", showHidden = false) { seen += it.name }
assertEquals(found.map { it.name }, seen)
assertEquals(2, seen.size)
}
@Test
fun `the walk stops at the limit`() = runBlocking {
val storage = storage()
repeat(30) { index -> storage.addFile(root.child("holiday-$index.txt"), "x") }
val found = FileSearch(storage).find(root, "holiday", showHidden = false, limit = 5)
assertEquals(5, found.size)
}
@Test
fun `globs work as they do everywhere else`() = runBlocking {
val found = FileSearch(storage()).find(root, "holiday-2026*", showHidden = false)
assertEquals(listOf("holiday-2026.txt"), found.map { it.name })
}
@Test
fun `a query that matches nothing finds nothing`() = runBlocking {
assertTrue(FileSearch(storage()).find(root, "no-such-thing", showHidden = false).isEmpty())
}
}
@@ -441,6 +441,63 @@ class BrowserViewModelTest {
assertTrue(viewModel(savedState = saved).state.value.isRootView) assertTrue(viewModel(savedState = saved).state.value.isRootView)
} }
// ------------------------------------------------------------------ search
@Test
fun `search reaches into a subfolder, not just the one being browsed`() {
val model = viewModel()
model.openDirectory(root)
model.setQuery("a.txt")
// The file lives in Download, which is not where the user is standing.
assertEquals(listOf("a.txt"), model.state.value.entries.map { it.name })
assertEquals(root, model.state.value.searchRoot)
assertFalse(model.state.value.searching)
}
@Test
fun `clearing the query brings the folder back`() {
val model = viewModel()
model.openDirectory(root)
model.setQuery("a.txt")
assertEquals(listOf("a.txt"), model.state.value.entries.map { it.name })
model.setQuery("")
assertEquals(listOf("Documents", "Download", "top.txt"), model.state.value.entries.map { it.name })
assertNull(model.state.value.searchRoot)
}
@Test
fun `navigating abandons a search, because its results belonged to the old folder`() {
val model = viewModel()
model.openDirectory(root)
model.setQuery("a.txt")
model.openDirectory(downloads)
assertEquals("", model.state.value.query)
assertNull(model.state.value.searchRoot)
assertEquals(listOf("a.txt", "b.txt"), model.state.value.entries.map { it.name })
}
@Test
fun `the search honours the hidden-files preference`() {
val model = viewModel()
model.openDirectory(downloads)
model.setQuery("hidden")
assertTrue("hidden files are off by default", model.state.value.entries.isEmpty())
model.setQuery("")
model.toggleShowHidden()
model.setQuery("hidden")
assertEquals(listOf(".hidden"), model.state.value.entries.map { it.name })
}
// -------------------------------------------------------------------- undo // -------------------------------------------------------------------- undo
@Test @Test