diff --git a/CHANGELOG.md b/CHANGELOG.md index 267f711..5ed132f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/searchmob_desktop/server/templates.py b/src/searchmob_desktop/server/templates.py index 28f8dfe..0daad77 100644 --- a/src/searchmob_desktop/server/templates.py +++ b/src/searchmob_desktop/server/templates.py @@ -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 ( + '
' + + _search_context_fields(query, sort_mode, vertical) + + '' + + f'' + + "
" + ) + + 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) @@ -819,7 +834,29 @@ def render_results_page( if blank: parts.append(f'

{escape(tr("Enter a query to search."))}

') elif not results_list: - parts.append(f'

{escape(tr("No results for “{query}”.", query=query))}

') + 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('

') + 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("

") + else: + parts.append( + f'

{escape(tr("No results for “{query}”.", query=query))}

' + ) else: parts.append(f'

{escape(tr("Results for “{query}”", query=query))}

') parts.append(_engine_status_line(engine_status)) diff --git a/tests/server/test_empty_scope_recovery.py b/tests/server/test_empty_scope_recovery.py new file mode 100644 index 0000000..f05b8ce --- /dev/null +++ b/tests/server/test_empty_scope_recovery.py @@ -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 & 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