Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ versioning (`YY.MM.VV`).
bridges those spellings, and when your query names a result's own domain it lifts that result
toward the top, so the official page leads instead of hiding several screens down. It is pure
on-device string work and adds no new requests.
- **A scope that filters out every result no longer looks like a blank page.** When you apply a
scope on the results page in a browser and it matches nothing, the page now tells you the scope
hid the results and offers a one-click "Clear scope" to bring them back, instead of an empty page
with no hint of what happened or how to undo it.

## 26.06.04 — 2026-06-10

Expand Down
40 changes: 39 additions & 1 deletion app/src/main/java/org/searchmob/server/SearchServer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,13 @@ private class ServedText(
val fromWikipedia get() = s(R.string.search_summary_source, "From Wikipedia")
val enterQuery get() = s(R.string.search_idle, "Enter a query to search.")
val noResults get() = s(R.string.search_empty, "No results found.")
val clearScope get() = s(R.string.search_clear_scope, "Clear scope")

fun noResultsForScope(
scope: String,
query: String,
) = s(R.string.search_no_results_scope, "No results match the %1\$s scope for %2\$s.", "“$scope”", "“$query”")

val tagline get() = s(R.string.home_tagline, "Private, battery-friendly, on-device search.")
val updateAction get() = s(R.string.update_banner_action, "Update")

Expand Down Expand Up @@ -872,7 +879,17 @@ private fun HTML.renderResultsPage(
query.isBlank() -> p("empty") { +text.enterQuery }
results.isEmpty() -> {
outcome.didYouMean?.let { didYouMeanLine(text, it) }
p("empty") { +text.noResults }
val activeLens = rules.activeLens
if (editable && activeLens != null) {
// An active scope filtered every result out. The scope bar only rendered when
// there WERE results, so the owner could neither see nor clear the scope that
// hid them and the page looked like a blank fresh search. Show both here.
scopeBar(rules, query, sortMode, vertical)
p("empty") { +text.noResultsForScope(activeLens, query) }
clearScope(text, query, sortMode, vertical)
} else {
p("empty") { +text.noResults }
}
}
else -> {
if (outcome.showingResultsFor != null) {
Expand Down Expand Up @@ -1149,6 +1166,27 @@ private fun FlowContent.scopeBar(
}
}

/**
* A one-click "Clear scope" control: POST /scope with an empty lens, carrying the search context.
*
* Clearing the active lens and redirecting back to the same query re-runs it unfiltered, so the
* owner recovers the results an over-filtering scope hid without retyping anything.
*/
private fun FlowContent.clearScope(
text: ServedText,
query: String,
sortMode: String,
vertical: String,
) {
form(action = "/scope", method = FormMethod.post, classes = "clearscope") {
hiddenInput(name = "q") { value = query }
hiddenInput(name = "sort") { value = sortMode }
hiddenInput(name = "vertical") { value = vertical }
hiddenInput(name = "lens") { value = "" }
button(type = ButtonType.submit) { +text.clearScope }
}
}

/** Per-result domain controls (block / lower / raise / pin / reset) as a single POST form. */
private fun FlowContent.rankControls(
url: String,
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@
<string name="search_idle">Enter a query to search.</string>
<string name="search_loading">Searching…</string>
<string name="search_empty">No results found.</string>
<string name="search_no_results_scope">No results match the %1$s scope for %2$s.</string>
<string name="search_clear_scope">Clear scope</string>
<string name="search_error_title">Search failed</string>
<string name="search_retry">Retry</string>
<string name="search_source_prefix">via</string>
Expand Down
25 changes: 25 additions & 0 deletions app/src/test/java/org/searchmob/server/WebUiPersonalizationTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,31 @@ class WebUiPersonalizationTest {
listOf(SearchResult(title = "A page", url = "https://news.example/x", snippet = "s", engine = "e"))
}

private class EmptyResultProvider : SearchResultProvider {
override suspend fun search(query: String): List<SearchResult> = emptyList()
}

@Test
fun emptyResultsUnderActiveScopeShowScopeBarAndClearControl() {
// Regression: an active scope that hid every result used to leave a blank page with no way to
// see or clear the scope, since the scope bar only rendered when results existed.
val prefs = RankingPreferences(FakeStore())
runBlocking { prefs.save(RankingRules(lenses = listOf(Lens(name = "Docs")), activeLens = "Docs")) }
val server = SearchServer(provider = EmptyResultProvider(), rankingPreferences = prefs)
val port = server.start(freeLoopbackPort())
try {
assertEquals(200, waitForHealthz(port))
val (code, html) = get(port, "/search?q=threejs")
assertEquals(200, code)
assertTrue("scope bar should render on the empty page", html.contains("class=\"scopebar\""))
assertTrue("emptiness should be attributed to the scope", html.contains("No results match the"))
assertTrue("a clear-scope control should be offered", html.contains("class=\"clearscope\""))
assertTrue("clear-scope posts an empty lens to /scope", html.contains("action=\"/scope\""))
} finally {
server.stop()
}
}

private fun waitForHealthz(
port: Int,
attempts: Int = 30,
Expand Down