Files
MeshTerm/meshterm/ui/archived_screen.py
T
Jean-Pierre MartineauandClaude Opus 5 424c33ac93 The purge is an edit, and archiving is not a deletion
JP's pass over the sweep, and one cut corner it found: the gallery specimen
hand-stamped its percentiles, so three contacts rendered as the 2nd, 7th and
11th of a field of three. The number cannot be written down and still be true
— it is a rank against the others in the same list — so the specimen now
scores through rank_contacts like everything else and reads 10/30/50. The
computation was never wrong; the picture of it was.

The ladder answers in the number the reader is choosing: how many contacts
you *keep*, the one that has to fit the device, with what goes as the muted
remainder. It also stops rounding with round(), whose banker's habit sent 4.5
and 3.75 to the same 4 and made "keep 90%" and "keep 75%" identical rungs on
a five-contact table; ceil is both distinct and the safe direction on a keep.

The preview is editable. Delete lifts one contact out of the sweep and the
list refreshes through replace_items, so sparing the one node you recognised
no longer means backing out to a shallower rung that also spares forty you
didn't care about. Rows h-scroll with the percentile lane pinned out of the
shift — it is your place in a list ordered by it. Neither the scroll atom nor
the keep atom is written into the base hint; the select list splices each in
where its key would act, which is also what keeps all three inside 72 cells.
It is a hub now, held by session.stay: re-running run_screen on a screen that
had already resolved spun forever the first time a row was spared.

Opening a candidate's page from in there offered to delete it, which was two
answers to one question. open_node_detail takes manage=False: the rule is
that management verbs belong to the list a contact lives in, never to a page
opened out of a list about to act on it wholesale.

The confirm is amber and button-only. Two caution tiers exist for two costs,
and a red typed gate here spent the app's loudest warning on its most
reversible bulk action. Red is left to the deletion, which now says what it
is — the key forgotten by both sides, no restore, only being heard again —
and points at the archive as the reversible way to free the same slot. The
page carries all three verbs: archive a live contact, restore an archived
one, delete either for good.

Archived contacts get their own screen rather than a section nailed to the
foot of the Contacts list, reached by a row under the purge carrying the
tally. That needed the shared contact list to stop believing every list has
the same columns: the middle lanes are now a declared tuple of ContactLane
(NAME and KEY stay fixtures), read by the header, the row builder, the sort
metric and the width arithmetic alike, replacing a show_traced flag that had
already made three of those four say "and if it's the trace picker…". The
archived list declares NAME · ARCHIVED · KEY and sorts on exactly those.
ARCHIVED draws as HEARD does — a relative age under recency heat — because
it is the same question about a different event.

One simplification falls out: TRACED sorted on a raw timestamp and opened
descending to mean newest-first, alone among the age columns. Every age lane
now sorts by age with never at +inf, so traced opens ascending like heard,
and reads the same either way.

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

164 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, Optional
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: Optional[int]) -> Optional[datetime]:
"""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