diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eb78f6..9b95efb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,21 @@ upload needs a **higher `versionCode`** than every previous upload. ### 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 `.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 diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/domain/FileSearch.kt b/app/src/main/java/de/softwareapp_hb/filemanager/domain/FileSearch.kt new file mode 100644 index 0000000..2e3c768 --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/domain/FileSearch.kt @@ -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 { + val found = ArrayList() + walk(root, query, showHidden, limit, found, onMatch) + return found + } + + private suspend fun walk( + directory: StoragePath, + query: String, + showHidden: Boolean, + limit: Int, + found: MutableList, + 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 + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt index 23f4d47..dbdf223 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt @@ -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, diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt index d558d4f..f024f14 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt @@ -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 } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt index bbbd0ec..9f0e8e9 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt @@ -119,6 +119,10 @@ data class BrowserUiState( val entries: List = emptyList(), val breadcrumbs: List = 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, diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt index a17c3ce..69f95d1 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt @@ -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 = 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) { diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/domain/FileSearchTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/domain/FileSearchTest.kt new file mode 100644 index 0000000..df6c25c --- /dev/null +++ b/app/src/test/java/de/softwareapp_hb/filemanager/domain/FileSearchTest.kt @@ -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() + + 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()) + } +} diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt index 98b4443..b7029bc 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt @@ -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