From 2a3b78e015e9bb31cce87e6b4b4e21bb06bb1e1a Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Mon, 10 Aug 2026 01:30:19 -0400 Subject: [PATCH] Purge stale contacts: a bar to watch, a dialog to close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- meshterm/ui/contacts_screen.py | 32 +++++++--- tests/test_device_config.py | 106 +++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 8 deletions(-) diff --git a/meshterm/ui/contacts_screen.py b/meshterm/ui/contacts_screen.py index 034bdfa..a11b673 100644 --- a/meshterm/ui/contacts_screen.py +++ b/meshterm/ui/contacts_screen.py @@ -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 diff --git a/tests/test_device_config.py b/tests/test_device_config.py index 9710d14..624687b 100644 --- a/tests/test_device_config.py +++ b/tests/test_device_config.py @@ -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