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 @@ -12,6 +12,10 @@ All notable changes to SearchMob Desktop are documented here. The version scheme
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.05 — 2026-06-10

Expand Down
39 changes: 38 additions & 1 deletion src/searchmob_desktop/server/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,21 @@ def _scope_bar(rules: RankingRules, query: str, sort_mode: str, vertical: str) -
)


def _clear_scope_link(query: str, sort_mode: str, vertical: str) -> str:
"""A one-click 'Clear scope' control: POST /scope with an empty lens, carrying the search ctx.

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.
"""
return (
'<form class="clearscope" action="/scope" method="post" style="display:inline">'
+ _search_context_fields(query, sort_mode, vertical)
+ '<input type="hidden" name="lens" value="">'
+ f'<button type="submit">{escape(tr("Clear scope"))}</button>'
+ "</form>"
)


def _rank_controls(url: str, rules: RankingRules, query: str, sort_mode: str, vertical: str) -> str:
"""Per-result domain controls (block / lower / raise / pin / reset) as a single POST form."""
domain = host_of_url(url)
Expand Down Expand Up @@ -819,7 +834,29 @@ def render_results_page(
if blank:
parts.append(f'<p class="empty">{escape(tr("Enter a query to search."))}</p>')
elif not results_list:
parts.append(f'<p class="empty">{escape(tr("No results for “{query}”.", query=query))}</p>')
active_lens = active_rules.active_lens
if editable and active_lens:
# An active scope filtered every result out. Without this the page looked empty with no
# hint why and no way back: the scope bar only rendered when there WERE results, so the
# owner could neither see nor clear the scope that hid them. Show both here.
parts.append(_scope_bar(active_rules, query, sort_mode, vertical))
parts.append('<p class="empty">')
parts.append(
escape(
tr(
"No results match the “{scope}” scope for “{query}”.",
scope=active_lens,
query=query,
)
)
)
parts.append(" ")
parts.append(_clear_scope_link(query, sort_mode, vertical))
parts.append("</p>")
else:
parts.append(
f'<p class="empty">{escape(tr("No results for “{query}”.", query=query))}</p>'
)
else:
parts.append(f'<p class="meta">{escape(tr("Results for “{query}”", query=query))}</p>')
parts.append(_engine_status_line(engine_status))
Expand Down
43 changes: 43 additions & 0 deletions tests/server/test_empty_scope_recovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Served results page: when an active scope filters every result out, the owner must still see the
scope (so they know what hid the results) and a one-click way to clear it. Without this the page
looked like a blank fresh search and the active scope was neither visible nor clearable from it.
"""

from __future__ import annotations

from searchmob_desktop.engines.rank import Lens, RankingRules
from searchmob_desktop.server.templates import render_results_page


def _is_safe(_url: str) -> bool:
return True


_RULES = RankingRules(lenses=(Lens(name="Recipes & cooking"),), active_lens="Recipes & cooking")


def test_empty_results_under_active_scope_show_scope_and_clear_for_owner() -> None:
html = render_results_page("threejs", [], _is_safe, rules=_RULES, editable=True)
# The scope bar is rendered so the owner can switch scopes...
assert 'class="scopebar"' in html
# ...the emptiness is attributed to the scope, not the query...
assert "No results match the" in html
assert "Recipes &amp; cooking" in html
# ...and a one-click Clear scope control posts an empty lens to recover the results.
assert 'class="clearscope"' in html
assert 'action="/scope"' in html
assert "Clear scope" in html


def test_empty_results_without_active_scope_keep_the_plain_message() -> None:
html = render_results_page("threejs", [], _is_safe, rules=RankingRules(), editable=True)
assert "No results for" in html
assert 'class="clearscope"' not in html


def test_empty_results_for_a_network_visitor_get_no_scope_controls() -> None:
# A non-owner page is read-only: no scope bar or clear control even with an active scope.
html = render_results_page("threejs", [], _is_safe, rules=_RULES, editable=False)
assert 'class="clearscope"' not in html
assert 'class="scopebar"' not in html
assert "No results for" in html