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:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 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
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user