Comics, music and PDF cards name their cover as a DocumentCover; one Fetcher
reads it through the FFI (MuPDF renders a PDF's first page at the requested
size) and a Keyer caches it under the lib's cover_key, registered app-wide by
a new SynchronicityApplication. The per-screen thumbnail maps and decode loops
are gone, and a cover that changes through sync is no longer held stale for as
long as the screen is open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WsxkM2ja42H2h5BPRexf2r
PdfListItem, ComicTreeItem (with ComicShelf::cover for a series' card) and
music's LibraryEntry carry a cover_key: the hashes of the blobs the cover is
drawn from, set only while they are stored here. A frontend that caches covers
by it gets a new key when a cover changes or arrives through sync, rather than
the old picture. LibraryEntry::has_cover becomes cover_key, and an artist's key
is its portrait's or its mosaic's covers'.
Comics read every first page's key in bulk alongside the availability roll-up,
choosing the first page the way list_comic_pages does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WsxkM2ja42H2h5BPRexf2r
The rule F1's sweep applied across Notes, the player, document detail and the
readers: a buffer carries its record's id, a load into a selection is held
rather than detached, and work that can't be canceled checks a generation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A long store path can't bind its socket, the local API's 2 MB body limit, the
daemon never closing its iroh endpoint, and a canceled 'syn docs import' that
doesn't say so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Restart forked with daemonize's start(), which exits the parent with status 0
the moment the first child does — so a daemon that then failed (a socket path
too long to bind, a node in no ensemble) said so only in daemon.log.
Both daemonizing modes now fork with execute() and wait on a pipe the child
reports through once its node runtime is up: its error if it failed, nothing if
it died. A child whose socket answers but that hasn't reported within five
seconds is still connecting to the network, and counts as started.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The signal handlers were installed only once the server was running, so a
signal during Store::open or NodeRuntime::start took the default action and
left the socket and PID file behind. They are now installed first thing, and
startup is raced against them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The daemon removes its socket on the way out, before the tasks holding the
store have finished, so 'syn ensemble retire' could find the socket gone and
then fail to open a store still locked. A daemon killed outright never removes
it at all, which a restart reported as 'did not exit within 5 seconds'.
wait_for_exit now takes the pid read before the stop was asked for and waits
for that process to be gone, then clears any socket and PID file it left.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rendering syn ensemble members from EnsembleStatus lost the old
snapshot's recourse: on a node in no ensemble it printed a bare "Error:
not part of any ensemble", and so did the retire and renew
confirmations. The daemon's ensemble_status now says
copy::ensemble_roster::NOT_IN_ENSEMBLE, which names what to run — the
sentence syn ensemble status, gossip-status and dht-peers also use,
where before they said it two different ways. The status and members
help no longer claim a certificate check and a gossip-only list that
they stopped doing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The x86_64 emulator was running the arm64 .so through the native bridge,
which returns JNI jbooleans with junk in the register's upper bits. Since
jni-sys 0.4 reads jboolean as a Rust bool, every ExceptionCheck reported a
pending exception, so rustls-platform-verifier could neither initialize
nor verify a certificate on the emulator.
Still one Rust cross-compile per build: -PrustAbi wins, else the ABI
Android Studio injects for the device it deploys to, else arm64-v8a. The
.so is now synced into build/rustJniLibs rather than src/main/jniLibs, so
clean removes it and a previously built ABI can't ride along in the APK.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The cargo update in d370691e took reqwest 0.13.2 -> 0.13.5 and with it
rustls-platform-verifier 0.6 -> 0.7. On Android 0.7 has to be handed the
JavaVM and app Context explicitly, and nothing did, so the first TLS
connection (node init's network setup) failed with "Expect
rustls-platform-verifier to be initialized". initAndroidContext now does
it alongside ndk-context.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
read_chunk decoded every record off the wire, stage_chunk encoded it
again to stage it, and promote_session decoded it a third time. A record
that decodes is already exactly what promotion will read, so it is now
kept as the bytes it arrived in (ReceivedRecord: its id and those
bytes) and staged as is — one full encode pass less per record on every
catch-up. The decode that checks each record, and drops one this build
can't read, is unchanged. (audit-lib-p2p F3, the type-3 item)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PodcastCoverArt and PodcastListThumbnail each decoded stored cover bytes
with BitmapFactory on the main thread, fell back to a Coil painter for
the remote URL, and swapped in a placeholder on error; the search
screen inlined the same shape a third time with no error state at all.
PodcastCover hands Coil the stored bytes or the URL as one model — Coil
2 takes a ByteArray — so the decode is off the main thread, downsampled
and cached, with a placeholder for a missing cover and another for a
failed one. A search result whose cover fails to load now shows the
podcast glyph rather than a blank. (F7, the podcast half)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build_comic_tree_nodes rolled each comic's availability up from its
pages one comic at a time: a page listing, a page-count read and a bulk
availability check per comic, so a library of N comics cost 3N queries.
comic_availabilities does the whole library at once — one read of the
page documents, grouped by comic, and one bulk availability check — and
takes each comic's expected count from the OrderTotal the library tree
already read. The roll-up rule is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop, the daemon and the FFI each had their own ensemble_peer_ids
for a user's pull or push, walking peers in store order with revoked
nodes included — dialling a node whose credential is revoked can only end
in a refusal at admission. scheduler::ensemble_peer_ids is now the one
list, the nodes of ensemble_peers_freshest_first, which already filters
revoked certs and tries the freshest peer first; the three callers keep
only their own error for an empty list, in copy::asset_sync::NO_PEERS's
words.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An artifact-sync response carried every value asked for in one frame, and
a node catching up asks for every value it is missing at once — the one
message with no bound, which is why MAX_FRAME was 256 MiB. A response
past the cap failed, and failed again on every later pass since each
asked for the same set, so that peer's values never arrived; and an
admitted peer could make this node allocate 256 MiB in one go.
Values now travel in pages of about 4 MiB in both directions: the
responder answers a fetch page by page (reading its store one page at a
time, so its memory is bounded too), and a push is sent the same way
with one ack at the end. A single value too large for a frame is left
out rather than answered None, which would record the peer as lacking
it. The client applies each page as it lands, so a fetch cut off by the
per-peer timeout keeps what arrived and the next pass asks only for the
rest. The session's message count changed, so ARTIFACT_SYNC_ALPN is
bumped.
With nothing unbounded left, MAX_FRAME is 64 MiB. Its doc comment lists
each protocol's bound, and blob-sync, blob-stream and artifact-sync
each have a test building their largest legal message and checking it
fits. (F3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
button(theme, id, label, primary, enabled) decided only how a button
looked; the caller attached the handler separately, so a plain
.on_click on a disabled button still fired. on_click_if gated it, but
the same bool was still written twice and nothing stopped a new call
site from forgetting.
Button is now an opaque RenderOnce element built by button(id, label,
ButtonKind), bar_button, icon_button, transport_button and
transport_button_primary. on_click/on_mouse_down and their _if forms are
the only ways to give it a handler, and having one is what enables it:
no handler draws flat and inert, with no tooltip. There is no
Stateful<Div> left to hang a second handler on; the few adjustments
callers made (w, h, self_start, tooltip) are builder methods.
ButtonKind {Primary, Secondary, Danger, Ghost} replaces the primary
bool and the danger_button/ghost_button functions, and the theme comes
from the context rather than an argument. The one control styled as a
button that isn't clicked, import_trigger's menu, builds on
label_button_shell.
Two buttons changed behavior as a result: Podcasts search's Subscribe
took a plain .on_click, so "Subscribed ✓" subscribed again —
subscribe_button now takes the handler and gates it itself; and the
subscriptions screen's placeholder Sort, drawn clickable with nothing
behind it, now draws disabled. Android needed nothing: its hand-rolled
.clickable uses are rows and cards, none drawn disabled. (F2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adding the RSS URL gave PodcastFfi::subscribe eight positional arguments
(clippy's too_many_arguments, which lint-rust reports but does not fail
on). NewFeed is now a uniffi::Record and the one argument, so Android
builds the same struct the desktop does; its last-fetched time crosses
as a Timestamp rather than a string parsed on the Rust side.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An image fetch that finished after the user moved on cleared the newer
image's progress block; a revoke that finished after its dialog was
canceled and another node's opened wrote its outcome into that dialog.
Both now check the item they were about. (F1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
go_to_page renders could land out of order — the toolbar, scrub bar and
outline aren't gated on loading — showing, and saving as the reading
position, a page the user had already left; the page turn is now a held
Task that the next one replaces. refresh_bookmarks applies its list only
if that PDF is still the selected one. (F1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
open_comic was detached and unguarded, and the library stays clickable
while a remote first page arrives — so opening A then B could end with
A in the reader, or with the reader popping open after Back. The open is
now a held Task that opening another comic or closing the reader drops.
(F1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
select_track resolved a track into the shared selected-track mutex in
the background, before checking whether the user had since picked
another — so a slow load of A could leave A's decoder there while the
bar named B, and Play sounded A under B's title. A selection counter is
now checked under that mutex immediately before the write, as the player
does, and the selection's Task is held so a newer one cancels the
stream fallback's network wait too (which also stops a stale load
clearing the newer one's progress block). refresh_library_view's Task is
held for the same reason: rows from a level the user had left put
library_rows out of step with library_path. (F1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every async step on the detail screen — the detail load, the delete-
locally presence check, pulls, pushes, peer overrides, purge, eviction —
wrote its result into whichever detail was open when it finished. Start
the delete-locally check on A, go back and open B, and B's dialog
offered A's eviction plan, which Confirm then executed under B's id; a
slow load of A could seed B's properties table, and B's Edit/Save would
write them to B.
Results are now written through detail_for(id), which returns the open
detail only if it is the document the step was about; per-peer results
find their Placement row by node rather than by an index taken when the
action started, since a placement reload re-sorts the rows. The detail
load is held in Detail rather than detached, so opening another
document cancels it. A purge that finishes closes the detail only if
it is still the purged document's. (F1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
start_audio opened the stream (the slow part) and loaded it into the
shared player in the background, and checked play_seq only afterwards,
in the apply step. Play episode A then B, and if A's stream opened last
it replaced B's in the sink while the bar still said B: the next Play
sounded A, and the ticker reported A's position as B's, which Podcasts
persists into B's listen state. play_seq is now shared with the load and
checked under the audio lock, immediately before the load, so a load
that has been superseded never touches the player. (F1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The A/B autosave race was fixed by one check in the load's apply step,
with about ten more of that shape guarding the rest of the async steps.
Two structural changes make the class impossible instead:
- The editor, its note's id and the sync guard are one OpenNote, held
by ContentState::Loaded rather than beside it. schedule_save and
flush_save take their destination and their text from the same
OpenNote, so a save can only write a note's own buffer into it; the
open-note re-read, the incoming-version actions and Export read it
the same way. close_note is the one place it is dropped, and updates
the rail with it.
- The note load is held in _load_task instead of detached. Selecting
another note, switching notebook or losing the note to a tree reload
replaces or drops it, and gpui cancels it before its result can land,
so the apply step no longer checks the selection.
Also: "Start this note over" reopens the note only if the user is still
on it, rather than pulling back one who moved on while it ran. (F1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop's with_store_state and Android's withStoredEpisodes each
took the Podcast Index page, folded in the stored episodes it was
missing, and re-sorted newest first, and each one's doc comment said it
mirrored the other. That rule is now podcast::feed_episodes over a new
FeedEpisode record (api id, title, description, published, duration,
stream URL, image), which fetch_episodes returns across the FFI and
PodcastFfi::feed_episodes exposes.
Only the list is shared. What the store knows about each episode stays
each frontend's to layer on: the desktop folds it into EpisodeRow (rows
already on screen are kept, so a save in progress stays marked), and
Android keeps its separate storeState map, applied as it arrives while
list changes wait behind the held-episodes strip. Android drops its
PodcastIndexEpisode and PodcastEpisodeItem mappers for one FeedEpisode
mapper; list_episodes and PodcastEpisodeItem leave the FFI, and
PodcastIndexEpisode stops crossing it. (P4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A subscription stored the feed's Podcast Index id in a property named
PodcastFeedRssUrl, and the search result's real RSS URL was dropped. The
id now lives in PodcastIndexFeedId, named for what it holds, and
PodcastFeedRssUrl holds the URL — written at subscribe on desktop and
Android from the search result, and read back as PodcastFeed::rss_url /
PodcastItem.rss_url. Nothing fetches from it yet; it is the publisher's
own address for the feed, which reading feeds without the directory
(docs/rss-reader.md phase 6) would need.
Not converted in place: a store holding feeds from before this change
reads their old id as an RSS URL, and is recreated rather than migrated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CLI had its own EnsembleMembers daemon command, whose snapshot
re-derived by hand what ensemble::screen::build_ensemble_status computes
— the revoked set, per-peer health, this node revoked or left behind by
an epoch — and syn ensemble status re-derived the pkdns link and gossip
topic the same status already carries. Both now read EnsembleStatus, the
snapshot the TUI already gets, and EnsembleMembers, its two snapshot
structs and the daemon's builder go. So do the retire and renew
confirmations' counts and syn ensemble gossip-status's peer list.
What changes in the output: "last seen" is the shared relative wording
rather than a UTC timestamp; with the daemon running the member list
also shows nodes known only from the pkarr registry, marked "not seen
via gossip yet"; and syn ensemble status drops the pkarr z32 line
(it is in the DHT bootstrap link) and the cert-signature line. The
gossip integration test now waits for every member to be seen via gossip
rather than for the count, which registry-only members reach first. (F3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NotebookCmd, NoteCmd and CollectionCmd each carried their own Delete and
Rename, all six answered by the same delete_item/rename_item on the same
tree. They are now DaemonCommand::NotesItem(NotesItemCmd::{Delete,
Rename}); the CLI keeps its three verbs per kind and sends that, and the
TUI drops the ItemKind mapping that existed only to pick a variant.
A daemon-protocol change; no ALPN is involved. (F21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three daemon comments and a protocol doc told the reader to run syn jobs
cancel, which did not exist; the TUI's Jobs screen said it had no cancel
because the protocol had no command for one. The daemon's JobRunner
could always cancel. JobsCmd::Cancel { id } now asks it to (Write, the
reach that starts an import), syn jobs cancel <ID> sends it and says
what happened in copy::job_cancel's words, and c cancels the selected
job on the TUI's Active tab.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
load_image_bytes, load_pdf_bytes and load_comic_page_bytes returned
BlobStoreError::NotFound when the document was here and its bytes were
elsewhere, and five callers matched that arm out to mean "ask a peer".
They now return Option, with None for exactly that (load_blob_if_local),
and the callers lose the match. ComicsFfi::get_cover_bytes is Option too,
so Android stops logging an absent cover as a failure.
The one caller that showed the error was Notes' embedded images, whose
placeholder read "blob not found: blake3:…". It now gets
ImageState::Absent with copy::picture_absent's headline for the
image's availability, drawn as a plain placeholder rather than an
error — absence is not an error. (M3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both Android import paths — a single picked file and a folder import —
read each file whole into a ByteArray and handed it across the FFI, where
Rust wrote it back out to a staging file because the importers take a
path. A large video or PDF was the whole file in the heap, twice.
Kotlin now streams the content into the app's cache, and
ImportBatch::import_file takes that path: StagedFile::adopt moves it
under the name dispatch needs (a rename on one filesystem, a copy and
delete otherwise) and removes it once the import is done.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each pkarr registry entry carried a copy of its node's delegation-cert
signature, which was never verified and could not be: the cert's signed
payload also covers its version and epoch, which the entry doesn't hold,
and the packet is already signed by the ensemble key. It was 64 of every
entry's 105 bytes against pkarr's 1000-byte packet, so the registry held
6 members before evicting the oldest; it now holds 13.
Entries are written at version 2 (version, pubkey, created_at). The
decoder still reads a version 1 entry's identical prefix, so a registry
an older build published keeps bootstrapping until a node republishes
it. The packet-size tests become one parameterized case at the new
ceiling; the eviction test, which re-implemented the loop rather than
calling it, goes with them. (F21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It stored the ensemble pubkey twice — once beside the delegation cert and
once inside it — with nothing keeping the two equal. The field goes and
ensemble_pubkey() reads the cert's, which is the one the signature
covers. A membership saved by an older build still loads: serde ignores
the field it no longer has. (F21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AgentType::Control is exactly the System group — Ensemble, Jobs and
Settings — so each registry row stopped saying so a second time.
AgentGroup::agent_type derives it, and AgentMetadata::agent_type reads
it; the FFI record still carries the field. (A-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The project takes no explicit database migrations — a store from an
older schema is deleted and recreated, not converted — so the one-shot
rewrite of human_job/human_religion/human_pet/human_web_presence rows
into facts goes, with its four tests and the Json/Uuid error variants
only it constructed. Also drops a doc comment left behind when the ADR
0038 migration's test went, which had attached itself to the next test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Kotlin named a job's subject through JobSubjectRef and an artifact through
DocumentAssetRef — FFI-only copies of JobSubject and ContentKey, each with
a TryFrom back. They existed because the real enums had tuple variants,
which uniffi hands Kotlin as v1/v2. Both now have named fields (Peer
{ node }, DocumentToPeer { document_id, node }, Blob { hash }, Artifact
{ id }, …) and derive uniffi::Enum, so the copies and their conversions
go. BlobHash crosses as its "blake3:<hex>" string and a bare Uuid as its
hyphenated one; a malformed one from Kotlin lifts as InvalidInput.
job_match_key now takes the subject a job row is built from, so the
drift-guard test that compared the two spellings has nothing left to
compare. (F4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JobItem.state was a snake_case tag beside an error field only Failed
ever set, and the Jobs screen compared the tag against "running",
"queued", "done" and "failed". JobState is now a uniffi::Enum and
JobItem carries it; Failed(String) becomes Failed { message } so Kotlin
reads .message rather than .v1. JobItem.error goes — Kotlin never read
it, since the failure already reaches it through status_line. (F4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JobItem.kind was a snake_case string, so Android compared it against
string constants it kept itself — a JobKinds object, a private
CONTENT_STREAM_KIND, and two bare "push"es on the Jobs screen. JobKind
is now a uniffi::Enum: JobItem carries it, job_match_key and
job_kind_label take it, and a misspelled kind is a compile error rather
than the InvalidInput that job_match_key used to return for one. (F4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DelegationCert, RegistryMember, PeerNode and RevocationRecord each kept
their own node_id_hex, and two of them a node_short_id that cut the key
with no ellipsis. Each now has one typed accessor — node(), or
revoked_node() on a revocation — and callers say .to_hex() or
.short_id() on what it returns, so a shortened node id goes through
copy::short_id like every other. The NodePubkey(x.node_pubkey) wrappers
at call sites fold into the same accessor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
short_hex cut the key to its first 8 hex characters with no ellipsis,
and its TODO asked for exactly the helper copy::short_id now is. Peer
labels in the sync logs now read like every other shortened id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each drew its own centered line: Documents a bold large "No documents"
beside fill_note's "Loading…" and "Nothing matches.", the PDF side panel
a MICRO-sized row, QR Code a default-sized one. They now share note's
look and keep only their own placement. Humans' empty state stays: it
carries an "Add a person" button, which a note does not.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PDF reader drew its zoom controls bare while the other two readers
labeled them. It now builds the group with components::reader::zoom_group
and keeps its info button inside it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The open note's stored_body/incoming_body pair and its hand-written
broadcast loop (watch_open_note) were the last hand-rolled copy of the
alert-don't-apply shape. Notes now holds the body as
OpenNote { id, body: SyncGuarded<String> }, and check_open_note does
Clippings' check: read the note on the background executor, treat text
equal to the editor as this node's own save, and only then
observe_fetched. It runs from store_watch::follow, so it follows the
store while Notes is on display and catches up on arrival.
Two behaviors change, both accepted in the audit decision:
- Keep mine dismisses the incoming version, so a re-read of that same
version no longer re-alerts. That memory replaces stopping the watch
until the overwrite finished.
- A version that arrives while Notes is off display is announced on
return rather than as it lands, and a newer version replaces a pending
one instead of being missed until the first is resolved.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clicking a rail row whose screen is popped out only raises its window.
App::set_current_view already declined to reload Documents and Jobs in
that case, since a re-read would move what the user is pointing at, but
every screen using store_watch::on_arrival/follow re-read anyway.
on_arrival now holds the one rule: skip when popped out. A popped-out
screen is on display, so watch_on_screen has kept it current.
Documents and Jobs move their arrival reload out of app.rs into
on_arrival, and Clippings drops its own visibility plumbing
(clippings_visible/sync_clippings_watch/hide) for on_arrival plus
watch_on_screen like every other screen. Clippings therefore no longer
re-reads when it is popped out or back in, only on arrival.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The filtered Documents listing (matches plus their ancestor chain) was
assembled twice, in gui-app's visible_filtered_rows and Kotlin's
filteredDocumentTree, and the copies disagreed on one edge: a row that
arrived as both a match and context was drawn once on the desktop and
twice on Android. documents::filtered_tree is now the one assembly, over
(id, parent_id) pairs; the desktop calls it directly and Android through
the filtered_document_tree export. It also no longer repeats an expanded
match's child that the listing already placed. The mirrored test suites
become one rstest table in the lib.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It was built as syn-cli while every message it prints, and the daemon's,
tells the user to run `syn …`. The [[bin]] is now syn (the package stays
syn-cli, so -p syn-cli still selects it), and the justfile recipes, the
integration tests' CARGO_BIN_EXE lookup and the flake's package follow —
nix's default package is now .#syn.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PictureTreeItem and MusicTreeItem were the same record but for music's
subtitle. Both libraries now hand out agents::LibraryTreeItem, built by
TreeNode::into_item; pictures leave the subtitle empty. Video and comics
keep their own item types, which carry more.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
library_tree took the fallback as a parameter because the libraries
disagreed: pictures said Local ("nothing known to be missing"), video,
music and comics said Unavailable. It only arises when a leaf vanishes
between two reads in one transaction, and there claiming the bytes are here
is the one answer that can be wrong, so it is Unavailable everywhere and the
parameter goes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With NativeLib an object, the ~60 nativeLib parameters and the ~100
arguments passing it down — from MainActivity through each screen, its
sections, its repositories and its helpers — carried nothing a call to
NativeLib doesn't. They go, along with the repositories' now-empty
constructors. (F2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every instance was a stateless view of the process-wide node handle held
in its companion, and 15 sites constructed one (most as
remember { NativeLib() }). It is now the singleton it always behaved as.
The JNI binding is unchanged: initAndroidContext is still an instance
method, now on the object's one instance. (F2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SchedulerConstraints re-checked the unmetered-network and charging
constraints the WorkRequest already declares — schedule() rewrites them on
every settings change, and WorkManager stops a worker whose constraints
stop holding — and its doc still said no Android driver ran the scheduler.
The worker now checks only that the scheduler is enabled. (F4i)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each row's ⋮ opened a menu with one item, "Settings", which opened a
read-only dialog of the title, type and created date — what the row itself
and the detail screen already show. (F4h)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings → System opened a screen showing one HashChip of the node's key,
which Settings → Ensemble already shows in its membership grid. The screen,
its route, the button, the System tab's now single-route nav graph, and the
FFI field that carried its explainer go. (F4g)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No Android player turned artwork on, and Visualization had only NONE, so
the capability flags, the Artwork and Visualization composables and
NowPlaying.artworkUri go. ADR 0046 and the design doc note that Android no
longer carries them; the desktop keeps both. (F4f)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NoteTabSidebar offered Info/Tags/Links/History/Attachments tabs that only
highlighted when tapped and had no content behind them, and the desktop has
no such panel. The panel, its header toggle, and the edge swipe that opened
it go; the notebook sidebar is unchanged. (F4e)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both hand-rolled their own scale/offset and transformable, without the
shared viewer's pan clamp or double-tap. ZoomState now takes a per-viewer
zoom range (Pictures keeps 0.5–8x) and a rotation (Pictures' quarter
turns), ZoomableBitmap takes an ImageBitmap, and it only pans once zoomed
in — the rule the comic reader's pager needed, and harmless to the rest,
since an unzoomed image has nowhere to pan.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Four private variants — InfoRow, DocumentSettingsRow, MetadataRow and
FactRow — and Humans' ReadOnlyField each laid out the same line, two of
them as a two-column grid that CLAUDE.md says Android does not use. They
are all LabeledValue now, which carries the stacked-on-a-phone rationale
that lived on ReadOnlyField.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It hand-rolled the Surface, back handler and a back-button row that
SubAppScreen provides. The podcast's title moves into the header (so the
body no longer repeats it), and the info button becomes a header action.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It hand-rolled the backdrop, card and click-swallowing that Modal already
does, with a bespoke ✕. It now uses the dialog pieces every other modal
does and closes with a Close button — copy::action::CLOSE, which Android's
two info dialogs now read too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
They each drew their own title row: Video a bold text_xl clone of the
header, QR Code a title inside its padded body, and podcast detail and
episode a "← Back" button beside a heading in the scrolling column. They
now open with the same header row as every other screen, and their back
buttons read "‹ …" like the rest.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three screens each drew "N new … — Show" for a list a sync is holding
back, in two different looks. components::incoming_banner::held_strip is
the list-level counterpart of incoming_banner, and all three use it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It drew its own column of text over two buttons; incoming_banner is the
strip Notes and the podcast queue already show for the same choice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its tab bar hand-rolled what tabs::{tab_bar, tab_pill} draw, and its
cards/list toggle was two separately rounded squares beside an existing
icon_segmented_bar that does the same job (and gives each option a
tooltip, now from copy::library_layout).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Folding PdfError/ComicError into ContentFetchError (X-6) turned "couldn't
reach a node that has this PDF" into "…this content", right after the
reader had said "Finding a node with this PDF…". The sentence is now
copy::content_fetch::unreachable(subject); the PDF, comic and picture errors
word their wrapped fetch error with their own noun, and the FFI keeps that
wording rather than the inner error's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It always said "Episodes changed elsewhere." while the desktop said "3 new
episodes." Both now read copy::podcast_episodes::held; Android through
podcast_episodes_held.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop and Android each built it, capitalized differently, and both
counted thumbnails, artifact values and whole files together as
"artifact(s)". copy::settings::run_summary counts the three apart, and
says so plainly when nothing arrived; Android reaches it through
scheduler_run_summary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop Documents screens and Android said "Untitled Document", the
TUI "(untitled)", the CLI "(untitled)" or "(no title)", and the lib's
own import and title fallbacks spelled out "Untitled" rather than naming
copy::document_display::UNTITLED. All of them now use the constant
(Android through placeholders().untitledDocument).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop's "Name the protocol first" was not a paraphrase of
CONTACT_NEEDS_PROTOCOL (no protocol picked) but a different condition:
Other picked with its name left empty. It gets its own constant.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
truncate_id/short_id kept the first `keep` characters and the last four
(3fa9c1d2…a1b2), cutting ids from the middle like readable text. Every id
here is random (UUIDv4/v5, node keys, BLAKE3 hashes), so the end is no more
distinguishing than the start, and the prefix is the conventional handle:
3fa9c1d2…. A scheme label such as `blake3:` is kept whole and not counted, so
a blob hash still shows eight characters of the hash itself. Every hash chip
on desktop and Android (via the short_id/truncate_id exports) and the browser
extension's truncateId change with it; ADR 0026 records the amendment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X3HRVnoLened7RocqUkvFs
The desktop's render_status_line and Kotlin's JobStatusLine derived the same
text (progress message while running, the failure once failed, otherwise the
state's word). Job::status_line now does, JobItem carries it as statusLine,
and each screen keeps only its color choice. The job_state_label export lost
its one caller and is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X3HRVnoLened7RocqUkvFs
MusicTreeItem carries its subtitle, computed in build_music_nodes (every
track counts toward each folder above it, walking parent links once rather
than recursing per card) with the words in copy::music::track_count. The
desktop's descendant_track_count and Kotlin's descendantTrackCount — the same
recursion with the same sentence — are gone. So are the desktop's and
Kotlin's per-level re-sorts of the music and video trees: the lib already
lists them collections-first by lowercase title (sort_for_display), and a
filter keeps that order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X3HRVnoLened7RocqUkvFs
The desktop's PresenceLook::label and Android's PresenceLook.label derived
the same word ("Not here" for Absent/Removed, the model's own word
otherwise); both now call copy::content_presence::presence_label, exported
for Android. The row-level words ("Partly here", "Arriving") are
desktop-only and stay where they are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop's summary_line and Kotlin's buildSummaryLine/plural were the
same sentence built twice ("N tracks in N albums by N artists; N not yet
available", "Library build canceled"); both now call
copy::music::library_build_summary, exported for Android.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
markup::resolve_wikilinks_by_title builds the lowercased title index that
the desktop Notes pane and the FFI's resolve_wikilinks (Android) each built
by hand, so the matching rule is written once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop's ComicsView and Android's ComicRepository each carried the same
tree logic ("Mirrors the desktop …"): sibling order, the recent filter,
latest-read leaf resolution, series root and effective direction, first
descendant / cover comic, the "N chapters · Continue: X" subtitle and the
In Progress list. They agreed rule for rule, so both now ask
agents::comics::ComicShelf (a uniffi Object on Android), with the words in
copy::comics. ComicTreeItem carries its latest_read_child, which ends
Android's one FFI call per collection on every load (and one per
standalone comic on entering In Progress, now one call); ComicsFfi.list and
get_latest_read_child give way to shelf/in_progress. RECENT_WINDOW_DAYS moves
from agents::pdf to agents and stops being redeclared by the desktop comics
screen and Kotlin. Sibling order keeps both frontends' plain case-insensitive
title fallback (the lib doc had claimed natural-name order; corrected).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
agents::podcast's search and episode fetches are one async index_get<T>:
the credential read, one SHA-1 auth header, reqwest's query encoder,
error_for_status (a 401/429 no longer surfaces as a serde "missing field"
error) and .json() in place of text + serde_json. Gone: the two catch_unwind
wrappers, the reqwest::blocking call, the per-call runtime + block_on, the two
description-cleaning loops (one clean_description), and the parse ->
re-serialize -> re-parse round trip. PodcastSearchResult and
PodcastIndexEpisode (trimmed to the fields a frontend reads, which also drops
the non-optional dateCrawled that could fail a page) derive uniffi::Record.
Desktop: search and the episode fetch are awaited on the tokio runtime via
network_task instead of blocking a gpui worker (CLAUDE.md: network waits never
occupy a UI worker thread); the store overlay stays on the background
executor. PodcastsView holds AppServices to hand the runtime down.
Android: PodcastFfi.search/fetchEpisodes return the records directly (run on
a current-thread runtime of their own, since they need no P2P node);
PodcastApi.kt and its four @Serializable mirrors are deleted. Search now uses
GET, as the Podcast Index documents for every endpoint.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MusicError::Audio(String) took both the audio player's AudioError and
symphonia's probe/decoder errors through e.to_string(). It is now
Audio(#[from] AudioError), transparent, and Decode(#[from] symphonia's
Error), so the four map_errs go (queued by the lib-agents batch).
Fixes a doubled prefix: an AudioError already reads "audio error: ...",
and wrapping its string in Audio's "audio error: {0}" showed
"audio error: audio error: ...".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rail, the FFI's get_agent_groups and the registry test iterate
AgentGroup::VARIANTS, so a new group cannot be left out of a hand-written
list. gui-app takes strum (already in the workspace) for the trait.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PomodoroPhase::label and completion_message only forwarded to
copy::pomodoro::{phase, completed}; the desktop timer and the FFI call copy
directly and the two methods go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
save_listen_progress and save_listen_finished were one-line wrappers over
save_listen_state (queued by the lib-agents batch). The desktop's player
subscription now maps its two events to a ListenState and makes one save,
the FFI's two methods (unchanged for Kotlin) build the state themselves, and
the wrappers go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It is the failure text a network action's row shows, so it belongs in
copy.rs with the rest of the user-visible words (queued by G1, which
gathered its twelve literals into network_task.rs). Wording unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ffi/documents, ffi/podcasts, ffi/music and agents/podcast each spelled out
blob_store.load(hash, plaintext_hash, vault_id) or the PrimaryContent
get_blob_ref batch; they now call agents::blob_document::{load_blob,
primary_blob_ref} (queued from the lib-agents batch).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
materialize() took four store references that every caller pulled out of
one Store (queued once the daemon batch landed); it now takes &Store. Its
primary-content read uses blob_document's primary_blob_ref and load_blob
instead of spelling out the triplet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Their uniffi custom types failed with a bare anyhow error, which Kotlin sees
as SyncException.Internal ("report a bug"). They now fail with
SyncError::InvalidInput, as NodePubkey's already did; one test covers all
three types.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NotebookManager::list_notebooks returned (DocumentID, String) tuples that the
FFI re-wrapped as its own NotebookItem and the daemon as NotebookRow. The lib
now defines NotebookItem (uniffi Record, serde) once; the FFI returns it
as is, the daemon's protocol carries it, and the desktop Notes screen reads
its fields by name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
agents::library_tree reads a media library's folder tree — every leaf of
one type plus the containers above it — with DocumentStore::ancestors_of
(one recursive query), one bulk property read for every node's title and
extras, and one bulk availability read. The four builders each had their
own collect_ancestor_collections walk (a query per ancestor) and then two
queries per collection and one or two per leaf; they are now a map over the
shared nodes, and pictures/video/music share one collections-first sort.
Each library keeps its own default for a leaf the availability read has no
row for (pictures Local, video/music Unavailable) and comics keep rolling
availability up from their pages; see handoff notes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every importer took (blob_store, doc_store, artifact_store, path, node,
parent, progress) — five clippy::too_many_arguments allows in comics alone,
and a table macro and a comic adapter that only forwarded the seven. They now
take ImportTarget { store, node, parent, progress } and the path; ImportFn is
fn(&ImportTarget, &Path), the directory walk's ImportOne closure receives the
target, and create_collection_node, import_blob_document and import_from_url
take it too. The cbz/cbr dispatch is comics::import_comic_file, which the
comic tree calls directly instead of looking itself up in the table.
SupportedFileType's functions are private behind import/import_directory,
which (like import_file and import_from_url) mark the new document present on
the importing node. Every frontend did that by hand after these calls
(gui import_job::mark_present and Documents, the daemon's mark_present,
Android's ImportBatch), so those copies go. Fixes: the desktop Comics folder
import (import_comic_tree) marked nothing present, and the desktop's and
Android's URL imports didn't either; both now do, as the daemon's did.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
agents::reading holds the reading page, PageBookmark (a uniffi Record with
label: Option<String>), list/add/delete bookmark and DeleteBookmarkError.
agents/pdf.rs and agents/comics.rs each had a byte-for-byte copy, and
ffi/pdf.rs re-implemented all of it inline (looking the node key up by hand
three times); ffi/comics.rs copied PDF's get_reading_progress. Both FFI
objects now delegate in one line, BookmarkInfo (which turned a missing label
into "") is gone, and Kotlin reads the label as nullable.
Behavior: list_bookmarks and reading_page now return their store errors
(the desktop keeps its old default of empty / page 0); the listing is sorted
by (page, id) everywhere; ComicsFfi::add_bookmark takes an optional label and
returns the new id like PdfFfi's. ComicMetadata::info_rows and the
comic_info_rows export had no caller anywhere and are deleted, so the PDF's
info rows stay where they are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TextInput split its line into three runs by hand to underline an IME
composition — what multiline_text_element::build_content_runs already does
(audit C3.2). It now calls that with no selection, since a single-line field
paints its selection as a quad; the runs, and so the field, are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
About forty gpui::Type paths in code (not doc comments) across twenty files in
this batch's scope, and crate::sizing::scaled in three, are now imported
names, per the Code Style rule against fully-qualified type paths. Mechanical;
text_editing's macro keeps its $crate/gpui paths on purpose.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every screen with a resizable sidebar (Notes twice, PDF, Pictures) had to wrap
its root in resizable::on_drag, which hung mouse-move/up/up-out handlers and
the resize cursor on the whole screen while a drag was in flight, so the drag
kept tracking once the pointer left the thin strip (audit M12). The divider
now does that itself: gpui's on_drag/on_drag_move follows the pointer
window-wide and keeps the strip's cursor, and its own mouse-up and
mouse-up-out end the drag. resizable::on_drag and its four call sites go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
render_scrubber and render_speed_slider drew the same groove, fill and ringed
thumb and did the same ratio-from-cursor math (seek_to_cursor,
drag_speed_slider) (audit M1). They are one slider() with its proportions in
a SliderLook, ratio_at, and an on_slide that sets the value on a press and on
every move of a drag begun there. The drag now goes through gpui's
on_drag/on_drag_move, which follows the pointer window-wide, so a scrub or a
speed drag no longer stops dead when the pointer leaves the 24px strip (B6).
The speed thumb sits half a pixel higher, now centered on its groove.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TrackArt::Url and ::Decoded built the same cover element twice; they map to
an ImageSource first (audit M9).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The wedged/failed/ordinary color match was written in both remote_fetch_note
and remote_fetch_line; it is tone(). cancel_label()/try_again_label() only
wrapped content_fetch::CANCEL/TRY_AGAIN, which Comics, Pictures and PDF now
name directly, and remote_fetch_note is private — only the overlay calls it
(audit M8).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
set_content is replace_bitmap plus a scroll reset; pan_by only forwarded to
scroll_by (pan_for_key now calls scroll_by, which takes the doc); the two
scrollbar blocks are one loop over the axes; and render_content takes the
image rather than an ImageSource the Render impl built for it (audit M7).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both text widgets set the same field background, border, focus/invalid
border color and rounding (audit C3.3); text_editing::field_chrome is that
box once. TextArea passes invalid=false, so it draws exactly as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Revocation import, the System tab's public-key load and the teardown wipe
each spelled out cx.spawn + background_spawn + this.update + notify, the
shape G1's background::spawn_then already names (left for this batch in the
G1 handoff).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The document detail screen's "Delete Locally" and Settings' "Reclaim…"
drew the same evidence dialog (verified / unverified / shared lines, Cancel,
the override-gated "… anyway", the confirm) and ran the same execute step
that re-reads the unverified-deletion override before honoring "anyway"
(audit S2). Both are now components::eviction_dialog: eviction_dialog() takes
the words that differ and the view's cancel/confirm, and execute() is the
checked run. Reclaim's dialog reads the override from the Settings view's own
field instead of a SQLite read on every frame. Words and layout unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Documents' AsyncActionState, Ensemble's ActionState and Settings'
RunNowState and RemoteTriggerState were four spellings of Idle / Pending /
succeeded-with-a-sentence / failed-with-a-sentence (audit S3). They are
job_binding::ActionOutcome — the half of a control's state the module doc
already said a job can't carry — with from_result for the "Ok → Done, Err →
Failed" match each result handler wrote, and is_pending. Settings' two
status-line matches are one widgets::outcome_line. Each screen keeps its own
colors and rendering.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
render_local_sync_policy and render_remote_sync_policy built the same
SyncPolicyControl per content-bearing type, differing only in where the
policy and ledger came from and whether Reclaim is offered (audit S7); both
now go through policy_rows. set_scheduler_enabled and set_scheduler_interval
were one persist-log-notify body (S12) and are one set_scheduler; the content
ledger reload is spawn_then, and sync.rs no longer spells out
std::sync::Arc.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The roster load and a member's Sync both re-read the settings row from SQLite
for the timezone the app already holds as a gpui global (audit S10); they now
take cx.timezone() when the work is started. Same zone either way (Settings
writes both), minus a store read per load and per sync.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
render_retire_dialog and render_reset_dialog were one dialog written twice
behind two bools (audit S6): same modal, title, two notes, Cancel and a danger
button. They are one ending_confirm: Option<Ending> and one
render_ending_dialog that picks its four copy constants by Ending; the words
are unchanged. The self-revoked banner and the load-error box share the red
error_card they both drew by hand (S13).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SynchronicityView::new passed the same store, node, node runtime, tokio
handle and job runner (plus the media session or nav status for some) to
eleven agent constructors, five to seven loose arguments each in whichever
order that screen listed them, and cloned store/node_runtime/rt aside first
so it could keep its own after the last move (audit T2). Each of those
screens now takes (&AppServices, &sidebar, cx) and destructures what it uses;
app.rs builds the bundle once. Constructors only — no screen's behavior
changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
node_ref.rs and document_ref.rs were one component written twice (audit C6):
identical render bodies around node_display::resolve and
document_display::resolve, each with a TODO for the copy-to-clipboard HashChip
already does. IdentityRef::node/document resolve the name and render it
beside a HashChip, so the Jobs screen's peer and document ids are now the
app's standard bordered chip (full id on hover, click copies) instead of dim
mono text with a whole-row tooltip — a visible change, on that screen only.
hash_chip()'s wrapper (an unused theme argument around HashChip::new and an
optional prefix) is gone for HashChip::new(..).prefix(..) at its twelve call
sites, and availability.rs's label() forwarder for DocumentAvailability::label.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Root and PopoutWindow each kept a bounds_save_generation counter, bumped it on
every bounds change, and had the delayed save compare it afterwards — hand-
emulated cancellation (audit T3). They now hold the pending save as a Task and
replace it; debounce_geometry_save returns that task, and the SQLite write it
runs has moved off the UI thread onto the background executor.
popout::open and open_at differed only in where the window went (T4): there
is one open(.., WindowBounds, ..) plus cascade_bounds, and app.rs's pop_out
and restore_popouts share one open_popout for "open, record, watch for close".
popout.rs no longer spells out crate::root:: paths.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's tokio runtime build is a ? (main returns io::Result), the quit
shutdown handle's mutex tolerates poisoning, and a reqwest client that can't
build (only if TLS won't initialize) is logged and leaves gpui's default HTTP
client in place — remote cover art won't load — instead of panicking at
startup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every PlayerControls — one per media screen — woke every 200ms for the life
of the app, playing or not (audit B7). The ticker now starts with playback
(play, restart, resume) and ends at the first tick that finds the bar paused
or stopped.
The transport reset was written out by hand five times (M4): play repeated
replace's load line for line, the Preempted handler repeated stop, and the
ticker's two end-of-track branches were identical. They are load_track, halt
and finish_track; emit_position still flushes before any reset. The custom
speed dialog's three fields (open flag, value before, live value) are one
Option<SpeedDialog> (M6), and the fifteen audio.lock().unwrap() calls go
through a lock helper that tolerates a poisoned mutex (M10).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The M:SS / H:MM:SS formatter was written in the player (format_time), in
lib's music track list, and reached by video through the player; the listen
progress line in copy::artifact_value::summary had its own M:SS with no hours
field, so a 90-minute position read "In progress: 90:00" (audit M3). All now
use copy::clock(total_secs); each caller keeps its own rounding (a track
length rounds, a playback position truncates).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
syntax_highlight registered its 21 languages with 21 near-identical calls
(audit M5); they are one LANGUAGES table of aliases, grammar and queries (the
TypeScript-over-JavaScript and C++-over-C query deltas are just two queries in
a list).
Bug (B5): the first code fence in any language compiled every grammar's
queries on the UI thread, and every code block was re-parsed on every render —
every keystroke in Split mode. Each language's configuration is now built the
first time a fence in it is highlighted, and highlight() remembers the spans
for recent (language, code) pairs, so re-rendering an unchanged block is a hash
lookup. Highlight output is unchanged; it hands back shared spans.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
render_image::rgba_to_render_image, thumbnails::to_render_image and the PDF
viewer's to_render_image were three BGRA swaps (audit M2). render_image.rs now
has the one to_render_image(RgbaImage) and a from_rgba(buf, w, h) that returns
None instead of panicking when a buffer doesn't match its size; the PDF page
and thumbnail paths and the typst preview go through them (a mismatched page
bitmap is logged and the previous page stays up), and Notes' image embed
converts its decoded RgbaImage directly.
Every formula in a note preview built a new RenderImage from a clone of its
RGBA bitmap on every frame — a new image id each time, so every formula on
screen was re-uploaded to the GPU atlas per repaint (B4). markup_view keeps
one image per cached MathRender (keyed by the Arc, pruned when the math cache
drops it).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In explicit-zoom mode the −/percentage/+ group sat on theme.accent with
text_on_accent over it; theme.rs reserves accent for marks and requires
accent_fill under any text (white doesn't reach AA on the brighter one), which
is what the Fit segment beside it already uses (audit B3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
import_trigger opened rfd's synchronous FileDialog inside a menu item's click
handler, so the window stopped repainting and taking sync arrivals for as long
as the native dialog was open (audit C10, an instant-feedback violation). It
now awaits rfd::AsyncFileDialog in a window task and hands the picked paths
to the same callbacks, as Notes' Export to PDF already does. Filters and titles
are unchanged; this was the last synchronous file dialog in gui-app.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three raw px() in render code measured against rem-sized neighbors, so each
was right only at 100% zoom: the notes/pictures tree's row indent
(pl(px(8 + 16n)), written twice; audit C8), the indeterminate progress bar's
slide (left(px) against a scaled() width; B1), and the scrubber and speed
thumbs' centering offsets (B2 — the speed thumb's -7px was also half a pixel
short of its 15px icon::SM). The indent is now INDENT_BASE/INDENT_STEP in
scaled() via one row_frame shared by the editing and normal rows; the others
are scaled() or derived from the thumb's own size.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one-pixel divider was spelled out by hand at a dozen sites, half without
flex_none — and in a squeezed flex_col a 1px child that may shrink collapses to
0 and the rule vanishes (audit C7). reader::hairline moves to
section::hairline and is used for the rail's two rules, markup headings and
thematic breaks, the Pictures sidebar, the document list header, the document
detail's section divider and the popup menu separator. The collapsed rail's
inset rule keeps its margins and gains flex_none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TextInput's and MultilineTextElement's paint called request_animation_frame on
every paint while focused, so a focused field kept its window redrawing at the
display's refresh rate for as long as it held focus, to animate a 500ms blink
(audit C3.4). text_editing::paint_caret now paints the caret and schedules one
timer for the next toggle; EditableText::claim_next_blink hands each toggle out
once, so a paint for another reason doesn't start a second timer and a
keystroke (which restarts the phase) starts a fresh one. The blink phases
themselves are unchanged. The 2px caret width is one CARET_WIDTH.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MultilineTextHost's only implementor was a blanket impl over MultilineEditing,
so the element now takes H: MultilineEditing directly and reads buffer(),
placeholder() and height(); it also stops taking a focus handle its widget
already owns. The measured-layout .expect in prepaint is a let-else that
paints nothing, and the module doc no longer claims gpui's
index_for_position is private — it is public now, but panics before layout,
which is why ours stays.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Combobox, TagInput and CommandPalette each declared their own SelectNext/
SelectPrev actions and up/down bindings, their own wrapping select_next/
select_prev, the clamp-the-highlight block and (the two dropdowns) the same
panel and row styling. components/suggestions.rs now holds the actions,
bind_keys(context), a Highlight, a Suggesting trait with one step() and
navigate() wiring, and panel()/row(). The frizbee config with its typo cap
(written in Combobox, TagInput and lib's find_human_by_name) is lib
synchronicity::fuzzy::rank; gui-app drops its direct frizbee dependency.
Bugs (audit C2): TagInput's dropdown was an in-flow absolute child at a magic
40px offset, so it was clipped by and painted under later siblings; it now
floats like Combobox's does. TagInput's focus handle was never tracked, so
focusing it did nothing; it now hands out its field's handle, as Combobox does.
The "capture the control's bounds, float the popup below it" code the
dropdown menu and Combobox each wrote (audit C9) is components/anchor.rs:
anchored_to (below, or above for TagInput's open_upward) and
track_first_child.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rail's and the notes tree's right-click menus were never focused, so
Up/Down/Enter/Escape did nothing in them (audit C1). A PopupMenu now focuses
itself on its first paint — in whichever window paints it, which also covers a
popped-out Notes — and gives focus back on dismiss unless the chosen item moved
it on purpose (Rename's inline editor, a dialog field).
app.rs and notes each hand-rolled the same "record the right-click, build the
menu on the next paint, subscribe to DismissEvent, float it" state machine
(three fields, four methods each); both are now an AnchoredMenu in menu/mod.rs.
Since building no longer needs a Window, PopupMenu::build and the dropdown
builder take just the menu.
Deleted as unused: context_menu.rs (ContextMenuExt was never called), the
action-dispatching half of PopupMenu (menu, menu_with_*, action_context,
PopupMenuItem::action/icon/label, the Label variant, max_w, is_empty) and the
module-wide allow(dead_code) that hid it. The goal of mirroring upstream
gpui-component's API is dropped: it is built on Zed's gpui, not gpui-ce.
DropdownMenuState's close reset is one method.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
load.rs seeded a row's sync state as Pending when a CatalogSync for that peer
was running at load time, and carry_over_actions carried that Pending across
every later refresh; only this screen's own click ever resolves it, so after a
scheduler-started sync the button stayed disabled for good (audit S1a). Rows
now load Idle — the button already asks ActiveJobs about the job itself.
reload and refresh share one load helper over spawn_then, and the job watch is
ActiveJobs::watch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Credentials' Save passed `!has_input` as its enabled flag, so it greyed out
exactly when there was something to save; Clear, its confirm and Cancel
passed `false`, so they always drew disabled (audit S1b). The clicks still
fired because a disabled button only restyled. Every Settings button whose
enabled flag is computed now attaches its handler with on_click_if, so the
node-name Save, Sync now, Run now, the revoke dialog and a member row's
Ping/Sync both ways commit nothing while drawn disabled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The header's and the empty state's "Add" buttons each cleared the name field
and opened the dialog inline, and the backdrop and Cancel each closed it
inline; set_create_open does both. Same behavior.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The podcast list's four optimistic row edits each cloned the whole episode
list to change one row and set it back; SyncGuarded::current_mut edits in
place (set_current was already a plain assignment). The queue's move up, move
down and remove were three copies of guard / optimistic local change /
background write / log; PodcastQueueView::edit is that sequence once. Same
behavior and log lines.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The podcast list, episode screen and queue each tested id match plus
has_audio_session against the transport bar (with the same comment about
set_pending), and the episode screen and queue each mapped the answer to
Play/Pause/Resume. PlayerControls::holding and
podcasts::episode_actions::play_control hold those once. Same labels, same
behavior.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
detail.rs wrote out the same Subscribe / Subscribing… / Subscribed ✓
tri-state and sizing as search.rs's subscribe_button; it now uses it.
Render-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Documents' and the podcast episode list's held-change strips spelled "Show"
and the document detail's properties control spelled "Edit" where
copy::action::SHOW and ::EDIT exist for exactly these (the former's doc names
every changed-elsewhere marker). Same words.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both screens framed their bare search input in header_search and showed
header_search_clear while it held a query, identically apart from the id and
what clearing does. components::screen_header::header_search_field is that
field once. Render-identical. The PDF library's header search has no clear
button, so it keeps its own frame (a decision, see handoff).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three places (the open note vanishing from the tree, selecting another note,
switching notebook) each stopped the body watch, dropped the stored baseline
and any held incoming body, and republished the rail slot, four lines apiece.
forget_open_body does it once. Same behavior; Notes' hand-rolled guard is
otherwise left as is (see handoff on SyncGuarded adoption).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each of the four library screens constructed a MediaLibrary with an empty
message for a placeholder "library" tab, then immediately replaced the tabs
(repeating that message). new() now takes the tab list; the placeholder tab
and its DEFAULT_TAB_ID are gone. The first tab is active, as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every MediaLibrary consumer built items with `activatable: true` and an id
stringified from a DocumentID, then parsed ItemActivated's string back into a
DocumentID. MediaItem::id and ItemActivated are DocumentIDs now, and the
activatable knob (true at all six construction sites) is gone, its rationale
folded into availability's doc. Render-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
button()'s doc asks every caller to gate its click handler on the same
`enabled` it drew with, since the disabled look alone does not stop a click.
Some callers did (`let mut btn … if enabled { btn = btn.on_click(…) }`, and
the podcast queue's own gated_button); most did not. on_click_if(enabled, …)
is that gate as one call, used at every agent button that draws a disabled
state.
Bug fix: disabled buttons that still fired now don't — most visibly the
podcast episode list's "Loading…" (Refresh) button, which restarted the
fetch it said was running; also PDF/Pictures/Comics prev/next at the ends,
Pictures' rotate with no image, Humans' Save with nothing changed, and the
document detail's busy Sync/Pull/Push/Purge/Delete-locally controls.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Humans, Notes' dialog and the document detail's property editor each built a
field with new, then set_content on a mutable binding. A with_content
builder does that in the chain; the property editor's five typed branches
become one local constructor. Humans' seeded fields drop their empty-string
guard, since setting empty content is a no-op on a new field.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both re-implemented the RGBA-to-BGRA swap and RenderImage build that
thumbnails::to_render_image already does. Render-identical. (QR decoding via
gpui::Image::from_bytes was not adopted: gpui loads that source
asynchronously and caches every image, so the preview would blank for a
frame per keystroke — see handoff.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The small error-colored sentence under a field or control was spelled out in
Humans (5x), Podcasts (2x), Clippings (2x) and the document detail; it is
components::note::error_line now. Humans' four identical set_*_error methods
(mark the input invalid, set the sentence under it) become one
set_field_error over a FormField enum, and labeled_field_with_error builds its
label with field_label like its siblings. Render-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Documents' error_banner and Notes' render_error were the same bordered error
sentence with a ✕ (Notes floats it at the bottom over a window-colored
ground). components::note::dismissible_error is that box once; each screen
keeps its own placement. Render-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bordered card (p_4, card background and border, CARD radius) was spelled
out seven times across the podcast screens, and the "Description" heading
over a card twice (detail and episode). podcasts::card and
podcasts::description_block hold them once. Render-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
render_circle_tabs/render_circle_tab re-implemented components::tabs line for
line apart from the bar's height (control::ROW_XL) and padding (px_4); it now
uses tab_pill and tab_bar with those two overridden. Render-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The three readers each built the same toolbar strip, fixed and flexible
toolbar groups, page-counter caption, "Zoom:" group, hairline, media canvas
and absolutely-positioned zoom layer, and Comics and PDF each declared
PAN_STEP and matched up/k/down/j by hand. These now come from
components::reader and ZoomableImage::pan_for_key. Render-identical; the PDF
toolbar keeps its zoom controls without the "Zoom:" label the other two have
(left as a decision, see handoff). The library's two hairlines use the same
helper.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
detail.rs wrote "N new episodes." / "Episodes changed elsewhere." on the
screen; it is now copy::podcast_episodes::held, worded exactly as before.
Android's strip still says only "Episodes changed elsewhere." (the zero
case) — whether it should count too is left as a decision.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
notes' render_incoming_body_banner and the queue's render_incoming_banner were
the same strip (icon, copy::incoming_change sentence, take/keep buttons) with
different ids and handlers. components::incoming_banner is that strip once —
the desktop counterpart of Android's ui/IncomingBodyStrip. Render-identical.
Clippings keeps its in-card column variant (a layout decision, see handoff).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comics, Music, PDF, Pictures and Video each wrote an import-files and an
import-folder method (ten ~50-line copies of start job, walk, filter, import,
mark present, finish, report first error, re-read). They now implement
import_job::ImportHost (their file type, their fields, what re-reading means)
and call import_job::import_files/import_folder, which import through the
lib's SUPPORTED_FILE_TYPES table — so comics stops re-implementing the lib's
cbr/cbz dispatch. Comics' folder import keeps import_comic_tree, through the
shared import_job::run.
Small behavior fixes: Comics and Video now tick progress past a skipped file
the way the others did (the job's total counts every picked path, so its bar
could stop short of the end); Music clears its artist-mosaic cache after a
folder import too, not only after a files import.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Save (as a tracked job), remove, queue, and the Save/Saving/Remove and
Queued/Add control states were written out twice, in podcasts/detail.rs and
podcasts/episode.rs; they now live once in podcasts/episode_actions.rs, each
screen keeping only its own optimistic bookkeeping. EpisodeMeta carries the
list's EpisodeRow instead of restating eleven of its fields, so
to_new_episode exists once, and the episode screen's own saving flag is the
row's.
Bug fix: a refused second save on the episode screen said the raw lowercase
clause ("a save of that episode is already running"); it now shows the same
sentence the list does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five best-effort preference writes (active notebook/note, view mode, math
dialect, split fraction) were each a ten-line clone-spawn-build-log block;
NotesView::persist(what, write) is that block once, with the same log
lines. manager(store, node) replaces the twelve spelled-out
NotebookManager::new(&store.doc_store, &store.artifact_store, node) (audit
N1, the part spawn_then didn't already cover).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
import_spinner handed every spinner on the Documents screens the id
"docs-import-spinner" — the load-more row, each Placement row's override
and job line, each Artifacts row's job line and the Danger zone's — so any two
on screen together shared one animation state (audit bug 15/D22). The
misnamed wrapper is gone; each call names its own id (keyed by row where
there are rows).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Documents screens had their own format_bytes in SI units (1000) with no
GB tier, so the same file showed a different size here than in the TUI and
on Android, which use the lib's copy::format_bytes (1024, with GB) (audit
bug 10). The local copy is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
render_detail and Notes' render_dialog take the Detail/Dialog their caller
already matched instead of expect()ing it back out; Humans' save returns if
the detail is gone rather than expecting it; the PDF thumbnail skips a
bitmap whose buffer doesn't match its size instead of panicking; the math
cache capacity is a NonZeroUsize const checked at compile time; Music's
player and the PDF viewer's loaded-document cell recover a poisoned lock
(audit bug 17).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comics, PDF, Music, Notes and Humans each had doc comments that had slid onto
the wrong item (a glyph's doc above a tab id, an import doc above the library
build, three orphaned helper docs stacked on Humans' today()) or described
constants that no longer exist; each is moved to its item or deleted (audit
bug 19). The PDF viewer's own THUMB_MAX_EDGE was a copy of
thumbnails::THUMB_MAX_EDGE (same 480) and now uses it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two doc comments had run together above documents::row_sizes: its own, and
one for a per-row "all blob-backed content here" function that no longer
exists (the column reads lib content_ledger::subtree_content now, which
documents itself). Queued from the lib-stores batch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Documents detail (purge broadcast, ensemble_peer_ids, placement rows) and
Settings (remote peer list, ensemble load) spelled out
settings_store.batch(|tx| settings_store.load_ensemble_membership(tx)); the
lib now has that as membership() (queued from the lib-p2p batch). The
presence survey's read stays as it was: it shares its transaction with the
node-name read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Placement picker's "Follow policy" is copy::sync_override::FOLLOW_POLICY
and the PDF library's "No PDFs match “q”" is copy::library_empty::no_match,
which Android already uses (both queued from the FFI batch). Same words.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DATE_FILTERS/DateFilter restated the lib's copy::date_filter::OPTIONS (the
keys the search query takes and their labels), and "Any Date" was typed
twice in list_view; all three now read the lib's (queued from the FFI
batch). Same keys, same words.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
import_job's already_running and podcasts' busy_saving capitalized a busy
clause with clause[..1].to_uppercase(), which panics on a multibyte first
letter; the lib's busy::sentence does it by char (queued from the FFI batch).
Both one-use wrappers are inlined into their only call site.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five documents-detail actions (pull, per-artifact pull, push, remote
override, delete-locally evaluation) and six settings ones (ping, peer sync,
reclaim evaluation, remote sync config read/write, Run now) each read the
endpoint, cloned the runtime, spawned, mapped a missing node to "P2P not
running", awaited run_msg, and applied the result; the tracked ones also
wrapped the job handle in an Arc to report progress and finish it. The two
helpers do that once (the job is finished whether or not the view is still
there), with the sentence as network_task::P2P_NOT_RUNNING until copy.rs
takes it (audit D2/X3). Also moves push_document's doc comment back onto it
from row_job_control.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The loop body (read settings, run every pass when enabled, log the outcome,
clamp the interval) is the lib's scheduler::tick now, which the daemon
already uses; MIN_INTERVAL goes for scheduler::MIN_TICK_INTERVAL (queued F14
from the frontends batch). Log sentences are identical. The per-tick
set_job_runner and the startup wait stay.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The type was spelled out 29 times and read through 29 .lock().unwrap()s (a
poison-panic each), 17 of them the same .as_ref().map(|rt| rt.p2p.endpoint()).
network_task::NodeSlot wraps the same cell with get/endpoint/set and recovers
a poisoned lock; the embedded daemon socket still gets the raw cell
(shared()), since syn-daemon reads it per request. A watch channel ticks on
every set, so the scheduler's startup wait is NodeSlot::ready under a timeout
instead of a sleep-poll loop (audit gui-core T1). Also drops the stale
"inferred iroh Endpoint, so this crate avoids a direct iroh dep" comments —
the type is the lib's EnsembleEndpoint — and moves comics' fetch_page_remote
doc back onto it from fetch_page_for_reader.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Podcast search (debounce + in-flight search), the podcast detail's episode
fetch, and Pomodoro's tick loop each bumped a u64 and had the stale task
check it before applying. Replacing a held Option<Task<()>> drops, and so
cancels, the task it supersedes, which is the same thing with less
bookkeeping (audit P8). Documents' page fetches keep their counter: load_more
shares it without superseding, which one Task slot can't express.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
About 50 agent call sites spelled out cx.spawn → background_spawn(work).await
→ this.update(apply; cx.notify()).ok(). background::spawn_then(cx, work,
apply) is that shape once; it returns the Task so the sites that hold one in
a field (search, episode fetch, typst compile) still can. Fire-and-forget
writes that only awaited a background task and never touched the view become
a bare cx.background_spawn(..).detach(). Converted mechanically (script),
only where the body was exactly that shape; apply now always repaints, which
at worst adds a harmless notify.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gpui's AppContext::background_spawn (on Context and AsyncApp alike) is the
same call; podcasts already used it, the other 87 sites didn't. Mechanical,
across the crate (rustfmt had been splitting the long form over three
lines).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
summarize_artifact_value was a near-verbatim copy of the lib's
copy::artifact_value::summary (queued F2 from the frontends batch), down to
the Gender words, plus an unreachable! for blob-backed kinds. The row now
calls the lib with the same 120-char middle cut.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rerender_current_page applied its bitmap if the page index still matched,
so switching to another PDF at the same page while a re-render was in flight
painted the old document's page over the new one (audit bug 14).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
load_thumbnails only decoded a leaf comic's cover when the comic was Local,
but a comic is Remote as soon as any one page is missing, so covers sitting
right here were withheld (audit bug 13). Android fixed the same thing
deliberately (ComicRepository.kt coverComicId); a first page that really is
absent just fails the load and leaves the placeholder, as collections already
did.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The screen spelled out JobSubject's variants twice (job_peer,
referenced_document_ids) and built "Started … · Finished …" by hand; the lib
now has those as JobSubject::document()/peer() and copy::job_history_times
(queued from the FFI batch). Also moves reload's doc comment back onto
reload — it had slid onto publish_nav_slot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JobsView kept its own copy of the timezone, read from the settings store on
each reload, so after a Settings change its history timestamps stayed in the
old zone until the next job event (audit bug 12). It now reads the display-tz
global (cx.timezone()) at render, like every other screen, which
timezone::set already repaints.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Video library's format_duration wrote M:SS only, so a 83-minute clip read
"83:20" — the rendering player_controls' format_time already calls wrong
(audit bug 11). It now delegates to format_time (made pub), keeping its
round-to-nearest-second.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
export_to_pdf opened a synchronous rfd::FileDialog inside the click handler,
so the window could neither repaint nor take sync arrivals while it was open
(audit bug 8, instant-feedback invariant). It now awaits rfd's
AsyncFileDialog (which runs the portal call on its own thread) from the spawned
task. components/import_trigger.rs has the same problem; it's the components
batch's (C10).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Documents screen and Android's import record each imported document as
present on the importing node; the Comics, Music, PDF, Pictures and Video
screens never did, so their imports didn't count toward this node's holdings
(e.g. the Ensemble screen's per-node document counts) (audit bug 6). The mark
moves to import_job::mark_present, used by Documents and by each media
screen's file and folder imports. A comic *folder* import (lib
import_comic_tree) is not covered — it returns no ids; queued for the lib batch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both screens follow the store only while on display (watch_on_screen), which
relies on arriving at a screen re-reading it, but neither did: a person or
subscription that synced while the screen was hidden stayed missing until the
next store event (audit bug 5). Humans now uses store_watch::follow; Podcasts
re-reads its showing tab on arrival, as switching to that tab does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comics, PDF, Pictures, Video, Music and Notes each paired a nav-sidebar
subscription (re-read on arrival) with a watch_on_screen call and the same
six-line "applied outright" comment. store_watch now has on_arrival and
follow (the two together), carrying that rationale once. Behavior is
unchanged, including that a rail click on a popped-out screen still re-reads
it (App::set_current_view deliberately doesn't for Documents/Jobs; see
handoff notes). Music keeps its focus hand-back as a second on_arrival.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
InteractionRow stored its date as the display string, so editing an
interaction whose date hadn't synced copied "date not synced" into the When
field and Save always failed to parse it (audit bug 4). The row keeps the
Option<PartialDate>; the timeline renders the same sentence, and the edit form
starts empty instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Detail.created_at was initialized empty and never set, so the Created row was
always blank (audit bug 3). The timestamp now comes from build_detail's
document read, next to Updated, formatted the same way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When reload_tree found the open note gone it closed the editor but kept
incoming_body, stored_body, the watch task and the rail slot, so a dead banner
stayed up with buttons that did nothing and the rail kept saying "1 incoming"
(audit bug 2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
select_note applied its background load without checking that the note was
still the selected one. Click A then B, and if A's load finished last its body
showed under B's title and the next autosave wrote it into B. The apply now
returns early unless the note it loaded is still selected (audit bug 1).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PodcastError::Http(String) and Blob(String), and the seven
map_err(to_string)s that fed them, are #[from] reqwest::Error and
BlobStoreError; the release path's EvictionError gets its own Release
variant instead of being reported as 'failed to store'. The status check is
reqwest's error_for_status. save_episode_audio writes the PrimaryContent
artifact and the mark_present in one transaction instead of two (audit
P-9). A failed download's message now reads as reqwest words it
('HTTP status client error (404 Not Found) for url (...)') rather than
'404 Not Found for ...'.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
save_listen_progress and save_listen_finished were the same upsert with a
different ListenState; both delegate to save_listen_state now (audit P-10).
The two wrappers stay because gui-app calls them (queued for the GUI
batch).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The shared tempo was an Arc<Mutex<f64>> whose lock the cpal audio thread
took (and .unwrap()ed) on every step; it is an AtomicU64 holding the
f64's bits.
- StretchSource is generic over any rodio Source rather than a Decoder, so
the tests run the real type over a SamplesBuffer instead of a test-only
copy of its SoundTouch loop. The decoder pull is extend(take(want)).
- AudioError::Audio(String) and its map_err(to_string)s are #[from]
variants for rodio's two errors (same message). reset_sink hands back the
Output it reset, which removes the two .expect("sink opened above")s
(audit P-7, B6).
Needs a manual check: music and podcast playback, including a speed change
mid-track.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Eleven .expect("streaming state poisoned")s — one on the audio thread's
read path — are one Shared::state() that reads through a poisoned lock.
- The blocking read polled with a 20 ms sleep and the reconnect wait
looped on wait_timeout; both are Condvar::wait_timeout_while with the
stall deadline, woken by the notify_all the downloader already sends.
- download's five mark_failed exits are one: download_into_cache returns
the failure's message and download records it. write_at's seek was
redundant (the file is written sequentially from the window's start).
- State derives Default; resolve_seek_target uses saturating_add_signed
(audit P-6).
Needs a manual check: remote music playback, including a seek past the
buffered window.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The list-filter-sort of the queue's entries was written four times
(list_queue, queued_episode_ids, add_to_queue, move_entry); it is
entries(). key_after(p) was key_between(p, None) and is gone (audit P-5).
Bug fix (audit B9): add_to_queue read the clock twice, so an entry's
added_at and its row's modified_at could disagree; it reads it once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The display-resolve, job_activity, placement, clippings source-line and
pending, and content_fetch display tests were repeated asserts; they are
rstest cases (human_display::resolve, untested before, is a case now).
Peer-health's skew()/ahead() share an issue() builder, and two caseless
#[rstest]s are #[test] (audit C-2).
- no_copy_string_carries_a_run_of_spaces reset its in-string state at every
line, so it could not see the slip it exists for: a string that spans
source lines without a \ continuation, whose indentation lands at the
start of a line the string did not start on. It is a small lexer now
(strings, escapes and line continuations, comments, char literals), and
was checked to fail on such a string.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- node_display/document_display/human_display::resolve differed only in the
placeholder; they share a private resolve_or (audit C-6).
- peer_health::severity's two same-shape arms compute 'losing' and map it
once; copy() no longer recomputes headline and detail to build one_line
(C-8).
- content_fetch's module doc had landed on credentials.
- American spelling in doc comments (forever, capitalizations, parenthesize,
spelled; music's 'anymore'). No user-visible string changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The inherent render and the trait impl were two copies of one method, the
trait one delegating to the other; the body now lives in the trait impl
(audit Math-1). gui-app already imports MathRenderer where it calls it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- TypstWorld::new -> typst::compile -> comemo::evict -> map diagnostics was
written three times (PDF export, preview, math); it is typst::compile
now. compile_to_pdf's two identical map_err blocks are one closure, and
compile_document no longer returns a world both callers discarded. The
math engine's parse errors now carry typst's hints, which it used to drop
(audit T-1).
- FONT_CACHE was an Arc<FontCache> around a FontStore; it is a plain
static FontStore. The sources map was never written. The main Source is
parsed once per world (Source::detached) instead of on every source()
call, which also removes main_id()'s non-test .unwrap() (T-3).
- The custom-font path (compile_to_pdf_with_fonts, new_with_fonts,
extra_fonts, merged_book, FontCache::infos, load_font_data) lost its only
caller when the FFI batch removed the export; deleted with its two tests
(T-2, lib side).
- Pixmap bytes are moved out (take) rather than copied (to_vec).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
extract_cbz and extract_cbr_pages returned (Vec<(String, Vec<u8>)>,
ComicInfo), which clippy's type_complexity rejects; a Page alias names the
pair everywhere it is spelled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- parse's nine Block literals are one: each arm yields its kind and end
offset, and the loop pushes (audit M-1).
- Peek-then-next-then-unreachable!() drains are Peekable::next_if (and a
concat_while helper), which also removes flatten_inline's non-test
events.next().unwrap() (M-2).
- ParagraphContent was a subset of BlockKind; parse_paragraph returns
BlockKind. Builder gains push_link (the enter/push/exit triple, three
copies) and push_span (the merge, two copies) (M-3).
- parse's doc comment had landed on wikilink_targets.
Bug fix: heading anchors could collide. 'Foo', 'Foo', 'Foo 1' got foo,
foo-1, foo-1; the allocator now skips any anchor already taken (foo-1-1
for the third), as GitHub does. Test case added.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
generate_qr_png and generate_qr_terminal each spelled out the encode and
its error mapping (audit H-12).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PomodoroPhase::label and completion_message were user-visible copy written
in the agent; they are copy::pomodoro::{phase, completed} now, and the two
methods (which gui-app and the FFI call) delegate. reset_phase was enter()
of the current phase (audit H-11). No wording changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
clipping_from opened two connections per clipping and found the page
title by listing every fact; it now takes the caller's transaction and
reads the one fact by id (facts::get_fact). list_page and get read in one
transaction, and update writes body and title in one (audit H-10).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
S4. facts.rs and contact.rs tests spelled out
store.artifact_store.batch(|tx| f(&store.artifact_store, tx, ..)).unwrap()
22 times; a local in_tx helper does it once, and contact's try_add takes a
label so the relabel and unnamed-protocol tests stop re-spelling the full
eight-argument add_contact_method. The document-store tests' 31 copies of
"Document::new; let id = doc.id.clone(); add_document(tx, doc)" become one
add(store, tx, type, parent) (make_note now goes through it), and
availability's two record-holder helpers take a ContentKey.
Deletes documents/document.rs's has_blob_content_matches_the_documented_set,
which restated the implementation's matches! list verbatim and so could not
fail independently of it; comic_and_podcast_feed_are_excluded stays.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
search_documents and search_document_items each built the same only_roots
/ document_types / date filter / hidden-provenance predicates; the keyset
predicate (created_at < ? OR (created_at = ? AND id < ?)) was written in
both search_document_items and list_children_page; and the "fetch one past
limit, truncate, derive the next cursor" step in both list_children_page
and search_document_items_page. They are now push_search_filters,
push_cursor and paginate (S17).
Also B2: ancestors_of's doc comment had been left sitting on
descendants_of, which then carried both.
Left alone on purpose: search_documents still filters titles in Rust (one
get_title per row) — the SQL LIKE the paged path uses folds case for ASCII
only, so moving it would change which non-ASCII titles match.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
remove_fact, remove_list_fact, remove_contact_method and the podcast
queue's remove_from_queue each spelled out "if get_artifact_by_id is None
return false, else tombstone_artifact(..., now, ...) and return true".
ArtifactStore::tombstone_if_present is that, once (S18). The derived-id
upserts already build through Artifact::with_id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TextFormat::as_str re-spelled the serde names by hand; it is now strum's
IntoStaticStr under the same snake_case rule, with a test pinning both
spellings. ChangeChannel::ALL was a hand-kept list of all five variants,
which strum's VariantArray gives as VARIANTS without the chance of a new
channel being left off it.
Skipped from S14 on purpose: ContactProtocol's slug (frozen into derived
ids) and ArtifactKind-from-ArtifactValue (renames stored strings). The
store-local enums were done with their stores.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
B6: three HELD_LOCKS.lock().expect("store lock registry poisoned") calls
in non-test code, one of them in Drop. They go through held_locks(), which
recovers the guard (each access is a single get/insert/remove, so there is
nothing half-updated to protect), the way change_bus already does. The
lock-file holder reader duplicated in acquire and foreign_holder becomes
read_holder.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GRAPH_TABLE_COLUMNS re-listed every column of nine tables, and a test
existed only to catch it drifting (provenance was once silently dropped
from reseeded stores that way). The copy now asks pragma_table_info for the
destination's columns and names them in both the INSERT and the SELECT, so
only the table list is kept and the drift test goes with the class of bug.
Store::db_path returns the PathBuf instead of a String built through
expect("store path is valid UTF-8"), and the ATTACH, which does need text,
reports a non-UTF-8 path as StoreError::NonUtf8Path rather than panicking
(B6, twice).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
B2: message()'s doc comment had been glued onto bytes_so_far, which then
carried both. B9: ImportProgress's doc said the blob store has "no chunk
hook, so a file contributes its whole size the moment it lands", which
StoreProgress/add_partial a few lines below contradict; it now says what
happens.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes B8: the document and its properties committed in one transaction and
the PrimaryContent artifact in a second, so a failure between them left a
PDF/image/track document with no content pointer. Both stores share
store.db, so it is now one doc_store.batch (UrlImportError gains the
Busy/Db pair every store error has, for the transaction's own errors).
Fixes B2 here: import_from_url's doc comment had been spliced into
download_reporting's mid-sentence, leaving "to matter." as the whole doc
of import_from_url. Also drops the (Title, FileType) match whose
unreachable! arm was a non-test panic (B6), and the three copies of the
Http error closure.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes B7 for get_peer_nodes_for_ensemble: .flatten() over the mapped rows
skipped every Err, including real SQLite errors while stepping the query
(BUSY mid-iteration), and returned a silently short roster. The mapper now
turns only a row it cannot decode into a skip, as the comment intended, and
a stepping error propagates. (peer_health::all got the same fix earlier.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes B5: add_edge updated the from document's modified_at through
touch_document_in_tx without marking it touched, so an edge-only
transaction (a derived-graph rebuild) never published a DocumentEvent,
while remove_edge did. add_edge and write_property now go through
touch_document, and ArtifactStore's CRDT restamp reuses the same
touch_document_in_tx instead of its own copy of the UPDATE. A test pins
the announcement.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both carried the identical INSERT OR IGNORE (Immutable) and the identical
last-write-wins upsert; they are now insert_property_immutable and
upsert_property_lww, the latter returning whether the row changed.
Fixes B4: write_property appended the old value to property_history before
an upsert whose WHERE excluded.modified_at > properties.modified_at could
reject the write (an older stamp, a clock stepped back), leaving a history
row duplicating the unchanged current value. History is now recorded only
when the upsert applied, on both paths; a test pins it. Also corrects
write_property's doc, which claimed local writes replace regardless of
timestamp.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every test re-spelled the four-store destructure and the seven-argument
materialize(...) call. A run() helper, a notebooks() manager and a
remote_child() stub builder replace them, and the music-track/PDF pairs
(primary content written, unavailable content skipped) become one rstest
each with a case per type (S4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The four store references were threaded through materialize,
materialize_children and a nine-argument
materialize_primary_content_child; they now live in a private
Materializer, so the recursion takes (parent, dir, skipped).
write_artifact_file and artifact_extension dispatched on an ArtifactKind
that only ever took two values, with unreachable! (non-test panics, B6) for
the rest; they are split into write_note / write_primary_content and
note_format / file_type_extension, and the note extension comes from
TextFormat::extension instead of a copy of it. The public materialize()
signature is unchanged (the daemon calls it).
Also fixes B2 here: materialize_primary_content_child's doc comment had
been spliced onto skip_reason.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
artifact_bytes_for_documents / blob_bytes_for_documents and the matching
_for_subtrees pair differed only in the SUM expression, and every caller
added the two halves back together. bytes_for_documents and
bytes_for_subtrees now sum length(value) + blob_size_bytes per row in one
statement each (blob_hash and blob_size_bytes are written together, so the
blob_hash IS NOT NULL filter was implied), and total_bytes_for_rows runs two
queries rather than four. The subtree test now checks the recursion against
per-document totals rather than blob sizes alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
store_reporting (slice) and store_file (file) each carried their own copy
of the chunked seal/hash/write/progress loop and of the "rename into place
unless already present" step. A &[u8] is a Read, so both now go through
one seal(reader, len, ...) that ends in commit(); store_ciphertext commits
through the same TempBlob path instead of hand-rolling a shard-dir temp
file (which also stops two concurrent receivers of one blob sharing a temp
name). hash_file becomes blake3's update_reader, and the three copies of
the not-found check become present_blob_path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes B10: on every vault-key cache miss, BlobStore::vault_key built a
fresh VaultStore and SettingsStore — running their CREATE TABLEs, and
SettingsStore::new spawned a thread to derive the node keypair while
load_keypair() derived it again synchronously on the new, empty cache.
BlobStore now holds clones of the Store's VaultStore and SettingsStore, so
a miss is one query plus the keypair the settings handle already cached.
The key cache's lock().unwrap() calls (B6) recover a poisoned lock instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
peer_nodes, peer_health, revocation, epoch, linkage and settings each
repeated the same ChangeSignal plumbing: a throwaway ledger in new(), a
batch that publishes, subscribe, adopt_ledger (re-spelling the channel) and
publish_pending. They now hold an AnnouncingDb (a Db plus its signal, by
composition, no Deref) and take the ledger in new(); JobStore takes it too.
Every ChangeBus/ChangeSignal registers itself (weakly) with the ledger it
is built on, so Store::batch publishes through ChangeLedger::publish_pending
instead of naming seven stores, and Store::assemble no longer has a second
list of adopt_ledger calls to keep in step. Adding an announcing store can
no longer silently skip Store::batch's announcement.
SettingsStore keeps its own IMMEDIATE-transaction batch and publishes
through the handle; DocumentStore/ArtifactStore keep their ChangeBus.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every store carried db_path plus a new() that opened a connection and ran
one conn.execute per CREATE TABLE/INDEX, and a ten-line batch() that
forwarded to batch_on. Now stores/mod.rs has a Db { path } whose open()
runs a store's whole schema through execute_batch (S8) and whose batch()
is the shared transaction entry; each store holds one, declares its tables
in a const SCHEMA (explanatory comments kept as SQL comments), and its
public batch() delegates in one line (S3a). Public signatures are
unchanged, so frontends need no edits.
ArtifactStore's retired-kind fixup now runs inside one transaction.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
settings, secrets and ensemble are the same table shape; one load_row,
save_row and has_row (through stores::sql::Json and Timestamp) replace the
six hand-written SELECT/INSERT OR REPLACE/COUNT blocks and three identical
CREATE TABLEs. The now-unconstructed Json and Io error variants go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A strum-derived PeerHealthKind (explicit column strings, pinned by a test)
replaces kind() and the bare "clock_skew"/"schema_ahead" literals, so
clear() and existing() take the enum. existing() and for_node() query their
one node (and kind) instead of reading every row through all().
Fixes B7: all() matched `let Ok(..) = row else continue`, which also
swallowed real SQLite errors while stepping the query and returned a short
roster. Now only a row this build cannot decode is skipped, as documented;
a database error fails the read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
content_release, epoch, revocation, document_sync_override and vault bind
Timestamp, [u8; N], DocumentID, VaultId and u32 directly and read them back
typed, dropping their private parse_timestamp copies, the try_into round
trips, and the error variants only those parsers constructed
(InvalidTimestamp, UnknownOverride, Uuid, InvalidNonceLength,
InvalidCiphertextLength).
RevocationAuthority derives its stored text with strum (pinned by a test);
DocumentSyncOverride is stored through sql_strum_text!. epoch history and
the revocation roster keep skipping a row they cannot parse, now by reading
the fallible columns without `?`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- get_human opened ~9 connections (one per field); it now reads the
human's artifacts once and matches them. list_humans read three fields
per human (3N connections); it now bulk-reads names, nicknames, and
circles for every human at once. list_all_circles likewise (audit H-6).
- update_human's eight writes, create_human's three, and each fact /
contact-method add or remove plus its touch are one transaction each,
through one set_or_clear and one touching helper; require_human is
called instead of inlined three times (audit H-7).
- read_interaction_summary takes the Document its callers already hold,
instead of re-reading it and inventing Utc::now() when it is missing
(audit H-8).
Bug fix: update_human could half-apply (e.g. the name written, the
nickname not) if a later write failed; it is atomic now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_tree opened one transaction for the whole subtree but read each note's
format through get_note_format, which opened another per note — the
comment saying the artifact store couldn't take a Transaction was stale.
It reads note_format(tx, ..) now, and get_note_format is gone (audit H-5).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
add_titled (a new document plus its Title) replaces the hand-written pair in
Notes' create_notebook/create_collection/create_note, clippings' capture,
and pictures' create_album (audit H-4). ensure_document (add unless the
derived id already exists, answering whether it did) replaces the
get-then-add in music's derived artist/album documents, anchors,
agent-settings documents, and podcast episodes (audit X-8). Both live in
agents/ rather than on DocumentStore, since stores/ is another batch's.
Bug fix, same shape as the importers': create_note and capture wrote the
document and its body in two transactions, so a failure between them left
a note or clipping with no body. Each is one transaction now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The five get/set pairs on NotebookManager each spelled out the
agent_settings call, the AgentId, and the parse. agent_settings gains
get_parsed<T: FromStr>, set_setting takes impl ToString, and each accessor
is one line over private setting/set_setting helpers (audit H-3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AgentId::as_str was a 20-line hand match and Display a hand impl; both are
now strum's kebab-case (IntoStaticStr/Display), the same spelling serde
already used. The serde round-trip test iterates AgentId::VARIANTS, so it
can no longer miss a new variant. The registry's sixteen 7-line struct
literals are one agent(..) call each (audit A-1). agent_type stays a field:
deriving it from the group is a decision (queued in handoff notes).
AgentGroup::all() is kept: gui-app iterates it by value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
incorporate_track_cached used to open about ten transactions per track
(blob ref, stamp check, tag write, artist, album, year, two edges, unlink,
two thumbnail checks), each its own connection and writer-lock grab — the
module had already measured per-track connection opens as half the build
time. Now: one read transaction to decide whether to scan, the blob read
and tag parse outside any transaction, then one transaction for
everything the placement writes, then artwork. ensure_artist, ensure_album,
ensure_library_edge and unlink_from_other_albums take the caller's
transaction instead of opening their own (audit A-6).
stored_grouping_tags was cached_grouping_tags with an empty cache; the
freshly-scanned path now passes one (grouping_tags), and it is gone (A-4).
A failure part-way through a track now rolls back that track's tag write
too, so the next build rescans it rather than leaving it half-placed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- import_cbz_file reads the file and calls import_cbz_bytes instead of
repeating it.
- ComicInfo::from_xml replaces four copies of the parse_comic_info +
parse_page_info pair, and import_comic_from_pages takes it whole.
- extract_cbz reads pages and the ComicInfo.xml sidecar in one pass over
the archive, replacing page_names + extract_pages + find_comic_info_xml
(which opened the ZIP three times and read it twice).
- chunk_page_name is slice::chunk_by; format_chapter_number was f64's own
Display.
- import_archive_as_chapter records a missing comic importer as that
chapter's error instead of .expect()ing it (audit A-2).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both copied ContentFetchError's Stream/Io/NotAvailableRemotely/Canceled
variants and hand-mapped them, on the claim that a #[from] wrapper would
lose ADR 0023's Offline-vs-Canceled distinction. PictureError already
wraps it and ffi/error.rs delegates to ContentFetchError's own SyncError
mapping, which keeps exactly that distinction (audit X-6). gui-app matches
only the BlobStore(NotFound) variant, which stays.
The unreachable-peer message is now content_fetch's ("couldn't reach a
node that has this content") rather than the per-type "...this PDF" /
"...this page".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
agents::blob_document::import_blob_document stores the blob, creates the
document, lets the agent write its properties, and points PrimaryContent at
the blob. ImportImageError and ImportPdfError were variant-for-variant the
same and are now one ImportError. primary_blob_ref/load_blob replace the
get_blob_ref+load triplets each agent spelled out (audit X-2, X-7).
Bug fix: the document+properties and its PrimaryContent artifact used to be
two transactions, so a failure between them left a document with no
content. They are now one (both stores share store.db, and a Store's
artifact store announces on the document store's bus, so subscribers still
see one event).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Document/DocumentType are only used by node.rs's tests since the
single-file import went, and Job's timestamps are already Timestamps, so
JobItem takes them as they are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JobItem.peer_id_short/document_id_short and
DocumentNodeStatusItem.node_id_short carried copy::short_id of an id sent
alongside in full, and HashChip already computes shortId(full) when given
no short form — the same function. The fields, and IdRef's idShort
parameter that only forwarded them, are gone; the chips render the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The free recourse(error) existed because uniffi was thought to generate
error classes without methods; uniffi 0.32 renders exported methods on
errors (checked against the generated Kotlin), so the impl block itself
is exported and Kotlin's userRecourse reads recourse().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DocumentDetailScreen treated "documents().get returned null" and "still
loading" alike, so a document deleted or purged (here, or by a purge
from another node) left the screen on a spinner, and a failed read did the
same after only logging. It is now tri-state: loading, gone — which says
copy::documents::GONE, exported on DocumentCopy — or loaded; a failed read
shows the error like the detail rows' failure already did.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two sentences Android retyped:
- "No X match “q”" was built in six Kotlin screens (Comics, Music, PDF,
Podcasts, Pictures, Video); it is copy::library_empty::no_match,
exported directly as library_no_match. gui-app's pdf_viewer copy is
queued for the GUI batch.
- The override picker's "Follow policy" choice and its description were
Kotlin literals beside Rust's Pin/Exclude; get_document_sync_override_options
now leads with a value: None option carrying copy::sync_override's
words, and OverrideControl renders the list as given. gui-app's
detail_view literal is queued.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- JobBinding.jobActivityText was a line-for-line copy of
copy::job_activity; it now calls the exported job_activity_text.
- JobsScreen's "Started … · Finished …" line (a copy of gui-app's
render_history_timestamps) is copy::job_history_times, exported as
job_history_times. gui-app switching to it is queued for the GUI batch.
Same words either way; the history line now formats through
Timestamp::format_display directly rather than a string round trip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- HUMAN_FIELD_MAX_LEN in Kotlin was a retyped 255; it now reads
human_field_max_len(), which is agents::humans::MAX_FIELD_LEN.
- JobsFfi::cancel_job regains the doc comment its deleted Kotlin wrapper
carried, rewritten for the current runner (every runner-backed job has a
token; what varies is how promptly the work notices).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bug fix: get_all_document_types returned snake_case names and the
Documents screen title-cased them itself, so its type filter and chips
read "Podcast_feed" and "Music_track". It now returns
DocumentTypeOption { doc_type, display_name } (DocumentType::display_name,
what the desktop shows), and search/search_page take Vec<DocumentType>
instead of strings they silently dropped when unknown.
The date-range options (key + label, and "Any Date") move from a
Kotlin literal list into copy::date_filter, exported as
date_filter_options / date_filter_any_label. gui-app's identical
DATE_FILTERS table is queued for the GUI batch.
Gender and ContactProtocol (the rest of F17) are a decision and are left
as strings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_tree flattened agents::notebook::NoteTreeNode into NoteTreeEntry rows
with a stringly "note"/"collection" kind, and NotebookRepository
regrouped them by parent id. NoteTreeNode now derives uniffi::Enum (uniffi
supports recursive enums) and crosses as itself; Kotlin maps it with one
recursive toItems(). Deletes flatten_note_tree_nodes, NoteTreeEntry and
buildTree/buildChildren.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- LogConfig: crate::log::LogConfig derives uniffi::Record; the identical
ffi::types::LogConfig is gone.
- MusicLibraryLevel: music::LibraryLevel derives uniffi::Enum under that
name, so list_library takes it directly instead of converting from a
string-id copy.
- SchedulerRunSummary: run_scheduler_passes returns the scheduler's own
SchedulerPassResult (catalog / catalog_fetch / content) rather than
flattening it; SchedulerWorker and the Sync settings "Run now" status
read summary.catalog.peersSynced etc.
- Kotlin's ContactRow was ContactMethodItem field for field; the human
detail screen uses the generated record, and the note about staged rows
having no uri moves onto ContactMethodItem::uri.
NotebookItem stays: list_notebooks' (id, title) tuple is also what
gui-app and the daemon consume, so naming it is queued for the GUI batch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- copy_record! emits each of the twelve constant-copy records
(PlaceholderCopy, ActionLabels, … RevocationCopy's 27 fields) and the
#[uniffi::export] function that fills it, so each field is written once
instead of twice.
- The 22 one-line pass-throughs whose copy function already has a
uniffi-compatible signature (&str/u32/bool/ServiceId in, String out) are
gone: the copy.rs function itself carries
#[uniffi::export(name = "<the old name>")], so Kotlin's names do not
change. The rest stay wrappers because they take Option<&str> or usize
or return &'static str, which uniffi cannot lift or lower.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- format_artifact_value was a verbatim copy of
copy::artifact_value::summary (Gender words included); it now calls it
(frontends batch F2, queued for FFI).
- validate_partial_date re-derived what parse_partial_date and
parse_optional_partial_date already decide.
- Two doc blocks were glued onto parse_partial_date; the first belongs to
parse_optional_partial_date.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The FFI's job_to_item and list-jobs title lookup each spelled out which
subjects name a document and which a peer; the answer now lives on
JobSubject.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ffi/music.rs and ffi/import.rs each wrote
format!("{}{}.", clause[..1].to_uppercase(), &clause[1..]), which
byte-slices and so would panic on a multibyte first letter. They now call
copy::busy::sentence. The two gui-app copies are queued for the GUI batch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_page_bytes and prefetch_page_bytes differed only in whether the
remote fetch gets a job row; both now call a private page_bytes with the
runner or None.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EnsembleFfi::p2p_parts was SyncNodeCore::require_p2p under another name,
and push_document inlined it a third time; all three now call
require_p2p. The DocumentSyncResult built identically by
sync_document_assets and sync_document_asset is a From<DocAssetSyncResult>.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Six getters each loaded the settings row and defaulted the one field they
wanted; Settings::default() carries the same per-field defaults, so each
is now one line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The primary-content blob-ref lookup (4 copies) is primary_blob /
required_primary_blob.
- SearchParams is built by one search_params for search and search_page.
- The two DocumentNodeStatusItem builders (node statuses, blob-ref rows)
share NodeLabels, which also carries the local pubkey and peer names
they each looked up.
- list_children builds DocumentListItems like search_page does, so
size_annotated_items takes those instead of a 6-tuple.
- SyncNodeCore::settings() reads settings-or-defaults once.
Bug fix: document-detail property, artifact and node-status times used a
bare "%b %d, %Y %H:%M" in UTC while the Placement table beside them
honored the user's timezone; all now go through
Timestamp::format_display(tz), so they read in the user's zone with its
suffix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 17 accessors were identical apart from their name and handle type;
ffi_handles! emits them as one #[uniffi::export] impl block, with the
shared explanation on the macro rather than repeated on each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The custom types already cross as plain strings (Kotlin typealiases), so
about 60 methods that took `*_id: String` and parsed it on the first line
now take a DocumentID, peer-taking methods take a NodePubkey, and methods
and records that returned `id.to_string()` return the id. Deletes
parse_document_ids, the JobSubjectRef parse closures, and ~100 parse and
to_string lines. No Kotlin change: the generated types are still String.
NodePubkey's try_lift now fails with SyncError::InvalidInput, so a
malformed peer id still reaches Kotlin as a typed SyncException (test
added). DocumentID and Timestamp declare their custom_type in documents/,
outside this batch; until theirs do the same, a malformed document id
surfaces as SyncException.Internal rather than InvalidInput (only reachable
through a Kotlin bug, since every id Kotlin holds came from Rust).
DocumentItem keeps String fields: it is built from the store's
DocumentListItem, which still carries strings.
Also restores HumansFfi::delete_interaction's doc comment that the Kotlin
wrapper used to carry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After Android's A1/A2 batches these had no Kotlin caller and no Rust caller
elsewhere: content_fetch_stall_hint_ms, compile_typst and
compile_typst_with_fonts (with FontData — so F18's parallel-array wrapper
goes with them), notebook_default_item_name, ComicsFfi::get_metadata and
get_reading_direction, SettingsFfi::get_document_sync_override, and the
four single-file imports (ComicsFfi::import_archive, PdfFfi::import,
ImportFfi::image/music_track) that ImportBatch::import_file replaced —
taking import_document_with_blob and run_one_file_import with them.
EnsembleFfi::get_gossip_neighbor_count is inlined into its one Rust caller.
Doc comments that named the deleted paths now name ImportBatch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
with_store(|store| body) only called f(&self.store()), so every one of its
~115 call sites paid a closure, an Ok(..) wrap and an extra indent for
nothing. Bind `let store = self.core.store();` once per method instead and
let `?` work directly. Mechanical; no behavior change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ensemble.rs mapped every RetireError, RetirementError, PingError,
AssetSyncError and InitError to SyncError::Internal, so a wrong keyphrase,
"already in an ensemble", a ping timeout or an unreachable peer all
reached the user as ReportBug. Add From impls in error.rs beside their
neighbors (plus RevocationError and the revocation/epoch stores' Busy) and
let `?` convert; sync_document_asset's hand-written UnknownAsset special
case is now the AssetSyncError impl.
Bug fix: those failures now carry the right Recourse (AmendInput for a bad
keyphrase, RetryLater for an offline peer, ResolvePrecondition for setup).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The `?` overlay left out the Documents screen's `d` (show/hide hidden
documents) and the whole Jobs screen; both are listed now. The Jobs
screen's module doc said the daemon had no JobRunner to cancel through; it
has had one since the scheduler driver — what is missing is a protocol
command.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`linkage pair` told the user to run `syn node daemon start`; the node
commands are top-level (`node` is only a help group), so it is `syn daemon
start`. And a handful of CLI messages, plus the extension popup, said
`syn-cli …` where everything else — the other hundred-odd messages across
lib, daemon, CLI and TUI, and the docs — says `syn …`; they now agree. (The
built binary is still named `syn-cli`; that naming question is in the
handoff notes.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`E` wrote the whole buffer back unconditionally once $EDITOR exited — the
design principle's "stop writing over it" violation: a whole-buffer write
is applied as a diff against what the store holds now, so a body that
arrived from another node while the editor was open was deleted from the
merged text everywhere. It also wrote when nothing changed or the editor
failed (vim's `:cq`), and ran an EDITOR with arguments (`code -w`) as one
program name.
Now: the editor runs through `sh -c '$EDITOR "$1"'`, as git does; a failed
exit or an unchanged file writes nothing and says so; and before writing,
the note is re-read. If its body moved meanwhile, the edit is not written —
it opens in the TUI's own editor as an unsaved buffer over the original
base, with the arrived version held as `incoming`, which is exactly the
state `on_documents_changed` produces: "⟳ changed elsewhere", `:e` loads
theirs, `:w!` keeps yours, and nothing moves until the user picks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both loaded every document — a `Document` built per row — only to take the
length, and the browser extension sends `status` to each node every time
its popup opens. `DocumentStore::count_documents` is the same count as one
`SELECT COUNT(*)`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The daemon's own copy of the tree walk never checked `is_canceled`, though
the comment above the per-path loop said it did, so `syn jobs cancel` on a
large directory import ran on to the end of the directory. It now checks
per entry, as lib's walk does. (F10 — sharing lib's walk instead — needs a
collection hook and a caller-owned progress in lib's API; left for the lib
batch.)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one-line artifact summary ("In progress: m:ss", "Bookmark: page N",
"(sealed)", …) was written three times — lib's FFI helpers, the desktop's
document detail, and the daemon's `docs show` — differing only in how much
body text each keeps; `Gender`'s display words likewise (FFI, daemon,
CLI). `copy::artifact_value::{summary(value, max_chars), gender}` is new
code in lib (nothing existing there is changed), and the daemon and the
CLI's `humans show` now use it; the daemon's blob-backed arm stops being an
`unreachable!`. Switching the FFI helper and the desktop's copy over is
queued for those batches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cli-app named `iroh` and `log` but uses neither (`log` only appeared in a
doc comment; logging goes through `synchronicity::log`), and tui-app named
`serde_json` without using it. syn-daemon's dev-dependencies re-listed
`chrono`, `serde_json` and `uuid` from its normal dependencies under a
comment claiming integration tests link only against dev-dependencies —
they link against the package's normal dependencies too (B12). No version
changes; Cargo.lock only loses the three edges.
Left alone: the workspace's two argument parsers (clap in gui-app, bpaf
elsewhere) — a design choice with little LOC in it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`globalThis.browser ?? globalThis.chrome` was defined in linkage.js,
permissions.js and background.js; it is `src/api.js` now. And
`rebuildMenu` created the identical root item in two branches; it creates it
once and adds per-node children only when more than one node is linked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `commanded_shutdown`'s changed()/borrow() loop is `watch::Receiver::wait_for`.
- The unused `/status` route and `handle_status` go: no Rust, JS or test
caller; the extension sends the `status` command.
- `shutdown_signal` no longer `expect`s on installing its signal handlers;
one that can't be installed is logged and never fires (and the
not-unix branch of a Unix-socket daemon goes).
- `serve` takes the PID path instead of `socket_path.parent().unwrap()`.
B14: `kill_existing_daemon` shelled out to `kill` and ignored the result,
then after five seconds deleted the socket out from under what could still
be a running daemon — which kept the store lock and became unreachable. It
now sends SIGTERM with `libc::kill` (already a dependency), reports a
failure to signal, and refuses to proceed if the daemon has not exited
rather than removing its socket.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The daemon's and the desktop's timer loops each carried the same body:
load the scheduler settings, run_all_passes when enabled, log the identical
"tick complete" / "tick skipped" sentence, and sleep for the interval
floored at a MIN_INTERVAL both defined. That body is now
`scheduler::tick(store, job_runner, endpoint, local_node) -> Duration` (plus
`MIN_TICK_INTERVAL`), added to lib without touching its existing code, and
the daemon's driver is a loop around it. The desktop driver's switch is
queued for the GUI batch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
About twenty positionals were `id: String` / `parent_id: String`, each
turned into a DocumentID by a `parse_id` call in main.rs; `settings timezone`
likewise took a string it validated itself. bpaf parses any `FromStr` type,
so these are `DocumentID` and `Option<chrono_tz::Tz>` now, and a bad value
fails at parse time with bpaf's usage error ("couldn't parse `x`: …")
instead of "invalid document id: x". `linkage revoke` keeps its string and
hand parse, whose error points at `syn linkage list`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Document ids in the protocol's rows and snapshots were `String`, so the
daemon stringified DocumentIDs it held and the TUI parsed them straight back
(`doc_item`, `parse_id`, `DocumentID::from_str`), keying its expanded-row
sets by `to_string()`. They are `DocumentID` now — a serde newtype over
Uuid, so the JSON is the same string — and the TUI's Documents screen keeps
the daemon's `DocumentItemRow`s rather than copying them into `DocItem`s.
`DeletedSnapshot` stays a string: credential-clear answers with a service
name in it.
`RetirementSnapshot.outcome` is `RetirementOutcome` (snake_case, as before)
rather than a string the CLI matched by spelling, and `ListDocuments.sort` is
`DocumentSort` (kebab-case, as before), parsed by bpaf for `--sort`.
B7: an unknown `--sort` value was silently treated as `name`; it is now a
usage error naming the three it accepts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Documents' pull and Ensemble's actions each hand-built the thread + mpsc +
try_recv + Disconnected dance; both now use `background::Pending<M, T>`
(spawn with the metadata the screen shows, `poll` for the outcome). A worker
that dies without reporting is now an error outcome like any other: on the
Ensemble screen that lands on the member's row instead of a popup.
Also: the Ensemble screen's "{participle} {label} already — wait for it"
guard (3 copies) is `busy()`, `reload` is `on_nodes_changed` plus the
default status rather than a copy of its body, and `start_action` takes the
command to send instead of an `unreachable!` arm for the prompted kinds.
Notes' "reload whichever view is showing" (4 copies) is `reload_view`, and
the ItemKind → delete/rename command mapping (2) is `ItemKind::{delete,
rename}`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The store-setup helper existed three times (setup_test_node, setup_node,
setup_source), the keyphrase parser twice and parse_ensemble_pubkey twice;
they are now `common::{node, parse_keyphrase, parse_ensemble_pubkey}`, and
common.rs loses the nine per-item `#[allow(dead_code)]`s its file-level one
already covered. The seeds and `setup_store` live in tests/support/seeds.rs,
which the dev example includes by path instead of carrying its own copy.
p2p_ping wrote its stores into the source tree (tests/stores/), which is why
`DaemonGuard` had to delete the socket a killed daemon leaves (Nix refuses a
tree containing one) — and, in this checkout, why the daemons failed to start
at all: the socket path exceeded SUN_LEN. It uses TempDirs now, and the guard
only kills. B8: its outsider test reused NODE_A/NODE_B's seeds while the
other test's daemons ran, so two live daemons could share an iroh node id;
it has seeds of its own. B10: the module doc named `syn p2p node-addr`,
which does not exist.
Also `AppState::new` replaces the four hand-built AppState literals in the
daemon and its tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`EnsembleSyncSnapshot` → `PeerSyncCopy` was written identically in the CLI's
format.rs and the TUI's ensemble screen; the job-state words were hardcoded
in the CLI and taken from `copy::job_state` in the TUI; and the TUI's
Documents screen wrote its own asset-sync sentences, which disagreed with
the shared `copy::asset_sync` ones the CLI used ("was already on this node"
against "is already on this node", its own peer-count phrasings).
Now `EnsembleSyncSnapshot::summary`, `AssetSyncSnapshot::summary(label)` and
`JobSnapshot::state_text` live beside the snapshots; the frontends only pick
colors. The TUI's pull status line therefore changes to the shared wording —
the copy-rule fix, not a new sentence.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`server::run`'s Foreground/Restart/EnsureRunning arms each repeated the
stale-socket removal, the store-lock check, the log-file setup and
Daemonize builder, proc_title plus logger re-init, and runtime → serve →
cleanup. They now share one sequence; only what differs (bail, kill, or
return when a daemon is already live; write a PID file, `start`, or
`execute` and wait) stays per mode.
Socket liveness (probed six ways), the PID-file read, and the
poll-until-deadline loop become `syn_daemon::{socket_is_live, read_pid,
poll_until}`, and the CLI's `DaemonStatus` and `wait_for_daemon_exit` move to
`syn_daemon::client` beside `ensure_daemon_running`, which uses the same
wait.
Needs a manual check: `syn daemon start`, `start --foreground`, a TUI
auto-start, and a restart over a running daemon.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Written once each instead of per call site: the `[y/N]` confirmation (4
copies), the keyphrase prompt (two near-identical functions, now one taking
its flag hint and empty-input message), reading stdin to a string (2),
SecretFormat from `--format-a` (2), the WRITE-DOWN-YOUR-KEYPHRASE block shared
by `ensemble init` and `reseed` (2), a DHT registry row (2), a hand-rolled
hex loop (4), the `%Y-%m-%d %H:%M UTC` format (5) and the 14-column dim label
(2). Output is unchanged: each phrase-row layout and every sentence stays
as it was.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
commands.rs spelled out `local_node_pubkey()?.ok_or_else("node not
initialized …")` twelve times, the best-effort mark_present-with-warning
block four times, NotebookManager-plus-local-node for each of Notebook, Note
and Collection, the same SearchParams twice in ListDocuments, and the
DocumentListItem-to-row mapping three times. Each is now written once:
`local_node`, `mark_present`, `notes`, and `From<DocumentListItem>` for
DocumentListRow/DocumentItemRow. The ensemble-members pubkey uses
`NodePubkey::to_hex` rather than a hand-rolled hex loop.
`syn docs search` now answers from `search_document_items` — one query
carrying each title — rather than `search_documents` plus a title read per
match (N+1). Same filters and ordering; the title match is SQLite's LIKE, as
the desktop and Android document search already use, so case-folding is
ASCII-only where it used to be Unicode lowercase.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every screen carried its own copy of `centered_rect` (×5), the error popup
(×4), the focused-border style (×6), the wrap-around move_up/move_down (×4),
the list highlight, the tab span, the padded label/value line and the
by-id selection restore; `app.rs` then matched on AgentId in five places to
reach the same four screens.
`ui.rs` holds one of each — `pane`, `error_popup`, `highlighted`, `step`,
`restore_selection`, `tab_span`, `field` — with popups centered by ratatui
0.30's `Rect::centered` rather than a hand-built three-way Layout (a popup
can move by a cell on odd remainders). Each screen implements `ui::Screen`
(render, handle_key, status_line, is_typing, error), and `App::screen()` is
the one AgentId match left.
Terminal setup and teardown use `ratatui::try_init`/`try_restore`, which also
restore the terminal on a panic. `handle_key` no longer threads the terminal
through every screen for the one that needs it: Notes suspends itself around
`$EDITOR` and raises `needs_full_redraw`, which the loop answers with a
`terminal.clear()` before the next draw.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A leaf command's handling was split three ways: a pre-dispatch or
`build_command` (five `unreachable!`s for arms the pre-dispatch had already
taken) picked the DaemonCommand, `send_command` sent it, and
`format_response` matched on the command again to pick a body type and
renderer — about 25 of its arms raw or dead, because no CLI command sends
those commands. The dead EnsembleBumpEpoch arm had already drifted from the
live `renew_membership` output (B13).
Each leaf now names its command and its renderer in one
`client::run(store, start, cmd, render)` call; `format::*` are plain
`fn(Snapshot) -> String`. The exhaustive CLI-enum matches keep every command
deciding its rendering, and `handle_ensemble` loses its `unreachable!()` arm.
Commands that auto-started the daemon still do; ensemble addr/ping/sync still
require one the user started.
B5: a failed command now returns its error to `main` rather than printing
"error: …" and calling `process::exit(1)` itself, so it gets main's
"Error:" rendering like every other failure. A daemon body this build cannot
decode is now an error rather than a line printed as if it were an answer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`handle_command` carried eleven hand-written special cases, each repeating
the require_p2p / success / error ladder, and `dispatch` was synchronous, so
every async or P2P command was an `unreachable!` arm in it — reachable in
practice: the CLI's no-daemon fallback could hand it `ImportUrl` and panic.
`commands::run(cmd, &Ctx { store, job_runner, p2p })` is now the one
exhaustive match. A command that needs P2P asks `cx.p2p()?`, which answers
the same "P2P node is not running on this node" the server tests assert;
EnsembleStatus still degrades without P2P; AwaitChange still parks on
spawn_blocking; SetNodeName's best-effort gossip re-announce stays in the
server, the only process with a runtime to re-announce through. Shutdown and
pairing, which live in the serving process's memory, are answered by the
server and are an error (not a panic) anywhere else.
The CLI's daemon-or-direct branches (import, import-url, rename, timezone,
node_info/ensemble_members/document_detail wrappers) collapse into one
`client::query_store_as`, which runs `commands::run` in-process when no
daemon responds. The friendly IANA timezone error moves into the SetTimezone
arm so both paths give it (B15). Ping and retire timeouts use
`Duration::try_from_secs_f64` rather than the panicking constructor, and the
node-runtime mutex no longer unwraps on poison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ~40 hand-written "match the value, Some on the one variant I want,
None otherwise" blocks around get_property and artifact reads become
get_value(..)?.and_then(PropertyValue::try_as_text) / .try_as_u32() and
a.value.try_as_reading_position() and friends. Deletes music's
u32_property and clippings' text_property (the one-off readers these
replaced) and shortens comics' order/reading-direction/page-count readers
to one line each. The five derived-id artifact writes in facts, contact
and podcast_queue use Artifact::with_id instead of spelling out the struct
with Timestamp::now().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mechanical conversion of every write_property(tx, &Property::new(id.clone(),
kind, PropertyValue::X(v), node)) in the agents, documents, credentials,
ensemble and the desktop's detail view to set_property(tx, &id, kind, v,
node); rustfmt laid each old call out over nine lines. Music's local
write/write_or_clear closures now wrap set_property/set_or_clear. Tests get
a matching test_support::set_property. Literal .to_string() values drop
the conversion now that &str converts into PropertyValue. Also picks up a
clippy redundant-closure (Timestamp::now) the previous commit introduced.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keys_with_known_holder bound two parameters per key with no chunking, so
document_availability_bulk over a library of more than ~16k missing items
failed with "too many SQL variables" (audit B1); it and holders_of (which
chunked by hand, citing a stale 999 limit) now go through query_in_chunks
with a row-value list. ContentPresence derives strum instead of its
hand-written as_str/parse, and the readers take NodePubkey and Timestamp
straight from the row. NodeContentStatusError's InvalidTimestamp,
InvalidNodePubkey and UnknownPresence variants had no remaining
constructor and are gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Document, Property, Edge, DocumentListItem, SetElement and the three
tombstone rows each had their own read-strings-then-parse copies; now
DOCUMENT_COLS + document_from_row, PROPERTY_COLS + property_from_row
(shared by properties and property_history), list_item_from_row (shared by
search_document_items and ancestors_of) and small per-row mappers,
through the stores::sql codecs. Writes bind typed values; property rows
bind through one property_params.
- load_documents_by_ids was an N+1 (one SELECT id, then one get_document
per id) behind list_documents(_by_type), list_children(_page),
list_root_by_type, find_documents_by_title and search_documents; each
now selects the document columns directly in one query.
- get_property was a byte-for-byte copy of get_property_in_tx; it now
calls it.
- list_properties_for_documents, descendants_of and ancestors_of go
through query_in_chunks, so a list larger than SQLite's bound-parameter
limit no longer fails (audit B1). ancestors_of now drops matches and
cross-chunk duplicates in Rust rather than with a second bound id list.
- list_tombstones_since is one statement (?1 IS NULL OR deleted_at >= ?1).
- DocumentStoreError's Json, Uuid, InvalidTimestamp, InvalidNodePubkey,
Unknown{DocumentType,PropertyKind,EdgeKind,Provenance} variants had no
remaining constructor and are gone, with their ffi/error.rs arms that
reported corrupt rows as SyncError::InvalidInput (audit B12).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Artifact row was decoded five times (get_artifact, get_artifact_by_id,
list_artifacts_of_kind, get_artifacts_for_documents, list_artifacts) and
the ArtifactRef row three times, each reading strings into a tuple and
parsing them in a second pass; now ARTIFACT_COLS + artifact_from_row and
artifact_ref_from_row, through the stores::sql codecs. The three copies of
"deserialize the value, take its blob_ref or fail" are blob_ref_of, the
four byte-total queries share bytes_by_document, singleton_flag() == 1
becomes is_singleton(), and writes bind typed values (BlobHash gains a
text codec). Enables rusqlite's fallible_uint so u64 sizes bind and read
without as-casts.
Every bulk IN (...) query here now goes through query_in_chunks, so a list
larger than SQLite's bound-parameter limit (32766 in the bundled build)
no longer fails with "too many SQL variables" (audit B1).
unfulfilled_stub_ids_for_documents and list_blob_refs_for_documents are
generic over the id type, so availability passes its DocumentIDs straight
through instead of stringifying them to look results up by string.
ArtifactStoreError's InvalidTimestamp, InvalidNodePubkey, UnknownKind,
UnknownDocType and BlobHash variants had no remaining constructor and are
gone, and ffi/error.rs no longer maps artifact row-parse errors to
SyncError::InvalidInput: they come from the store's own rows, so they are
Internal (audit B12).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SELECT column list was written three times and each read went through
a Columns tuple, row_to_columns and linkage_from_columns; now LINKAGE_COLS
and linkage_from_row, and authenticate reuses one() now that one() takes
its value as &dyn ToSql. LinkageStoreError's InvalidTimestamp and
InvalidUuid variants had no remaining constructor and are gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_peer_nodes_for_ensemble read blobs and strings into an 8-tuple and
converted each by hand; peer_from_row now reads [u8; 32] and Timestamp
directly through rusqlite and the stores::sql codecs, and writes bind the
arrays and timestamps as themselves. A row that fails to read is still
skipped, as before (B7, errors swallowed by that skip, is left for a
separate change). PeerNodeStoreError::InvalidTimestamp had no remaining
constructor and is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
row_to_job read eleven columns as strings and handed them to an
11-argument build_job under #[allow(clippy::too_many_arguments)]; it now
reads each field through the stores::sql codecs (TextUuid, JobKind,
Timestamp, Json<JobProgress>) and only parses subject/origin/state by
hand. Writes bind the typed values too. JobStoreError's Json, Uuid, Parse,
UnknownKind and InvalidTimestamp variants had no remaining constructor and
are gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The four readers each read columns as strings into a tuple and rebuilt a
NodeDocumentRecord by hand; they now select the same RECORD_COLS and map
with record_from_row through the stores::sql codecs. NodeDocumentPresence
derives strum instead of its hand-written as_str/FromStr, the three mark_*
functions share one private mark, and list_nodes_for_documents goes
through query_in_chunks, so a page larger than SQLite's bound-parameter
limit no longer fails. The error enum's parse variants (InvalidTimestamp,
InvalidNodePubkey, UnknownPresence, InvalidDocumentId) had no remaining
constructor and are gone; a corrupt row now reports as a database error
whose source names what failed to parse.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ToSql/FromSql for Timestamp (written exactly as to_rfc3339(), since the
SQL compares those strings; rusqlite's chrono feature is deliberately not
used), DocumentID, VaultId, NodePubkey (hex), and the strum enums stored by
name (DocumentType, PropertyKind, ArtifactKind, EdgeKind, Provenance,
JobKind) via sql_strum_text!; a Json<T> wrapper for JSON BLOB columns; and
TextUuid for plain Uuid TEXT columns (rusqlite's uuid feature would bind a
BLOB). A column that fails to parse reports a ColumnParseError naming the
type and the text found, inside rusqlite's FromSqlConversionFailure.
placeholders() moves here from document.rs, alongside query_in_chunks,
which splits an IN (...) list across statements under SQLite's
bound-parameter limit. Stores switch over in the following commits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mechanical: every Timestamp(chrono::Utc::now()) and Utc::now().into()
that produced a Timestamp in the lib now reads Timestamp::now(); three
files lose their now-unused chrono::Utc import.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds DocumentStore::set_property (write a fresh local value, taking
anything Into<PropertyValue>), set_or_clear (write when present, else
remove_property_if_present, generalizing music's local write_or_clear) and
get_value; From impls into PropertyValue for String/&str/Vec<String>/
Timestamp/PartialDate/f64/u32/bool; strum::EnumTryAs on PropertyValue and
ArtifactValue (the two numeric PropertyValue projections are hand-written
as try_as_u32/try_as_f64 because the derive would call them try_as_u_32);
Timestamp::now(); and Artifact::with_id for derived-id artifacts, which
Artifact::new now calls. Call sites convert in the following commits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The five hand-written adapter fns that forwarded seven arguments to each
agent's importer and flattened its error to a String are now one
import_fn! macro producing a non-capturing closure, which coerces to
ImportFn in the const table. The comic directory importer already had the
right signature and goes in directly; import_comic keeps its body for the
cbr feature branch. Also folds import_tree's child_parent/next_parent
two-step and matches_path's map(..).unwrap_or(false).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nine pub fns with no caller anywhere in the workspace or behind uniffi:
ArtifactStore::{artifact_bytes_per_document, blob_bytes_per_document,
count_artifacts, documents_with_unfulfilled_stubs},
DocumentStore::count_properties, JobStore::set_running,
BlobStore::wipe_all_blobs, PeerNode::first_seen_date_str,
import::all_document_extensions, plus TextFormat's unused lenient FromStr.
The doc comments that linked the deleted functions (two of them intra-doc
links that would now be broken) are reworded to stand on their own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five SectionHeadings on the System settings screen repeated the same
top/bottom padding; SettingsHeading carries it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The node heading, the member list and the empty state were each wrapped
in the same screenData?.let { if (!loading && error == null) … }; one
takeIf over the three says it once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PingControl and SyncControl each spelled out the same 32dp outlined
button and the same ok-in-success-color / error-in-error-color status
pair; CompactActionButton and OutcomeText say them once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NotesContentArea took 30 parameters and passed 22 straight on to
NoteEditorPane; it now takes an editor slot and hands it the modifier
carrying the swipe/offset handling, so NotesScreen calls NoteEditorPane
directly. SplitBody likewise takes the preview as a slot instead of
PreviewBody's ten inputs, and the left and right sidebars share one
SlidingSidebar for their enter/exit animation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The note editor ran the incoming-body rule inline in NotesScreen (load
theirs, keep mine, observe, the post-save baseline) over three session
fields, while the clipping session had the same logic as methods over
three fields of its own. IncomingBodyBuffer, beside IncomingBody.alertFor,
now holds text/stored/incoming and the transitions; each session owns
one and keeps its existing names (body, text, incoming) by delegation.
Ordering is unchanged: keepLocal still moves the baseline to their
version and clears the hold before the caller asks for the write, and
the note's save gate (and so autosave/flush) still refuses the body
while a version is held. The two identical activity-scoped
rememberXSession helpers become one activityViewModel<T>().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Type.kt restated all fifteen Material 3 styles to change four weights
and one size. It now takes Material's Typography() re-pointed at
Typeface.UI (the existing withFontFamily) and overrides only
headline*/titleLarge (SemiBold) and labelSmall (12sp). Its comment no
longer claims only used styles are overridden.
Needs a visual check: Material's default styles carry its default
lineHeightStyle/platformStyle, which the hand-built TextStyles did not,
so line metrics may shift slightly; displayLarge (unused) picks up its
-0.25sp tracking.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md's style rule: use an import rather than a fully qualified
type in code. Covers the remaining TextOverflow, ExperimentalLayoutApi,
java.time, java.io/java.net, android.util.Log and android.graphics.Color
references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The five Card/Column(16dp, 8dp)/SectionHeading openings become one
DetailCard(title, trailing).
- The ID row, which re-implemented MetadataRow around a HashChip, uses
a slot overload of it; its unused valueStyle parameter goes.
- PropertyRow and ArtifactRow share KindAndSummary (kind, modified-at,
three-line value).
- The load chain's four sequential getOrElse { fail; return } reads are
one ffi block on Dispatchers.IO, as are the document and descendant
count reads that were on the main thread.
- Fully qualified TextOverflow references become an import.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The type and date filters were two ~45-line ExposedDropdownMenuBox +
read-only anchor blocks, each with its own expanded flag hoisted to the
screen; FilterDropdown owns its expanded state and hands its menu a
close(). The two removable chips become RemovableFilterChip, and the
set toggle written out twice becomes Set.toggle. The type and MIME
tables are read once with remember instead of a LaunchedEffect wrapped
in a try/catch around two infallible FFI calls.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five OutlinedTextFields on the human detail screen hand-copied
HumanTextField's HUMAN_FIELD_MAX_LEN guard. HumanTextField now takes an
optional label and a modifier, so the gender freeform, fact key and
value, and contact value and label fields use it; the length cap is
written once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PodcastSearchScreen registered its own BackHandler inside a
SubAppScreen, which already has one. PodcastInfoScreen set a
shouldRefresh flag after subscribing to trigger reloadPodcasts(), which
subscribeToPodcast (and unsubscribeFromPodcast) already call.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The music and podcast screens each ran the same while(isPlaying)
{ startPositionUpdates(); delay(1000) } loop, and startPositionUpdates
was really a one-shot refresh. The transport now starts a ticker from
onIsPlayingChanged and stops it on pause (refreshing once, so a paused
bar shows where playback stopped rather than up to a second before).
The literal log tag repeated through the file becomes a TAG constant.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The last two string-route graphs move to @Serializable routes like the
rest of the app: Documents' sealed DocumentsDestination (with its fully
qualified navArgument/NavType, against the style rule) and Ensemble's
EnsembleRoute. The String overload of AnimatedNavHost then has no
caller and goes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The four initialization screens each spelled out the same headlineLarge
centered title and the same full-screen background-plus-32dp ground;
InitTitle and Modifier.initStep say it once. The two-column mnemonic
grid walks displayLines.chunked(2) instead of indexing by twos.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Booting into Main and finishing initialization both started the P2P
foreground service and then scheduled background sync; startNodeServices
does both. The ENSEMBLE and SETTINGS agent branches differed only in the
initial section, so they share one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seven near-identical alertFor tests become one table of named cases,
each keeping its reasoning as a comment; the case name is the assertion
message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cache was a hand-rolled access-ordered LinkedHashMap with its own
eviction loop and clear(). LruCache does both; entryRemoved recycles an
evicted page's bitmap as the loop did, and evictAll() replaces clear's
recycle-then-clear.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JobUtils.kt was two one-line aliases of the uniffi jobKindLabel and
formatTimestamp; call them directly. PushJobRefs/asPushRefs paired each
id with its short form before building the push label, but IdRef already
takes a nullable short id. JobStatusLine's three identical
onSurfaceVariant branches collapse into the else, and the running-row
spinner is an InlineSpinner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FlatButtonWithIcon was FlatButton with a composable icon slot and no
enabled state; FlatButton takes an optional ImageVector instead. The
start-aligned nav-row button is renamed IconRowButton so it no longer
shadows Material's IconButton (a bare glyph) in files that import both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PdfFilePicker and ComicFilePicker were the same button with a different
MIME type, and VideoScreen inlined a third; ImportFileButton replaces
all three. importPdf, importComic, importVideo and importDocument each
resolved a name, read the bytes on IO and called a per-type import;
NativeLib.importPickedFile does it once, through the generic batch
import (Rust's SUPPORTED_FILE_TYPES dispatch) as a one-file tracked job.
Behavior differences:
- Documents' single-file import now handles every type folder import
does (video and comics too, not just PDF/image/mp3) and names a PDF by
its full filename like the PDF library and folder import, not its stem.
- A failed video import is now reported through the snackbar; it was
only logged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seven screens each held a mutableIntStateOf(0), bumped it from
OnStoreChange(DOCUMENTS), keyed their load effect on it, and pasted the
same three-line comment above it. rememberReloadKey returns that counter
(still bumpable by the screen after its own writes) with the comment
written once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The same TabRow + forEach(Tab) loop was written out on Jobs, document
detail, human detail, the podcast library, the media library and
Settings. SectionTabs<T> takes the entries, a label and optional icon,
and a scrollEdgePadding for the two scrollable rows.
Also fixes human detail losing its tab on rotation: it kept the section
in remember, where every other detail screen uses rememberSaveable.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ui/Pickers.kt adds SegmentedChoice<T> (the SingleChoiceSegmentedButtonRow
+ forEachIndexed + itemShape block written out eleven times), LabeledSwitch
(label-left/switch-right rows) and ExplainerText (the receded explainer
tone). Built on them:
- SyncOverrideControl and RemoteOverrideControl become one OverrideControl
taking an isSelected predicate, a pending flag and an optional
description.
- SyncPolicyControl and RemoteSyncPolicyControl share a PolicyPicker
(name, picker, description, held/missing lines).
- The note toolbar's FormatDropdown and ReadingSurfaceDropdown share one
CompactDropdown<T>.
The remote override's "Keep here:" caption is now bodySmall like the
local one (was labelSmall).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ui/States.kt gathers the shapes screens kept re-typing: CenteredMessage
(MediaLibrary's private CenteredNote, with an optional glyph) replaces
the empty/failed panes on Jobs, the podcast queue, the document list,
new-episodes, podcast search and document detail; CenteredLoading the
four centered first-load spinners; InlineSpinner the small in-row and
in-button spinners; ErrorCard the errorContainer cards on Ensemble,
Node key and Retirement. RemoteFetchOrSpinner folds the identical
remote-fetch-or-spinner branch of the picture viewer and comic reader.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five hand-built confirm AlertDialogs (notes delete, human delete, job
history clear, purge, retire/reset) become ConfirmDialog, which closes
itself before running the act. Notes' InputDialog moves to ui/ with
accept/isValid/keyboardOptions, and replaces the new-person and PDF
go-to-page dialogs.
Human delete's confirm word is now error-colored like every other
destructive confirmation (it was the one exception).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The eight ui/*Copy.kt wrappers were each a remember { x() } or a plain
alias, and actions() was called ~40 times directly inside composables,
so every recomposition paid an FFI hop and an allocation for a constant.
One object with lazy vals holds each record for the process lifetime,
with one consolidated KDoc.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NativeLib carried a wrapper for each top-level uniffi function that
needs no node (listTimezones, buildInfo, parseMarkup, renderMath, ...).
Callers now import the uniffi function; the fallible ones
(generateEnsembleKey, validatePartialDate, generateQrCodePng,
renderMath) are wrapped in ffiCatching at the call site, so their
Result handling is unchanged. The UInt/ULong conversions the wrappers
did move to the one caller each.
compileTypst, compileTypstWithFonts and notebookDefaultItemName had no
callers and go. With the wrappers gone, NoteEditorPane, TypstPreview
and MathContext no longer need a NativeLib at all, and QrCodeScreen and
GenerateEnsembleKeyScreen lose their nativeLib parameter.
Audit F18 (ffi report).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 18 *Agent.kt classes (about 1,500 lines) were ~175 methods of the
form ffiCatching { nativeLib.node().podcast().x(a) }, each with a KDoc
restating the Rust doc comment that uniffi already copies into the
generated bindings. They are replaced by one member,
internal inline fun <T> ffi(block: SyncNode.() -> T): Result<T> =
ffiCatching { node().block() }
so a call site reads nativeLib.ffi { podcast().queueAdd(id) }. The
Result totality guarantee is unchanged: every call still goes through
ffiCatching, including the NotInitialized case.
The few methods that did more than forward stay, as small pieces:
- NativeLib.timezone()/nodeName() (log and collapse to null, 9 callers)
and NativeLib.setLogConfig (also updates the Android log filter)
- NativeLib.beginImportBatch + BatchImport, moved to BatchImport.kt
- the one-caller "absent -> null + log" readers (comic pages/covers,
pictures, blobs, podcast cover art, documents.get) became a generic
Result.getOrLog(tag, what) at the call site, keeping each message;
isBlobLocal/fetchBlobToPath/pagesNeedingPrefetch are inlined likewise
- UInt/ULong conversions and Array->List moved into the call
Call sites that used named arguments keep them, under the uniffi names.
nodeInitialized/resetNode lose their NativeLib wrappers the same way.
Not carried over, having no callers: ComicsAgent.getMetadata and
getReadingDirection, EnsembleAgent.getGossipNeighborCount,
InteractionsAgent.setAttendees, SettingsAgent.getDocumentSyncOverride.
Two small behavior differences: a call's arguments are now evaluated
inside ffiCatching (an exception while computing one becomes a failed
Result instead of propagating), and the best-effort readers log under
their caller's tag rather than "ComicsAgent" etc.
Audit F1 (android and ffi reports); the shape is recorded in the audit
handoff notes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing imports retrofit, okhttp's logging-interceptor, the retrofit
kotlinx-serialization converter, fragment-ktx or media3-ui (no
PlayerView, no layouts). Removed after grepping main, test and res, and
assembleDebug still builds. Version pins are left alone.
Audit F5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Zero callers or zero effect:
- rememberActionLabels / rememberPlaceholders (the non-composable
actions()/placeholders() are what every site calls)
- SyncException.isVanished
- PresenceLook.ARRIVING (of() never produces it) and rowLabel
- RemoteFetch's stallHintMs()
- SynShapes.Pill
- PendingMnemonic.continueToMain, always false, and its branch
- the Pomodoro sessionsSummary alias
- PomodoroViewModel.onCleared: the ticker runs in viewModelScope, which
cancels it anyway
- iconForDocumentType's note/notebook/collection branch, identical to
its else
- DocumentFolderPicker's default label, equal to its one caller's
- the template ExampleUnitTest (2 + 2 = 4)
Audit F4d. Not taken: DocumentDisplayModel.size / formatDocumentSize,
which the audit called unread — VideoScreen draws it as the subtitle.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Zero callers: the PodcastItem and EpisodeItem composables,
Podcast.episodes (only ever emptyList(), and the reason equality had to
skip it), PodcastEpisode.isDownloaded (never set true or read),
PodcastRepository's public episodes flow, getEpisodesForPodcast and
getPodcast, and AudioPlayerManager/AgentAudioPlayer seekBackward,
seekForward and getDuration with their MILLIS_PER_SECOND.
Audit F4c.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NotesModels.kt carried helpers from before the tree came from the store:
newId, addToCollection, removeItem, the tree renameItem, updateNoteBody,
placeholderNotebooks, and NoteBodyState.isEditable, none called anywhere
(isEditable's rule moves into the class doc). NoteItem.body was always
"" and never read. itemId() and NoteSearch's title() restated what the
sealed base can hold, so id and title are abstract there.
Audit F4b.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PdfScreen's only caller selects a PDF by path; the open-by-Uri and
open-by-bytes paths were dead: PdfRepository (PdfSource, SelectedPdf,
the StateFlow, selectPdf*/clearSelection), two of the three
PdfViewerScreen overloads, the PdfViewer(Uri) overload, and
MuPdfDocument.openFromUri/openFromBytes/getPageSize with their temp-file
cleanup. The documentId is always present too, so the viewer's
null-document branches (and the drawer's bookmarksEnabled switch) go.
The selection now travels as a @Serializable PdfViewerRoute(path, name,
documentId) instead of in a remember'd repository, which also fixes
rotating while a PDF is open: the recreated NavHost restored the viewer
route with no selection and drew nothing.
Audit F4a; fixes BUG4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
supports called nativeLib.node() outside ffiCatching, so a
NotInitialized SyncException escaped as a thrown exception instead of
landing in a Result — the one import-surface call that broke the
totality guarantee. It returns Result<Boolean> now, and the folder walk
treats a failure as unsupported.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Naming a node that never announced a name used nodeIdHex.take(8), an
end cut, against the rule that truncation cuts from the middle. Use the
shared shortId (the one HashChip uses) and drop the local SHORT_ID_CHARS.
Fixes audit BUG7.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The shared ExoPlayer was built with neither audio attributes nor
becoming-noisy handling, so podcasts and music kept playing over a phone
call and out of the speaker after the headphones came out. Two builder
calls: setAudioAttributes(DEFAULT, handleAudioFocus = true) and
setHandleAudioBecomingNoisy(true).
Fixes audit BUG6.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The notes tree is re-read outright on every OnStoreChange (which fires on
this node's own autosave too), on every save/rename/create/delete, and
NotebookRepository builds each CollectionItem with the default
isExpanded = true. So any sync or save reopened every collection the user
had closed — rows moving under the user's finger, against the first
design principle — even though NotesScreen's comment said the sidebar
kept its expansion.
Expansion now lives in NotesScreen as a rememberSaveable set of collapsed
collection ids, passed to visibleNoteRows and the sidebar, instead of on
the model. That deletes CollectionItem.isExpanded, toggleCollection and
the notebooks.map rewrite the toggle did.
Fixes audit BUG3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rememberSaveable held a List<PodcastSearchResult>, a kotlinx
@Serializable class that is neither Parcelable nor java.io.Serializable,
so rotating or backgrounding with results on screen crashed. The loading
flag was saveable too and could come back stuck at true after process
death. Both are plain remember now; the query and error text stay saved.
Fixes audit BUG2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
uniffi renders SyncException.message as "msg=<text>", and about fifty
sites showed it.message/e.message to the user, most with a per-site
fallback string that could never fire (a SyncException always has a
message). Add Throwable.userMessage beside SyncFailures.kt's detail
(detail for a SyncException, else message, else toString) and use it
everywhere a failure reaches the screen or a log line.
The folder import in DocumentsScreen now goes through
launchReportingFailure instead of a bare launch + catch-all + showError;
the snackbar text ("Couldn't import folder: ...") is unchanged, and it
gains the shared retry policy.
Fixes audit BUG1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clippy fixup for 'one ingest path for doc-sync pulls': the tests that moved
onto TestStore bind the stores by reference already.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Panics (audit B12): SyncCursor::encode swallowed an encode failure into an
empty cursor with unwrap_or_default — it now returns the error through
wire::encode; Android's DNS-over-HTTPS setup expect()ed the TLS config and
unwrap()ed two parsed socket addresses — it now reports a P2PStartError and
builds the addresses with SocketAddr::from. (That block is
target_os = "android" only and was not compiled here.)
Docs (audit B11): GATED_ALPNS carried two doc comments run together;
MembershipBatch had DocumentTombstoneBatch's doc glued on top and the latter
had none; build_membership_batch was documented as building tombstones;
node_admin claimed no membership check exists, which admission now
contradicts; the doc-sync session diagram omitted the tombstone and release
batches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build_ensemble_status wrote EnsembleMemberInfo out three times (this node,
gossip peers, registry-only members) with the same revoked/held/health
lookups, and keyed the revocation map by hex String — built with a
hand-rolled fold — so health_of/held_by parsed hex back into NodePubkey per
row. Everything is keyed by NodePubkey now and one row() closure builds all
three kinds. Adds a test pinning the row order, dedup, and revoked flag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EpochBump and RevocationRecord each carried to_bytes/to_blob/from_blob/
from_bytes, a many-argument signing_payload, and copy_from_slice parsing.
The SignedRecord trait provides the encoding once from payload(),
signature() and from_parts(); fields are read with a small Fields reader.
Records (and DelegationCert) are now built with a placeholder signature and
signed over their own payload(), so the free signing_payload fns and their
three identical call sites per type go away. Byte layouts are unchanged.
Also: the pkarr TXT-record decode loop is written once (registry_entries);
DelegationCert::create and revocation stamps use chrono instead of
SystemTime + expect; the cert payload doc now includes the epoch (audit
B11); and create_new_ensemble/join_local share register(), which also stops
setup_default_vault_key loading the node keypair a third time (so the
never-reachable InitError::KeypairMissing is gone).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sync_document_assets, sync_document_asset, and the scheduler's catalog-fetch
and content passes each walked peers fetching blobs under PEER_BLOB_TIMEOUT
and artifacts under PEER_ARTIFACT_TIMEOUT, recomputing what was missing
after each peer, with the two timeouts defined twice. asset_sync's
fetch_from_peers is the one walk: it takes what is missing now plus a
closure that recomputes it, and returns received counts, peers tried, and
whether the user canceled.
A canceled blob transfer counts what completed before the stop by
recomputing against the store, exactly as sync_document_assets did (and as
sync_document_asset's one-blob special case did); the content pass, which
used to count nothing on a cancel, now gets the same accounting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fetch_blob_bytes and write_remote_blob_to_file were the same peer loop with a
Vec or a file for a sink; stream_from_peers is the one loop, taking a sink
factory it calls afresh for each peer that has the blob (so a failed peer's
partial copy is never appended to). The two *_as_job wrappers share
finish_stream_job.
write_local_blob_to_file re-implemented BlobStore::load_to_file chunk by
chunk, re-opening and re-seeking the blob for every 64 KiB read and
reporting a read failure as 'no vault key locally' (audit B8). load_to_file
now takes an on_chunk callback that can stop the copy, and the local path is
that plus the byte tracker; its errors surface as ContentFetchError::Local,
mapped to the readers' existing BlobStore variants and at the FFI to
BlobStoreError's own classification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fetch_blobs_from_peer, query_blob_presence, fetch_artifacts_from_peer,
open_blob_stream, the node-admin calls, and deliver_revocations took a hex
&str (or an EndpointAddr) and re-parsed it, while the push fns already took a
NodePubkey. All take NodePubkey now, and wire::open converts it to an
EndpointId once; the per-protocol InvalidTarget variants fold into
WireError::InvalidPeer (still InvalidInput at the FFI).
The peer lists follow: scheduler::peer_order_by_presence,
content_fetch::peer_candidates, asset_sync's peers, the three
ensemble_peer_ids helpers (gui, daemon, ffi) and retirement::PeerLabel hold
NodePubkeys instead of converting to hex and back, which also retires the
two parse_peer copies whose failure silently dropped possession evidence.
The uniffi-exported remote_admin fns keep their String parameter and parse
it once at the boundary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Android's fetch_blob_to_path (music playback of a track held on another node)
walked get_peer_nodes_for_ensemble unordered and unfiltered — revoked peers
included — buffered the whole ciphertext in memory via
blob_sync::stream_blob_ciphertext_from_peer, and had no job and no cancel
(audit B6). It now calls content_fetch::write_blob_to_file_as_job, the path
the PDF reader already uses: presence-ordered, revoked peers filtered,
chunked to file, as a cancelable ContentStream job. The uniffi signature is
unchanged.
stream_blob_ciphertext_from_peer, its timeout, and
BlobStore::decrypt_ciphertext had no other callers and are deleted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NodeRuntime::start and Android's start_p2p_node each loaded the node name,
fetched the DHT registry into the UI cache, derived bootstrap ids, joined,
and spawned the bootstrap retry loop. P2PNode::join_ensemble does all of it
from the node's own store; both callers make one call, and
join_ensemble_gossip/seed_peer_addresses become its internals.
Bug fix (audit B5): Android's copy skipped seeding persisted peer addresses
and using persisted peers as bootstrap peers, so a phone only found peers
through the DHT registry. It now gets the same persisted-peer bootstrap as
the desktop. The per-peer bootstrap info dumps collapse to one count line,
dedup is by EndpointId rather than its string, and the registry cache lock
recovers from poisoning instead of panicking.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
doc-sync's membership batch, revocation delivery, and gossip's two handlers
each verified records against the ensemble key, bounded the stamp with
wire_timestamp-or-now, recorded revocations, then applied bumps oldest first.
revocation_delivery::apply_signed_membership is now the one copy; it returns
per-caller counts (verified for delivery's reply, new revocations and epochs
moved for doc-sync's log), and gossip calls it with a one-element slice. The
unbelievable-stamp warning gossip had now fires on every channel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GossipDiscovery held Option<..> handles to the peer-node, peer-health,
document, revocation and epoch stores, filled in by five setters (one
through join_ensemble_gossip's peer_node_store parameter, which both callers
filled the same way), and every handler carried a 'no store attached'
branch. It now takes Arc<Store> at construction; the handlers take the store
they need directly, and join_ensemble_gossip loses the parameter.
The announcement watch channel holds a NodeAnnouncement rather than its
serialized bytes, so update_node_name no longer deserializes it back and
drops the wrong-variant NotJoined hack; messages are serialized at broadcast
through one broadcast() helper that the three broadcast_* fns share too.
PeerMap shrinks to the replay high-water mark it actually is, and its lock
and the neighbor set recover a poisoned lock instead of panicking.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The client pull and the server's reverse pull ran the same sequence — header,
membership batch, tombstones, releases, staged chunks, schema-gap record,
promote — written out twice. Both now call ingest_pull (the reverse pull
passes no resume cursor, as before, so always opens a fresh session).
promote_session, apply_records, apply_document_tombstone_batch,
build_document_record, build_header and fetch_chunk took five to nine loose
fields of one Store under three #[allow(clippy::too_many_arguments)]; they
take &Store now, and serve_pull_response builds one SyncScope and varies only
its cursor. The peer-health store was optional only so tests could pass None,
so SkewedStamps::report takes it directly. SyncRequest::full()/scoped()
replace five literal constructions. The doc-sync tests use the shared
TestStore fixture instead of hand-building four stores each.
Also drops the next_cursor.unwrap() in the serving loop (audit B12).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sync_from_peer ran the exchange in a detached tokio::spawn and handed back a
channel stream whose sends ignored a closed receiver, so dropping the stream
did not cancel anything despite the doc saying it did (audit B3). The
scheduler's PEER_CATALOG_TIMEOUT, its cancel break, and asset_sync's timeouts
all returned while the sync kept running, and the next tick could start a
second concurrent sync with the same peer.
It is now an ordinary async fn returning a SyncOutcome, reporting through an
FnMut(SyncProgress) -> ControlFlow callback. Dropping or timing out the future
abandons the exchange; the callback answering Break stops it at the next
chunk with DocSyncError::Canceled, which gives the scheduler's catalog pass
and refresh_document_catalog a per-chunk cancel instead of between-peers.
Without the spawn, sync_from_peer and its callers (scheduler passes,
asset_sync's refresh/recursive/push) take &Store instead of Arc<Store>.
Behavior change on cancel: a canceled catalog sync is now an error (recorded
as Canceled on the job) rather than Ok with zero counts, so it no longer
advances the peer's last_synced_at watermark over a partial pull, and
scheduler::sync_with_peer no longer goes on to trigger the remote and run
content passes after the user canceled.
Adds a test that an abandoned staging session leaves the next sync free to
complete without its records.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
content_ledger, scheduler and eviction each carried verbatim copies of
missing_blob_ref/attach/add_doc, reseed its own attach_blob, and retire,
retirement and scheduler wrote out the eight-field PeerNode literal five
times with its upsert transaction around it. test_support now has
add_doc, missing_blob_ref, attach_blob_ref, attach_stored_blob, peer_node
and add_peer; tests override only the fields they care about.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- run_as_job and run_as_job_async are now the _with_progress forms with
an op that ignores the reporter, so open_job/close_job inline away.
- JobSubject::from_key builds its parse error in one closure instead of
seven copies.
- The cancel flag was a tokio watch channel used only as a bool: it is
now an Arc<AtomicBool> shared by the runner's entry and the handle, and
JobHandle.finished needs no Arc (the handle is never cloned).
- node-admin's JobRunnerCell becomes Arc<OnceLock<..>> — every modality
wires the same runner, so first-wins is the existing behavior — which
drops two poisoned-lock expects.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
decrypt_blob_chunk re-derived the HKDF content key and rebuilt the cipher
for every 64 KiB chunk — 16k expansions per GB — though BlobSealer's own
doc explained why the encrypt side avoids exactly that (audit B10).
BlobSealer becomes BlobCipher with seal and open; decrypt_blob,
BlobStore::load_to_file, BlobStreamSession (which now holds a cipher
instead of vault key + hash) and content_fetch build it once per blob.
The four HKDF-SHA256-to-32-bytes copies (vault x3, Secret::to_keypair)
share hkdf32, and ChaCha20Poly1305 keys are built with the infallible
KeyInit::new from a [u8; 32], removing five expects; hkdf32 keeps the one
genuinely-infallible expansion expect.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- SettingsStore::membership() replaces the
settings_store.batch(|tx| settings_store.load_ensemble_membership(tx))
dance at 17 call sites across lib (p2p, ensemble, scheduler, node,
node_status, ffi).
- retire.rs's three copies of from_keyphrase -> compare pubkey ->
WrongKeyphrase become ensemble_keypair_for().
- Hand-rolled hex loops become hex::encode (already a direct dependency),
including BlobHash::from_blake3_hex's decode, which also stops it
panicking on a 64-byte string containing a multi-byte character.
Short-id truncation (NodePubkey::short_hex etc.) is untouched: that is
user-visible and left for a decision.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fetch_artifacts_from_peer ?'d on one ArtifactValue it could not decode,
abandoning the rest of the response — so after a new ArtifactValue
variant, a single artifact from a newer peer blocked every artifact fetch
from it. serve_push already skips such an item with a warning; the fetch
side now does the same (audit B7).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
None of these had a caller anywhere in the workspace (gui-app, daemon,
cli, tui, ffi, Kotlin):
- blob_sync::fetch_missing_blobs_from_peer (and the module doc pointing
at it);
- GossipDiscovery::with_peer_node_store, shutdown and the event_task
handle only shutdown read (P2PNode::shutdown goes through the router),
plus GossipError::Shutdown;
- P2PNode::discovered_peers and GossipDiscovery::peer_map, and with them
PeerMap::peers and DiscoveredPeer's relay_url/created_at_secs, which
nothing but tests read. len/is_empty stay as test-only helpers;
- the empty rust/lib/src/bin directory.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
P2PNode::ping and ping_via_endpoint each carried the whole PING/PONG
exchange, differing only in how the target was parsed and in the CLI's
connect hint. A free p2p::ping(ep, addr, timeout) now does the exchange;
ping_node_id parses JSON-or-id and adds its hint, ping_via_endpoint parses
a hex id. Callers are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three receive loops and two send loops in blob_sync were the same code
with and without a progress tracker. send_blob/recv_blob take a
FetchTracker (untracked where nobody watches), keeping the cancel-between-
chunks behavior of the tracked paths exactly.
Sending no longer collects the chunks into a Vec to find the last one or
copies each 4 MiB chunk: BlobChunk borrows its data as a Cow. The receive
buffer's preallocation is capped at one chunk rather than trusting the
peer's claimed size. blob-stream's send loop drops the same collect.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EnsembleProtocol gains an associated Error type, so each handler's serve
is its former run body and Gated converts with AcceptError::from_err —
instead of six serve shims that flattened the error to a string and lost
its source chain. The Debug supertrait is dropped (Gated prints the
handler's type name), taking eight hand-written Debug impls and six new()
constructors with it; Retiring derives Debug and uses Default.
A small Gate struct replaces the local generic gate() fn, which shrinks
P2PNode::start's 60-line router block to one line per ALPN. ClientIntent,
generic but used by artifact-sync alone, becomes a plain enum there. The
admission lint's accepted spelling follows the rename.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EnsembleEndpoint::connect's DIAL_TIMEOUT covered only the QUIC dial, so a
peer that completed QUIC and never answered admission hung the caller
until its own outer budget (180 s for doc-sync) — audit B4. It now bounds
dial + handshake together, which is what artifact-sync, blob-sync,
blob-stream and node-admin each re-implemented with a second 15 s (10 s)
timeout around connect. Those wrappers, their constants and the two
timeout-only _inner fns in blob_sync are gone.
Effective bounds: unchanged (15 s) for artifact/blob/stream; node-admin's
connect bound goes from 10 s to 15 s; doc-sync, revocation delivery and
ping gain a handshake bound they never had.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seven protocols each carried an identical write_msg/read_msg pair and the
same seven transport/codec error variants. They now share p2p/wire.rs:
framing, a WireError each protocol wraps as Wire(#[from] WireError), and
open/accept helpers for the single bidirectional stream.
Fixes two bugs from the p2p audit:
- B1: the length prefix was trusted, so any peer could make us allocate
up to 4 GiB — on admission, before its cert was checked. Frames are now
capped (MAX_FRAME for members, 64 KiB for the handshake), and the writer
refuses an oversized frame instead of truncating its length to u32.
- B2: rmp-serde wrote every Vec<u8> as an integer array (~1.5x for
ciphertext). wire::encode writes bin; a unit test proves the decoder
reads both encodings for every wire shape, so no ALPN bump is needed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cargo update moved rustls-platform-verifier-android to 0.2, which no
longer bundles a maven/ directory for Gradle to point at, so the build could
not resolve the verifier. Upstream now publishes the AAR under org.rustls in
a Maven archive on GitHub; that repository is scoped to that one group, and
the version is read from Cargo.lock so the JVM half always matches the crate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Same iteration, remainder dropped as before; the chunk is now a [u8; N], so
the PDF UTF-16 decoders destructure it rather than index it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HEAD renames the packages to gpui-ce and gpui_ce_platform; their lib names
are unchanged, so the dependency keys stay gpui/gpui_platform and no import
moves. It needs Rust 1.95, which the flake now provides. The one API break is
Deferred::with_priority becoming priority, with the same meaning for an
explicit value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Highlighter::highlight gained an input-encoding argument; None keeps the
UTF-8 parse it always did.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its Kotlin-facing breaks are UDL [ByRef] bytes arguments and async primary
constructors; this crate has no UDL and no async constructors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pkarr stays on 5: pkarr 8 drops the compare-and-swap argument from publish,
which publish_membership relies on so two nodes registering at once don't
evict each other's registry entries. Moving to it is a design change, not a
version bump.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aead 0.6 renames AeadInPlace to AeadInOut and deprecates Nonce::from_slice,
so nonces are built from their [u8; 12] by value. SealedName::open splits the
wire with split_first_chunk rather than a slice it then re-checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
quick-xml 0.42 hands names, attribute keys and text back as str rather than
bytes, so ComicInfo.xml parsing compares against string literals now. fontdb
was declared by lib but never used, and typst still pins 0.23.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The gpui-ce git dependency had no rev, so any cargo update pulled its HEAD —
which has since renamed the package to gpui-ce, and fails to resolve.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1.92 was holding back crate updates for no reason: the native toolchain is
already on the latest stable, and gpui-ce's HEAD needs 1.95.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PdfLibraryScreen takes PdfFfi::list instead of the generic document
search, so each row's subtitle (page count, or when it was added) and its
availability marker come from the same Rust list the desktop draws. The
byte-size subtitle it had before was a line the desktop never showed.
Closes the TODO item; ADR 0035's consequences updated to match.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BgxTcsGx5z4m8ftm4U489
The "Added …" derivation and the recently-added window it keyed off move
to synchronicity-lib (copy::pdf_library, agents::pdf::RECENT_WINDOW_DAYS);
the row now shows the page count when one was read at import.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BgxTcsGx5z4m8ftm4U489
A new PdfPageCount property (U32, LastWriteWins, absent rather than 0 when
the bytes didn't parse) is written by write_pdf_properties from the same
hayro parse that already reads the title, so every import path gets it.
PdfListItem carries the count and a ready-made subtitle — the count when
there is one, else the existing "Added …" line — decided in
copy::pdf_library so the desktop and Android cannot word the row
differently. The item is now a uniffi record and PdfFfi::list hands the
whole library across, availability included.
A new PropertyKind is a sync wire change, so SYNC_PROTOCOL_VERSION bumps.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BgxTcsGx5z4m8ftm4U489
The middle-ellipsis pass covered every .truncate() call but left the
elements that clipped with overflow_hidden + whitespace_nowrap and no
ellipsis at all. Where the text is the user's — a document title in the
screen header, a note's title in its meta row, a node name, a sidebar
row, a media-library row or breadcrumb, the track fetch line — it now
gets truncate_middle() instead of a raw cut at the right edge.
screen_title and header_crumb were flex_none, so a long document title
pushed the search field and actions off the header rather than giving
way; both now shrink (never grow) with a middle ellipsis.
Fixed-vocabulary labels — the agent rail's names, the "Video" heading,
the reading-surface picker — keep their hard clip: nothing at their end
tells two of them apart, and the rail's clip-as-it-narrows is on purpose.
The table's Auto column is sized to its content and never overflows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Single-line Text uses TextOverflow.MiddleEllipsis. Multi-line clamps
keep the end ellipsis: Compose only supports the middle one on a single
line. HashChip's private copy of truncate_id is replaced by the FFI
export of the shared one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
src/elide.js restates copy::elide_middle and copy::truncate_id, which
the extension cannot call. CSS has no middle ellipsis, so a node name
is measured and fitted instead.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gpui's .truncate() ellipsizes the end. components::truncate adds
truncate_middle() — the same composition over text_ellipsis_middle —
and every truncating element uses it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An end cut drops whatever distinguishes two long strings that share a
beginning — a draft/final suffix, an episode number, the last digits of
an id. copy::elide_middle keeps both ends, and truncate_id, the FFI's
value previews, the daemon's docs-show summaries, the desktop property
table and the TUI's note tree all go through it (the TUI by columns
rather than characters). The FFI previews were byte-sliced and would
have panicked on non-ASCII text.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every surface that starts one of these had a live cancellation token
already; nothing read it. asset_sync's functions took no signal, so the
work ran to the end and the row was only recorded as Canceled.
The signal is a ProgressReporter, threaded through every asset_sync entry
point and down into blob_sync's chunk loops. It is read in four places,
which between them are what a cancel feels like: between documents in a
recursive walk (a folder of two hundred episodes is two hundred short
transfers, and that is where it pays off most), before dialing each peer,
and once per chunk in both the fetch and the push. A Canceled from a
transfer ends the whole pass rather than falling through to the next peer,
which would have made it mean "skip this node".
A stopped pull reports what it got, as a `canceled` flag beside real
counts rather than an error in place of them. Blobs that completed inside
the interrupted session are read back off the store, since the error
carries no count and a verified blob is on disk either way. The sentences
live in copy::asset_sync so all four surfaces share them — the case that
matters is a canceled pull reporting itself "up to date" over partial
counts, which is a lie about the ensemble.
Two things scoped deliberately, both written down where the code decides:
- A canceled push names no artifact count. That number is the peer's tally
and canceling abandons the session before it arrives, so the peer keeps
what it got but this node cannot say how much.
- The recursive walk drops the byte counters it would otherwise emit. Its
unit is a document, and per-file bytes restart the row's counters at
each one, drawing a bar that fills and resets — which reads as stuck.
Counting documents properly is follow-up work; ImportCounts has the
shape.
The blob protocol's header already carried total_bytes and a per-item
size, so the counters cost no wire change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPmrChbfTsjXLr162v18hQ
The player screen grows the same three-state control the desktop's cards
have — Save for offline / Saving… / Remove download — and episode rows
and queue rows carry the `⬇ Saved` mark beside the listen label.
The control is on the player screen rather than on every row, which is
where the desktop puts it: a phone row is ~350dp and already ends in the
queue control, so a second button would leave the title two words. This is
the same width decision as the two-column/stacked split on detail screens,
not a difference of taste — the rows still say whether an episode is
saved.
Playback opens the saved copy through `local_audio_path`, which decrypts
to a file under the app cache named after the blob's own address: a second
play finds it already there, and an episode re-saved from a publisher who
changed the file lands under a different name rather than silently serving
the old audio. Decrypting streams a chunk at a time, so a 200 MB episode
does not have to fit in memory twice on its way to ExoPlayer — which
`getBlobBytes` would have required.
A failed decrypt falls back to streaming: the enclosure is still there,
and refusing to play something because a cache write went wrong would be
the worse answer.
Episode cards and the episode screen grow one control in three states —
Save for offline / Saving… / Remove download — and a `⬇ Saved` mark that
also appears on queue rows. The mark is per node and is only ever an
addition: its absence says "not here", not "nowhere".
Saving is a tracked `Import` job keyed on the episode document, so the
click returns at once and two presses are one download. A save refused by
that dedup says so where the control is.
`episode_audio_source` is what makes any of it worth doing: every place
that hands an episode to the player now prefers the saved copy and falls
back to the enclosure URL, rather than three screens each deciding. An
episode saved and then streamed anyway is a download that cost bandwidth
and bought nothing.
`EpisodeRow` loses its derived `PartialEq`. `saved_locally` and
`saving` are facts about this disk, not about the document, and no sync
can change either — leaving them in would put a "these episodes changed
elsewhere" marker over the user's own download finishing. They are kept
fresh through `current` instead, which is allowed because a mark
changing in place moves nothing.
The episode screen also stops retyping "+ Queue"/"✓ Queued", which have
lived in `copy::podcast_queue` since the queue landed.
The TODO's "no offline copy of an episode": playback streamed the
enclosure and nothing ever put the audio in the blob store, so an episode
could not be taken onto a plane.
`podcast::save_episode_audio` downloads the enclosure to a staging file
and seals from there. Two new `BlobStore` methods make that possible
without holding the episode in memory — `store_file` seals from a path
and `load_to_file` decrypts to one, both a 64 KiB chunk at a time. A
200 MB episode was 200 MB resident through `store`'s `&[u8]`, and the
phone is the node most likely to be asked to hold one. `store_file` reads
twice, because convergent encryption derives the content key from
`BLAKE3(plaintext)` of the whole blob — that price is paid in disk reads
rather than in memory, and a test pins the two paths to the same address.
Downloading gives the episode a document, whether or not anyone has
started listening: saving a back catalogue for a flight is something the
user has said about those episodes.
`release_episode_audio` drops the local copy, deliberately not through
`eviction`. That machinery exists to stop a user deleting the ensemble's
last copy and pays live presence queries for it; a podcast enclosure has a
copy the ensemble never controlled, at the URL on the document itself.
The two records eviction *would* have written are still written, through
its `record_local_release` — extracted here, because owing peers a
release log is not eviction's alone.
The episode document survives, and must: `Provenance::Syndicated` is
never tombstoned, since a deterministic id that has been tombstoned can
never be used again.
`saved_locally` joins the two batched projections both frontends already
read, so a list gets the mark without a query per row.
`rememberSaveable` for the podcast library's tab, and the words on the
two-answer strip stop claiming only two screens draw it — the queue is the
third, over a hand-ordered list rather than a buffer, and what each of its
answers costs is different enough to say.
Playback outlives the screen on both modalities — Android's transport is a
`MediaSessionService` that keeps going from the notification, and the
desktop's bar sits under every tab of the Podcasts agent — but the write
loop was owned by the player screen. Leaving it flushed once and then
recorded nothing, so an hour listened from the lock screen came back as
the two minutes before the user navigated away. On the desktop it was
worse than that: the subscription hung off the podcast *detail* screen, so
an episode played from the Queue tab was never recorded at all.
Android gets a `ListenProgressRecorder` that follows the Podcasts handle
on the transport for as long as the app is alive; the player screen keeps
only the resume *read*, which is still its own (a position arriving
mid-episode must never seek under the listener). `flushListenProgress`
and the scope it needed go with it — there is nothing left to outlive.
On the desktop the same subscription moves up to `PodcastsView`, which
owns the player and lives as long as the app. The feed's remembered rate
stays with the detail screen, which is the only thing that knows which
feed it belongs to.
What is written and when is unchanged; `docs/audio-player-ui-design-\
principles.md` now says which lifetime it belongs to, since "flushed when
leaving the screen" was the line that made this look correct.
The phone already wrote the position and resumed from it; the list said
nothing, so an episode started and abandoned looked exactly like one never
opened.
A row's second line grows a third fact after its type and date — the
`DocumentDisplayModel.status` slot, null for every other kind of document,
which is most of them. The episode's words are `copy::listen_state`'s, the
same ones the desktop's cards draw, and they arrive already written on the
batched `episode_states` read the queue control was already making.
`podcast_queue` — the cross-series queue the desktop has a whole tab for —
was desktop-only, which also left the phone with no way to put an episode
anywhere but straight into the player.
A Queue tab joins Subscriptions and New episodes in the podcast library,
and each episode row grows the `+ Queue` / `✓ Queued` control the
desktop's cards have. Both read their words from `copy::podcast_queue`.
The queue is ordered by hand, which makes it the sharpest case of
"a sync must never disrupt what the user is doing" on a phone: an
arriving reorder moves the row under a thumb already reaching for it. So a
re-read that differs from what is on screen is held and announced — the
same two-answer strip and the same words the desktop shows — and while it
is held the reorder controls are inert. The user's own moves apply at
once, and a write that fails re-reads the store and says so, rather than
leaving the list claiming a move the ensemble never heard.
Rows come through `episode_states`, one batched read per page rather than
one per row.
The phone is about to grow a queue screen and listen state on its episode
rows, and both were about to be assembled a second time in Kotlin.
- `podcast::episode_store_state` is the batched join the desktop's
`with_store_state` was doing by hand: derived document id, listen state
and queue membership for a whole page of a feed, in one pass. Asking per
row is two store round trips per episode on a list of thirty, and a sync
re-reads the list.
- `QueuedEpisodeRow` carries the episode whole (`StoredEpisode`) rather
than the four fields the desktop happened to draw. The queue is the one
place an episode is reached without its feed's list in hand, so opening
one from there has nowhere else to get its description or publish date.
The property-reading loop it had grown is now `podcast::stored_episode`,
shared with `list_feed_episodes`.
- `copy::listen_state::label` and `copy::podcast_queue` move the words
out of the desktop screen: "▶ 12m left", "✓ Finished", "+ Queue",
"✓ Queued", "Remove", and the empty-queue sentence. Two modalities
drawing the same rows from the same synced artifact must not each spell
them.
- The FFI grows `episode_states`, the five `queue_*` calls, the queue's
incoming-change banner, and `podcast_queue_labels`.
Android played episodes and remembered nothing. The artifact
(`ArtifactKind::ListenProgress`), the store functions, and the desktop's use of
them all existed; the phone simply had no path to them, which is also why it
created no episode documents at all once browsing a feed stopped writing them.
`ListenState` now crosses the FFI as itself — it is a three-variant enum, and a
mirror record would be a second place for those variants to disagree — under
three new `PodcastFfi` methods: `listen_state`, `save_listen_progress`,
`save_listen_finished`. All three sit on the `agents::podcast` functions the
desktop already calls, so both modalities write the same row through the same
code.
The player screen then does, in order:
- derive the episode's document id from its feed and publisher id, which writes
nothing and works before any document exists;
- read the stored position and start playback there — read *once*, before
playing, because a position arriving from a peer mid-episode must never seek
under the listener;
- create the document on the first transition to playing, which is what
"starts listening" means and the moment an episode earns a row;
- write the position every 5 seconds, and once more the instant playing stops.
Two ways this goes quietly wrong, both handled. A pause that only cancels the
write loop loses up to the interval, and pausing is how people ordinarily stop
— so stopping flushes rather than just returning. And the end of an episode
stops playback too, so the stop-flush would race the `Finished` write and could
land a position two seconds short of the end on top of it; the flush stands
down when the track ended. Leaving the screen flushes as well, on a
repository-owned scope, because playback outlives the composition and the
composition's own scope is already cancelling by then.
The write policy — every 5s, floor at 3s in, `Finished` rather than a position
at the end — is now written down in `docs/audio-player-ui-design-principles.md`
rather than decided twice. Both players' constants cite it. Android's live with
the writer in `PodcastRepository`, so the floor holds for every path that
writes a position instead of each call site remembering it.
What this does not do: the episode *list* still shows no listen state on
Android, where the desktop's rows read "12m left". And on both modalities the
write loop belongs to the player screen, not the transport, so a phone playing
from the notification after the screen closed records nothing past its parting
flush. Both are in TODO.md.
Two changes that turn out to be one.
**Derived ids.** Episodes took fresh v4 ids and `save_feed_episodes`
deduplicated by scanning the feed's existing children for a matching
`PodcastEpisodeApiId`. That only compares against what *this* node holds,
so two nodes refreshing one feed independently created two documents per
episode and sync delivered both — the failure `docs/rss-reader.md` §3.3
exists to reject. `episode_document_id(feed, api_id)` derives the id
through `documents::derived_id`, and the dedup scan is deleted along with
`existing_episode_api_ids`.
**A document is what an episode with state looks like.** Searching a
podcast and subscribing wrote the first 50 episodes as documents, every one
of which then synced to every node. Nothing was stored *about* them:
listen position, queue membership and (eventually) downloaded audio are the
only reasons an episode needs a row. So `save_feed_episodes` becomes
`ensure_episode_document`, singular and idempotent, called at the moment
the user plays or queues an episode — not when they browse one.
The two changes are the same change: a derived id is what makes browsing
without writing possible. A row the API just returned can be asked what its
document id *would* be, so a listing carries listen state and queue
membership for episodes no node has ever written a document for, and the
document that later appears lands at exactly that id.
The list on screen is now the publisher's, from the API, with the store
layered over it — plus any episode this node holds a document for that has
since dropped off the feed's first page, which would otherwise silently
vanish. A failed fetch falls back to what is held rather than erroring, so
an offline node still reaches what it has state on.
Android loses its offline episode list and gains no writer: it saves no
listen position and has no queue, so nothing there creates an episode
document yet. `PodcastAgent.ensureEpisode` is the seam; TODO.md carries it.
Several kinds of row here have an id that is a function of what they are
rather than a fresh v4 — a music artist from its tag, an agent's settings
row, a fact under a key — which is what lets two nodes create "the same"
thing without coordinating and converge on one row after sync.
Every one of them derived its id for itself, and while they all agreed on
UUIDv5, the particulars drifted: a namespace constant per site, three
conventions for joining key parts (`\u{1f}` in facts, `:` in anchors,
`\u{0}` in credentials), and no shared answer to whether the parts arrive
already normalized. Agreement on those particulars *is* the point — two
derivations that differ in any of them produce two ids just as surely as
two different algorithms would, and each side is internally consistent, so
nothing notices.
`documents::derived_id` is now the only place `Uuid::new_v5` is called for
a row id. A caller names a `Domain` and the parts of its key; the namespace
and separator are the helper's. Canonicalization moves in with it, as
`folded` (trim + case-fold) and `collapsed` (adds NFKC and a whitespace
squeeze), both idempotent so a caller that normalizes for its own reasons
can pass the result straight through. The `Domain` enum is the registry of
every derived id in the codebase; adding one has nowhere else to go.
Ids move, once. The project has no backwards-compatibility constraint and
key-derivation salts are explicitly in scope for breaking changes; the two
"have not moved" tests that pinned the old per-site formulas are replaced
by one pinning this one.
Also adds `Provenance::Syndicated` — deterministic id, listed, never
tombstoned — which the podcast agent needs next and no existing variant
said. `Anchor` has the same three predicate answers but means "an agent's
well-known singleton"; there is one syndicated document per item a
publisher released.
Every wrapped key in vault_keys is sealed under the same node-local wrapping
key, and the wrap used empty associated data — so a row's ciphertext was
interchangeable between vaults, and the table's primary key was the only thing
saying which vault a key belonged to. Asking for one vault's key and being
handed another's would have succeeded quietly.
Binding the id makes that fail authentication instead. It matches what every
other AEAD call site here already does: a blob chunk binds its plaintext hash
and index so it cannot be spliced elsewhere, and a sealed node name binds its
pubkey so it cannot be lifted onto another member's announcement.
This changes the wrap format, so existing vault_keys rows no longer unwrap and
an existing store fails to start with a decryption error. Acceptable per the
project's status; a store is re-initialized rather than migrated.
Found while writing docs/vault-pddb-research.md (§4F).
Four claims found while writing docs/vault-pddb-research.md, none of which the
code has supported for a while:
- Blob content encryption is built (ADR 0028), so the data model's "remaining
step" is only Property and Artifact values. The note now says which boundary
that leaves standing: blob bytes, and nothing else.
- document-sync said a receiver without the vault key "cannot decrypt the
content". It cannot open blob bytes; PropertyRecord.value is MessagePack
plaintext on the wire and in SQLite at both ends.
- It also had the default vault's UUID being HKDF-derived from the ensemble
public key. The id is the nil UUID constant; the *key* is what is derived,
from the ensemble secret. Id and key had been conflated.
- Vault key versioning describes machinery that does not exist — vault_keys has
no version column and default_vault() hard-codes version 1 — so it now says so
and points at the revocation 3c item that wants rotation first.
Vaults are specified but almost entirely unbuilt, so this is the cheap moment
to read the closest prior art. docs/vault-pddb-research.md is the comparison:
what transfers (the union view and its write-target rule, enumerate-vs-unlock,
key commitment, an offline store reader), what does not (a noise-filled fixed
container, make-before-break journaling, device-bound AAD, a per-device
pepper), and the pitfalls — most of which bite harder here than they did on a
single 100MiB device, because an ensemble manufactures the disk snapshots that
deniable storage cannot survive.
Its conclusion is that a locked vault and a hidden one are different products
and only the first is reachable, so TODO.md now carries the work that implies
and docs/long-term-todo.md carries the hidden vault, its trigger, and the three
storage rules it would have to be built under.
Rewrites every British spelling found in the codebase to American: neighbour,
colour, catalogue, behaviour, honour, flavour, grey, judgement,
acknowledgement, licence, defence, organise/optimise/initialise/recognise/
analyse, artefact, cancelled/cancelling, labelled/labelling, modelled/
modelling, and centre/centred/centring all become their American forms —
consistently in prose (docs, ADRs, comments) and in identifiers, including
the FFI-shared CatalogueSync/Cancelled enum variants and catalogue_* fields
that cross the Rust/Kotlin boundary.
Left untouched: external API names that only look like a spelling variant
(kotlinx.coroutines.CancellationException/NonCancellable, Compose Material3's
labelLarge) and the EFF diceware wordlist used for key derivation.
Adds a Code Style rule to AGENTS.md/CLAUDE.md requiring American spelling in
user-facing copy and documentation going forward, with that same carve-out.
Verified: cargo build --workspace, all 1410 Rust tests, Kotlin compile,
fmt-rust/fmt-kotlin, lint-kotlin-fast (detekt), and the lint-gui-layout/
lint-sql-params/lint-p2p-admission custom checks all pass clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017fHWzCF6oNiCV4ue1bBf1t
TODO.md's clippings item is closed. ADR 0039's `NoteEditor` prediction
gets its second correction: the guard was the hard part, not the editor,
and TextArea is what a card-sized buffer with no TextFormat wants.
ADR 0031 gains an amendment for what the third caller surfaced — that the
'identical to what the editor already holds' comparison cannot live in
SyncGuarded, because the type would then have to hold a text buffer and
stop being gpui-free. Both editable-buffer callers make it before feeding
the guard, and that is a decision worth recording rather than
rediscovering. It also stops claiming Android has no equivalent: it has
the rule (ui/IncomingBody) and the shared words, just not the type.
AGENTS.md's worked examples pointed at notes/NotesScreen.kt for a symbol
that now lives in ui/IncomingBody.kt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsACwkLMA9MUh5bvgAnZnK
ClippingEditSession is Android's half of the desktop's SyncGuarded: it
holds the open buffer, the store baseline and a version arriving from
another node, and IncomingBody.alertFor decides which reads are news and
which are this node's own save coming back. A ViewModel rather than
remember, for the reason NotesEditorSession is one — a decision that
vanishes on a rotation is not one the user was given.
The alert-don't-apply rule and its strip were the note editor's alone and
are now shared, because two screens holding whole-buffer last-write-wins
text is exactly when a rule stops belonging to one of them: IncomingBody
moves to the ui package with its test, and NoteEditorPane's private
banner becomes ui/IncomingBodyStrip taking a subject's IncomingChangeCopy.
ClippingsFfi gains update(), which the module doc used to say it would
not export; the reason it would not was the missing guard, and the guard
now has a name to point at.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsACwkLMA9MUh5bvgAnZnK
A clipping's body is LastWriteWins, so an editable buffer over one is
exactly the case CLAUDE.md's first principle is about. The screen now
opens a TextArea over one clipping at a time and holds a SyncGuarded
<String> around it: while an editor is open it re-reads that clipping on
every store change, and a body this node did not write is announced in
the card and held rather than applied. Save is inert while a version is
unresolved — the banner's Load theirs / Keep mine are the only writes
that have an answer for what they overwrite, and copy::incoming_change
gains a ClippingBody subject so they say the same thing the Notes pane
does.
An editor is on screen whether or not its row is: adopting a held list
page can push the edited clipping off the loaded rows, and a decision
with nowhere to make it is not one the user was given.
NoteEditor was the component ADR 0039 expected to reuse; TextArea is
what shipped. NoteEditor owns a whole pane (monospace, Tab-indent,
markdown list continuation) and a clipping is edited in the card it is
read in, beside a composer using the same box for the same text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsACwkLMA9MUh5bvgAnZnK
The reachability matrix was recorded as blocked on data — each node
gossiping its neighbour set, every node assembling the N×N picture. The
data was never the problem. The matrix arrived as the answer to "what goes
in the rectangle where the rejected node editor was", and DIRECTION.md
§8.5 argues for it as a picture: every pair appears twice and reads the
same, "which is what co-equality looks like drawn". True, and not a
question anyone was asking the screen. It is withdrawn, not deferred.
The same defect was already shipping at 1×N. The roster read the live
gossip neighbour set per peer and drew everything outside it as Degraded —
but iroh-gossip keeps a bounded partial mesh, holding a small neighbour
set on purpose and dropping edges it does not need, so a node we could
dial in one hop showed as degraded whenever the swarm had no edge to it.
That badge reported swarm topology in the voice of connectivity.
Remove it rather than repoint it at iroh Endpoint::remote_info. The
screen already probes on demand — Ping reports a real round-trip, Sync now
reports what happened — and an on-demand probe beats a dot that is stale
the moment it is drawn. Gone with it: MemberConnectionState, the
connection_state field, P2PNode::active_neighbor_ids_hex,
GossipDiscovery::active_neighbor_ids, and the dot/glyph on all four
modalities. active_neighbors stays for neighbor_count, with a doc comment
saying why it must not be exposed per peer.
The aggregate line had the same defect and is reworded, not deleted:
"{n}/{m} other nodes reachable now" becomes "N gossip neighbour(s)", a
bring-up diagnostic that claims nothing about reachability. On the desktop
it moves from the roster header into the Membership fact grid, where a
diagnostic belongs. All three phrasings now come from
copy::ensemble_roster — the TUI already disagreed with the other two —
reaching Android through an ensemble_roster_copy FFI record.
What the badge was groping toward is a real question, and it is now in
long-term-todo.md: when this node is continuously updating a document
meant to reach the ensemble live and that stops happening, the user should
be told. That is a claim about what they are doing, not about the network,
and it cannot be designed before some agent actually pushes a live
continuous update — podcast playback position being the first candidate.
The preamble claimed the distinction was not size or difficulty. It is,
sometimes: theming, self-hostable relays and parallel asset fetch are all
deferred because the cost is out of proportion to what they buy today, or
because acting now means guessing at a design decision. Name the three
reasons an item lands here, and admit that not every entry has a crisp
trigger.
Also drops the Security section from TODO.md, which had nothing open left
in it.
ADR 0017 decided against capability scoping in v1 — a paired client got what a
CLI session gets — and wrote down the price as "the same blast radius as a
compromised CLI session", worth revisiting. What made it worth revisiting was
not a use case; it was reading the list next to the fact that the holder is a
browser sandbox with no filesystem access of its own. ImportFiles reads any
path the daemon's user can read, into the store where the client reads it back.
MaterializeDocument writes any path it can write. PurgeDocument destroys data
ensemble-wide. Shutdown stops the node, and was reachable because it was
handled *above* the two hand-written guards rather than below them.
CredentialCmd::{Set,Clear} rewrites the node's API keys, and simply did not
exist when those guards were written — which is the whole argument for not
doing this with guards.
So: five capabilities in stores::linkage, one per command through
DaemonCommand::required_capability. The match is exhaustive, and that is the
part worth having — a command added without deciding what it costs stops the
build instead of inheriting the last variant's reach.
Transport becomes Caller, because the question is who is asking rather than
which socket they reached: Owner is answered unconditionally (reaching a socket
inside a 0700 store directory already means being the user who owns it), Linked
carries the linkage id. One gate, before anything is done, replacing both
guards. A holder may revoke its own linkage and no other — that is the
extension's Unlink, and it needs the caller's identity rather than the
command's cost, so it lives in Caller::permits.
Credentials are NodeAdmin in full, including the read. Status carries no secret
value, so Read would be defensible, but no linkage has a use for any of it and
relaxing this later is easier than clawing it back. Linkage listing likewise:
a client knows its own, so what a listing offers is everything else the owner
has paired.
Reused from the parked linkage-security branch: the Capability enum, the
required_capability shape, the Caller/permits split, and the revoke-your-own
carve-out. Dropped from it: LinkageScope::{Companion, Full}, `pair --full`, the
stored scope column and the narrowest-reading rule for an unknown scope. The
qualm recorded in TODO.md was right — nothing wants Full. The extension's whole
vocabulary is four commands, so a two-member scope model has one real member
and one guess about linkages that do not exist yet. Policy over capabilities is
cheap to add against a second client and expensive to un-guess once it is on
every row.
The loopback tests move with the model: a paired client can no longer enumerate
linkages, and the escalation test reads the count from the store rather than
over a wire that now refuses. New tests draw one command per refused capability
rather than re-asserting the two cases that used to be special-cased.
ADR 0017 gets the amendment; TODO.md's Security section is now empty and says
so.
A node answers local callers on a Unix socket and a loopback TCP port, and
"local" has been doing damage: it suggests one trust boundary where there are
three, with different populations on each side, and more than one bug here has
come from reasoning about the wrong one.
Mostly a consolidating record — permissions, Origin refusal, CORS, the pairing
challenge, hashed tokens, pairing refused over loopback all already exist, and
their reasoning was scattered across module docs and one commit message. Three
things it settles rather than restates:
- **Code running as the node's own user is not a boundary and will not be
made one.** It can read store.db directly, and it can drive the socket's
full surface including pairing. The loopback mitigations look like they
should help here and do not: they defend against a caller who cannot read
your files.
- **Offline copies are out of scope**, stated as the model rather than as an
omission. The whole store directory is the secret; its mode is the whole of
the at-rest story.
- **A token is not a CLI session**, which reverses ADR 0017's v1 grant
decision. That one is a real decision and the implementation follows.
The deferred half — optional start-time password protection — moves to a new
docs/long-term-todo.md, for work where the decision to not build it has
already been taken and an entry in TODO.md would keep reading as unfinished
business. Each entry there carries what would make it worth doing; this one's
is the threat model gaining offline copies, and its note says to reach for a
keychain or TPM before a prompt, since only the headless node has no
alternative.
TODO.md loses the encrypt-at-rest item and gains a line saying where that kind
of thing goes now. The Argon2id entry stays but records that its one concrete
caller has moved.
`node_status::artifact_presence_for_node` was rewritten during revocation
phase 3a so that a remote node reads `Present` only on that node's own word
from `NodeContentStore` — an artifact's `origin_node` is the sender's
unsigned claim about a third node, and a relayed record carries a claim its
relayer never checked either. The helper grew four paragraphs saying so.
The desktop did not call it for every row. A node named as an artifact's
origin that has no `NodeDocumentRecord` gets an extra row on the grid, and
that row was assembled by hand twenty lines below the import: `Present` if
the local store held the artifact value, `Ref` otherwise. So the inference
the phase removed was still live on the one modality with a presence grid, and
still on the screen a user reads before deciding what is safe to delete
locally. Any ensemble member could put a `Present` on any node's row by
naming it as an author.
The fix is a third function beside the other two rather than a second
correction, because the failure mode here is a caller that assembles a row
itself: `artifact_presence_without_record` takes the same evidence its
siblings do and floors at `Unknown`, not `Ref` — a node with no record has
not claimed to hold the document either, so even the weak rung is unearned.
Two tests pin it, next to the ones that pin the same rule for the helper it
was supposed to mirror.
Nothing else derives a decision from `origin_node`: the daemon and the
Android FFI both go through the helper, and the TUI and CLI only print it.
With that closed, per-record signatures for provenance are settled as not
worth building, and TODO.md's item goes. The arithmetic is in
NODE_REVOCATION.md's Open Questions — briefly, 64 bytes on eight record types,
a signature column on seven tables because a signature has to survive relay,
a verification per row, and a cert to carry for an origin never met, in
exchange for three read-only surfaces and against an adversary holding one of
the user's own delegation certs. §3a records what would reopen it: any point
at which attribution stops being decoration and starts being load-bearing.
2026-08-31 00:54:26 -07:00
516 changed files with 36228 additions and 42033 deletions
Compose Material3's `labelLarge`), a URL, or a third-party wordlist
(`rust/lib/src/ensemble/eff_large_wordlist.txt`) — those aren't ours to
respell.
- Errors: Use typed errors with meaningful messages. In rust, prefer `thiserror`. Avoid using .unwrap() or .expect() in non-test code, and fix uses of these or other panicking functions when you encounter them.
- the justfile codifies recipes that run standard Rust and Kotlin formatters (just fmt-kotlin, just fmt-rust). Run these tools to keep code formatted on every commit.
- don't generate code that has fully-qualified type names (e.g. `documents::Artifacts::new()`); instead prefer to `use documents::Artifacts`, `Artifacts::new()`
- GUI frameworks (GPUI or Jetpack Compose): Prefer to create components built out of functional subcomponents with meaningful names, instead of long components with no internal structure. Prefer to define named constants instead of using the same color, spacing, etc. values in-situ. Don't define default values for properties unless necessary. Prefer to create text elements that have copy-able text;
- When displaying hex-encoded hashes or UUIDs, use standard UI components (depending on modality) which display a truncated hash but make the full hash available if the user requests it.
- **A truncation of human-readable text cuts from the middle, never the end.**
What tells two long strings apart is as often at the end as at the start —
`Quarterly report - draft` against `- final`, an episode number, a file
extension — and an end ellipsis drops exactly that.
- A cut made by character count is `copy::elide_middle`. The TUI's
`notes_screen::elide` is the same rule in terminal columns, and the browser
extension's `src/elide.js` is it restated in JS, since the extension cannot
call the lib.
- **Ids and hashes are the exception: a shortened id keeps its prefix**
(`3fa9c1d2…`), the way git shortens a commit hash. An id is random, so its
end is no more distinguishing than its start, and the prefix is the
conventional handle — the part people read aloud, compare and type. The
full value stays one action away through the hash-chip component (see the
bullet above). The helpers are `copy::short_id`/`copy::truncate_id`.
- A cut made by layout uses the toolkit's middle ellipsis:
`components::truncate::TruncateMiddle::truncate_middle()` on gpui rather
than `.truncate()`, `TextOverflow.MiddleEllipsis` in Compose, and the
extension's `fitMiddle` where CSS would have said `text-overflow: ellipsis`.
- The one exception is a multi-line clamp (`maxLines = 2` and up): Compose
only supports a middle ellipsis on a single line and falls back to a hard
clip, so those keep `TextOverflow.Ellipsis`.
- **gpui sizing: no raw `px()` in render code.** Font sizes, icon/control
sizes, panel widths, and gaps come from `gui-app`'s `sizing` module
(`font::`, `icon::`, `control::`, `layout::`), or `sizing::scaled(..)` for a
@@ -310,7 +377,8 @@ developers build.
Android window, and it is worth doing for its own sake before it is worth
doing for this.
- Worked examples: `human.rs`'s `read_only_row`/`read_only_values` against
`HumanDetailScreen.kt`'s `ReadOnlyField`.
`HumanDetailScreen.kt`'s `ReadOnlyField`. Android's one stacked row is
`ui/LabeledValue`; reach for it rather than laying out another.
# VCS Workflow
- This project uses Jujutsu (jj) with a git backend. Prefer `jj` commands over
@@ -51,7 +51,7 @@ most widespread example of an overlay OS today.
<!-- note: I would eventually like to be able to say Synchronicity is a computing stack, but that is not the case yet -->
<!-- the overlay OS terminology comes from https://urbit.org/overview/urbit-explained among others -->
<!-- note: "Ra is a single entity distributed across the whole solar system. No part of Ra is slaved to any other part of it. No shell-Earth's core node has a noticeably differing personality from the others, or from the megastructure inside the Sun. Opinions and behaviour and available information are continuously synchronising. Eventually, there is only one Ra." - qntm's Ra, Abstract War -->
<!-- note: "Ra is a single entity distributed across the whole solar system. No part of Ra is slaved to any other part of it. No shell-Earth's core node has a noticeably differing personality from the others, or from the megastructure inside the Sun. Opinions and behavior and available information are continuously synchronising. Eventually, there is only one Ra." - qntm's Ra, Abstract War -->
What can someone do with Synchronicity? Some examples: