Purge stale contacts: a bar to watch, a dialog to close

The sweep is one companion command per victim over a link that can be slow,
so its end is a count away — but it ran under the busy overlay, which only
ever says "working" and is drawn only in the gaps *between* screens. Over the
pushed contacts list it drew nothing at all: a hundred-contact purge looked
like a screen that had stopped answering, while the arrow keys still moved a
cursor nothing was being done with. And what it removed went to a note the
reader would only meet on their way off the screen.

Now the app's progress dialog counts the sweep down against a real total and
swallows every key while it runs, and the outcome lands in a dialog dismissed
in front of the list it just purged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-08-10 01:30:19 -04:00
1 parent 28bd7e6fd3
commit 2a3b78e015
2 files changed
+130 -8

No files matched your search

+24 -8
View File
@@ -230,6 +230,10 @@ async def _purge_stale(ctx: "AppContext", self_key: str) -> int:
a forgetful bridge doesn't merge it back. Only the *contact* is dropped — each node's
reception history in MeshTerm stays. Runs over the pushed contacts list as its backdrop.
The sweep itself runs under the app's progress dialog — one command per victim, so the
bar has a real total to count down — and its outcome lands in a dialog the reader
dismisses, rather than in a note they would only meet on their way off the screen.
Args:
ctx: Shared application context (interactive menu).
self_key: The device's own public key (hex) — how the contact store scopes this
@@ -295,10 +299,16 @@ async def _purge_stale(ctx: "AppContext", self_key: str) -> int:
removed = 0
failed = 0
# A companion-local command per contact (no LoRa transmission), so a straight sequential
# sweep — not paced radio traffic — under a busy overlay for the slower links.
async with ctx.ui.busy_overlay(
f"Purging {_count_desc(len(victims))}…", title="Purge stale contacts"
):
# sweep — not paced radio traffic. It runs under the app's progress dialog rather than
# the busy overlay (JP, 2026-08-10): the sweep is one command per victim over a link that
# can be slow, so its end is a *count* away and the reader deserves to watch it come
# down. The overlay only ever said "working", and only in the gaps between screens —
# over a pushed list it drew nothing at all, so a hundred-contact purge looked like a
# screen that had simply stopped answering while the arrow keys still moved a cursor
# nothing was being done with. The dialog swallows every key for its whole lifetime, so
# the list underneath holds still until the work is actually finished.
with ctx.ui.progress("Purge stale contacts") as progress:
task = progress.add_task("Purging", total=len(victims))
for contact in victims:
try:
await device.remove_contact(contact)
@@ -306,6 +316,8 @@ async def _purge_stale(ctx: "AppContext", self_key: str) -> int:
failed += 1
ctx.log.debug("contacts: purge failed for %s: %s", contact.name, exc)
continue
finally:
progress.advance(task)
if store is not None and dev_pub and contact.public_key:
store.forget(dev_pub, contact.public_key)
removed += 1
@@ -313,12 +325,16 @@ async def _purge_stale(ctx: "AppContext", self_key: str) -> int:
# device without the swept contacts.
ctx.devstate.invalidate_contacts()
# The outcome lands in a dialog the reader dismisses, not in a note they would only
# meet on their way out of the screen: they asked for this sweep and waited on its bar,
# so what it did belongs in front of them while the list they purged is still behind it.
if removed and not failed:
ctx.ui.note(f"[ok]✓[/ok] purged {_count_desc(removed)}")
outcome = Text(f"✓ purged {_count_desc(removed)}", style="ok")
elif removed:
ctx.ui.note(
f"[warn]purged {_count_desc(removed)}; {failed} could not be removed[/warn]"
outcome = Text(
f"purged {_count_desc(removed)}; {failed} could not be removed", style="warn"
)
else:
ctx.ui.note("[err]no contacts could be removed[/err]")
outcome = Text("no contacts could be removed", style="err")
await session.message_dialog(outcome, title="Purge stale contacts")
return removed
+106
View File
@@ -341,6 +341,112 @@ def test_purge_buckets_split_stale_from_never_heard() -> None:
assert [c.name for c in never_heard(contacts)] == ["Unheard"]
async def test_purge_sweeps_under_a_progress_bar_and_reports_in_a_dialog() -> None:
"""The purge counts down a real bar, then hands its outcome back in a dialog.
Two things the busy overlay could not do (JP, 2026-08-10). It only ever said "working",
and only in the gaps *between* screens — over the pushed contacts list it drew nothing
at all, so a long sweep looked like a screen that had stopped answering while the arrow
keys still moved a cursor nothing was being done with. And the count of what it removed
went to a note the reader would only meet on their way off the screen. So: the app's
progress dialog (which swallows every key for its lifetime) while it runs, and a
dismissible dialog naming the count when it is done.
"""
import asyncio
import logging
from types import SimpleNamespace
from meshterm.core.models import Contact, utcnow
from meshterm.ui.contacts_screen import _purge_stale
from meshterm.ui.surface import TuiUi
from meshterm.ui.tui.progress import ProgressScreen
from meshterm.ui.tui.session import TuiSession
from datetime import timedelta
old = utcnow() - timedelta(days=400)
victims = [
Contact(name=f"Old{i}", public_key=f"{i:02x}" * 32, key_prefix=f"{i:02x}" * 6,
last_seen=old)
for i in range(3)
]
removed_from_device: list[str] = []
class _Device:
async def remove_contact(self, contact) -> None: # noqa: ANN001
await asyncio.sleep(0) # a real companion command takes a turn of the loop
removed_from_device.append(contact.name)
session = TuiSession()
ui = TuiUi(session)
ui.typed_confirm = lambda *a, **k: _true() # the red gate is its own tested thing
ctx = SimpleNamespace(
ui=ui,
log=logging.getLogger("test.purge"),
contact_store=None,
devstate=SimpleNamespace(
contacts=lambda: _contacts(victims), invalidate_contacts=lambda: None
),
device=lambda: _device(_Device()),
)
task = asyncio.ensure_future(_purge_stale(ctx, "cc" * 32))
# The age ladder: Enter on its first rung (a week) sweeps every one of these.
ladder = await _step_until_screen(session, lambda s: s.title.startswith("Purge stale"))
ladder.handle("enter")
# The sweep now runs under the progress dialog, which is the frontmost screen for its
# whole lifetime — so the list underneath cannot be walked while it works.
bar = await _step_until_screen(session, lambda s: isinstance(s, ProgressScreen))
assert bar.title == "Purge stale contacts"
assert bar.render_body(60) # a real bar, with a real total to count down
bar.handle("down") # every key is swallowed; nothing underneath moves
# …and the count lands in a dialog, not in a note read on the way out.
done = await _step_until_screen(
session, lambda s: not isinstance(s, ProgressScreen) and "purged" in _screen_text(s)
)
assert "purged 3 contacts" in _screen_text(done)
done.handle("escape")
assert await task == 3
assert removed_from_device == ["Old0", "Old1", "Old2"]
async def _true() -> bool:
return True
async def _contacts(rows): # noqa: ANN001
return list(rows)
async def _device(device): # noqa: ANN001
return device
def _screen_text(screen) -> str: # noqa: ANN001
"""Everything a pushed screen says, as one plain string."""
import re
body = screen.render_body(60)
lines = body if isinstance(body, list) else list(body)
drawn = [line() if callable(line) else line for line in lines]
return re.sub(r"\[[0-9;]*m", "", " ".join(drawn) + " " + str(screen.title))
async def _step_until_screen(session, predicate, *, limit: int = 500): # noqa: ANN001
"""Yield to the loop until the frontmost screen satisfies ``predicate``, then return it."""
import asyncio
for _ in range(limit):
top = session.top
if top is not None and predicate(top):
return top
await asyncio.sleep(0)
raise AssertionError("the expected screen never reached the top of the stack")
def test_contacts_screen_tail_offers_purge_only_when_populated() -> None:
"""A populated list closes with the purge action + Back; an empty one has no tail action."""
from meshterm.core.models import Contact