Files
MeshTerm/meshterm/ui/archived_screen.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00

160 lines
6.9 KiB
Python

"""The Archived contacts list: what the sweep took off the device, and when.
A purge (see :mod:`~meshterm.ui.purge_screen`) removes a contact from the *device* — the
scarce resource — and keeps it here in full. This screen is where those contacts live: the
same sortable, filterable lane layout as the Contacts list, in its own lane set
(``NAME · ARCHIVED · KEY``, :data:`~meshterm.ui.contactlist.ARCHIVED_LANES`), with its own
sort ring over exactly those three columns.
The ``ARCHIVED`` lane is drawn as ``HEARD`` is on the main list — a relative age under
recency heat — because it answers the same shape of question about a different event, and a
second grammar for "how long ago" would have been one for the reader to learn for nothing.
It opens sorted by it, freshest first: the question this screen answers is *what did that
sweep just take?*, and the answer belongs at the top.
**It carries no actions.** Not a select list with the verbs removed — a list with nothing to
commit, which Esc leaves. Everything you might do to an archived contact belongs to that one
contact and lives on its Node detail page, which Enter opens: restoring it to the device, or
deleting it for good. Putting a restore verb on this screen as well would have been a second
route to the same write, and the page is where the reader can actually see which node they
are about to act on.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from .contactlist import (
ARCHIVED_LANES,
ARCHIVED_SORT_COLUMNS,
ARCHIVED_SORT_OPENS_ASCENDING,
ContactListScreen,
ContactRow,
)
from .tui.screen import CANCEL
from .widgets import ContactsSort
if TYPE_CHECKING:
from ..context import AppContext
from ..core.contact_store import RememberedContact
#: The list's footer: navigation, the sort, filtering, then Esc last. ``Enter open`` because
#: a row pushes the node's detail page — the same verb the Contacts list uses for the same
#: gesture.
_HINT = "↑↓ move · ^←→↑↓ sort · type to filter · Enter open · Esc back"
def _archived_time(stamp: int | None) -> datetime | None:
"""A stored archive stamp as an aware UTC datetime, or ``None`` if it carries none.
Stored as unix seconds (see :class:`~meshterm.core.contact_store.RememberedContact`) and
rendered as a local-time age by the lane, so it crosses into the list's own currency
here rather than in three places downstream.
"""
if not stamp:
return None
try:
return datetime.fromtimestamp(int(stamp), tz=timezone.utc)
except (OverflowError, OSError, ValueError):
return None # a corrupt stamp reads "never", not a crash
def archived_rows(archived: list[RememberedContact]) -> list[ContactRow]:
"""Turn remembered contacts into the shared list's row data.
Args:
archived: The device's archived contacts, from
:meth:`~meshterm.core.contact_store.ContactStore.archived`.
Returns:
One :class:`~meshterm.ui.contactlist.ContactRow` each, carrying the rebuilt
:class:`~meshterm.core.models.Contact` as its value so Enter hands the whole record
to the detail page with no re-lookup.
"""
return [
ContactRow(
value=remembered.to_contact(),
name=remembered.name,
key=remembered.public_key,
node_type=remembered.node_type,
archived_at=_archived_time(remembered.archived_at),
)
for remembered in archived
]
class ArchivedScreen(ContactListScreen):
"""A full-screen, sortable list of the contacts a sweep took off this device."""
def __init__(self, rows: list[ContactRow], prefix_bytes: int, sort: ContactsSort) -> None:
"""Build the list over already-resolved rows.
Args:
rows: The archived contacts' lane data (see :func:`archived_rows`).
prefix_bytes: Path-hash width in bytes to highlight in every key.
sort: The sort, mutated in place by the Ctrl+arrows. Its ring should span
:data:`~meshterm.ui.contactlist.ARCHIVED_SORT_COLUMNS`.
"""
super().__init__(
f"Archived contacts · {len(rows)} kept",
rows=rows,
prefix_bytes=prefix_bytes,
sort=sort,
prompt=("Off the device, kept here with their history. Open one to restore it."),
footer_hint=_HINT,
lanes=ARCHIVED_LANES,
)
async def open_archived(ctx: AppContext, self_key: str, prefix_bytes: int) -> bool:
"""Open the archived list; Enter opens Node detail, Esc leaves. ``True`` if anything changed.
The list stays pushed for the whole visit, so a detail page nests above it and Esc from
there lands back on the row it was opened from — same sort, same filter, same scroll.
A page that *restored* or *deleted* the contact it detailed ends the visit and the list
is rebuilt without the row, which is the same deliberate exception the Contacts list
makes: the row it was holding a place in no longer exists.
Args:
ctx: Shared application context (interactive menu).
self_key: The device's own public key (hex) — how the store scopes this device's
remembered contacts.
prefix_bytes: Path-hash width in bytes to highlight in every key.
Returns:
``True`` if any contact was restored or deleted, so the caller's own contact list
knows to re-read.
"""
from .node_detail_screen import open_node_detail
from .surface import TuiUi
assert isinstance(ctx.ui, TuiUi) # guaranteed by the Contacts screen
session = ctx.ui.session
store = ctx.contact_store
dev_pub = (self_key or "").lower().removeprefix("0x")
sort = ContactsSort.from_name("archived", ARCHIVED_SORT_COLUMNS, ARCHIVED_SORT_OPENS_ASCENDING)
changed = False
while True:
rows = archived_rows(store.archived(dev_pub) if store and dev_pub else [])
if not rows:
# Everything was restored or deleted while the reader was in here; there is no
# list left to show, and an empty screen they would have to Esc out of says
# less than the note does on the way back.
ctx.ui.note("[muted]no archived contacts[/muted]")
return changed
screen = ArchivedScreen(rows, prefix_bytes, sort)
rebuild = False
async with session.stay(screen) as visit:
while True:
chosen = await visit.result()
if chosen is CANCEL or chosen is None: # Esc
return changed
# The page manages the contact it details — restore it, or delete it for
# good — and says so on the way out, at which point this list is stale.
if await open_node_detail(ctx, chosen):
changed = rebuild = True
break
if not rebuild:
return changed