PE-9205: Release v2.88.0 - #2226
Merged
Merged
Conversation
`dev` said 2.86.0 while production ran 2.87.1, so every staging build reported a version two releases old. The drift is structural rather than an oversight: a release branch is cut from `dev`, the version is bumped *on that branch*, and it merges to `master` - so the bump never travels back. It happened for both releases done this way and would happen again on the next one. The code on the two branches is byte identical; this is only the string they report.
Fetching the chain head and prefetching snapshots both ran with the dialog showing a static "0% complete". The snapshot prefetch is the largest download in a sync - tens of megabytes for a wallet with history - and the longest stretch of it that said nothing at all. Both now name themselves. Only those two. The dialog renders `statusMessage` *instead of* the percentage, not beside it, so a message during the drive walk would delete the one number the dialog shows across the longest phase of a sync. The walk stays deliberately silent and keeps its percentage; the dialog already names how many drives are done on the line above. Also promotes two numbers the probe already computed and only logged: `firstTimeSyncDriveCount` and `skippedDriveCount`. Both are set on every path that decides them - the probe, a deep sync, a failed probe, and a single drive sync - because a count that is only correct on one path is worse than an absent one: a caller cannot tell which it is holding. The no-op sync carries them through too, since "nothing to do" is the sync where the skipped count is the whole story. Nothing in the delicate zone: no batching or concurrency arithmetic, no watermark or rewind, no snapshot validation, and not one extra network request. Every value surfaced was already computed. Known and deliberate: the messages are hardcoded English, matching the existing `syncCheckingForChanges`, which is also a literal with a registered but unreferenced ARB key. Two more keys are registered here on the same footing. Wiring localisation needs the repository to emit keys the UI resolves, and should fix all five messages at once rather than half of them - its own change. Non-English users see English for these two short windows where they previously saw a translated "0% complete". flutter analyze clean; 1465 passing / 4 skipped.
Both sync states painted a full-screen scrim with a modal on top, and the syncs that run automatically - the one on login and the periodic timer - are ones no user ever asked for, so every user was locked out of the app by work they never requested, for as long as their history took to walk. Syncs now carry provenance. `SyncTrigger` rides on `startSync` and `startSyncForDrive` and defaults to `userInitiated`, so every existing call site - the resync menu, the retry button, the failure snackbar, a drive's own resync - behaves exactly as it did. The two that fire without being asked pass `background`: the sync `createSyncStream` runs on login, and the periodic auto-sync the same method schedules. `SyncInProgress` and `SyncCancelled` carry the trigger through to the shell. The shell branches on it. `SyncOverlay` - the two scrim blocks lifted out of `AppShell.build` - paints for a user-initiated sync and nothing at all for a background one, so the app stays browsable and uploadable while login syncs. `SyncLoadingDrives` no longer blocks either: it is the cubit's initial state and the metadata-only login path, both syncs nobody asked for, over a drives list the local database already has. Errors are the deliberate exception: `SyncCompleteWithErrors` blocks whoever started the sync, because it is the one outcome that asks a question and offers a retry. The top bar reports the quiet ones. `SyncButton` was a static icon that reflected no state; it is now the indicator - a ring that turns while a sync runs, filling as progress arrives but never standing still, since sync progress plateaus for long stretches and a frozen arc reads as a hang. Every state renders into the same 24x24 slot the icon already occupied, so nothing in the row moves when a sync starts or stops, and the glyph keeps one colour (`textMid`) whether a sync is running or not. The phase does not hide behind a hover. `SyncButton` is mounted in `MobileAppBar` too, where nothing hovers and a tooltip is never seen, so the menu the ring already opens now carries a status header: what is syncing, the phase or the percentage, and the elapsed time - reachable with a tap. The dropdown sizes itself as `items.length * 48`, so the header is paid for with an explicit `maxHeight`. The sync is named with the modal's own strings (`syncingAllDrives` / `syncingSingleDrive`) rather than a third name of its own, and the percentage is rendered whole - "42% complete", not "42.0%". Tests: `SyncOverlay` paints no scrim for a background sync or a metadata load and does for a user-initiated one, errors block regardless, cancelling follows the sync it came from, and the modal counts whole percent; the indicator turns on and off with the state, holds its footprint, keeps its glyph colour, keeps moving after progress stops changing, keeps its menu, and reports the sync to a tap with no pointer involved; and both the login sync and the periodic timer emit `background` while a resync and a retry emit `userInitiated`. The button's test drives the cubit through a broadcast controller, because a single- subscription one wedges its own tearDown instead of failing if the button is ever unwired, and the trigger test waits for the emission rather than for a fixed 50ms of wall clock.
A successful sync emitted SyncIdle and the modal vanished, which told the user nothing - least of all that nothing had changed, the one result that teaches them the next sync is safe to ignore. - count what a sync actually writes. SyncProgress.entitiesSynced was a number nothing in lib/ ever set: 0 in initial(), 0 in emptySyncCompleted(), and no copyWith gave it a value, so a sync that pulled in five hundred files still reported "up to date, nothing new". The repository now accumulates the file and folder revisions it inserts, keyed by drive, next to the skipped-entity count it already kept, and surfaces it on the same final copyWith. Counted per revision written, not per revision the batch hands back: those lists carry cached revisions for entities that did not change, and every sync re-walks the last 240 blocks, so counting them announced a drive full of new items on a sync that wrote none - add SyncComplete, extending SyncIdle so DriveDetailCubit's post-sync refresh and SharingFileListener's handoff, which both gate on `is SyncIdle`, keep working untouched - carry entitiesSynced, skippedEntityCount, the drive name and the trigger straight off SyncProgress, plus a sequence so two zero-change results are two states rather than one bloc drops. Not a timestamp: two syncs with no I/O between them land in the same millisecond - say nothing when there is nothing to say. Both paths fell through the catch into the completion block, so a sync that threw before any drive query recorded a failure emitted "Up to date - nothing new" beside the snackbar saying the sync failed; a sync with no logged-in profile did the same. Both now emit a plain SyncIdle, exactly as before results existed - build one line from those counts: up to date, some items, a named drive, and items that could not be read. No count of drives - drivesSynced counts drives walked, failures included, so "12 new items across 3 drives" sent a user whose twelve files landed in one drive looking through three - keep the unreadable clause out of the ellipsis. On a 320px phone the joined line overflowed and cut exactly the clause that says the sync has holes in it, so the pill lays the two halves out as separate lines - refuse to announce a stale result. SyncComplete stays the cubit's state until the next sync and both surfaces start their timer when built, so crossing the shell's breakpoint an hour after login popped a card for a long-finished sync - report it where each sync ran: a background sync flashes beside the top bar indicator, out of the bar's layout, for four seconds; a sync the user asked for ends its modal on the summary, without a scrim and without a click, and dismisses itself Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
the drive walk owned 0.0-0.9 and everything after it was three fixed jumps - 0.92 for ghost folders, 0.96 for transaction statuses, 1.0 for done. so the bar climbed quickly and then stood perfectly still through two phases of unknown length, one of them a gateway round trip bounded only by a 30s timeout. a bar that has not moved in a minute reads as hung. bar travel now goes where progress is real, and where it is not real the bar stops pretending to have a number. - the walk keeps the large share (0.85). it is the only phase whose length grows with the user's history and the only one that already reports continuously - per drive, per block range within a drive - the fast local phases get small shares they can actually spend: ghost writes 0.85-0.88 (per row), the snapshot/preference reads 0.88-0.92 (per chunk), the pending-tx local reads 0.92-0.97 (per read) - the gateway confirmation calls get the smallest share of all, 0.97- 0.99, which is the opposite of what their duration suggests and is the point: that phase reports one step per 5000 pending transactions, so for a real user it reports exactly one step, when the gateway answers. points handed to it are points the bar crosses on no evidence - so it is drawn indeterminate instead. SyncProgress carries an isIndeterminate flag, set while the confirmation loop runs and cleared however it ends; the modal's bar animates and the top bar's ring drops its value and sweeps. the phase still names itself, and there is no ETA and no time-based ramp - three local database reads that already happened before the gateway loop - the owner overrides, the per-drive owners, the pending rows - now report a step each. free granularity, no new query two bugs this reweighting introduced or exposed: - the walk's own number can exceed 1. it is 1 - (head - height) / range, and head is read once at the top of a sync that then runs for minutes, so a transaction mined after that read makes the term negative. head 100 with transactions at 99/101/103 emitted 1.2 then 2.4, and the monotonic sink took 2.4 as its high-water mark: every later emission, "Sync complete" included, reported 240% and the bar never moved again. the walk's contribution is now capped at its own phase end where it is computed, and the sink bounds to 0..1 before anything becomes the mark - publishing to the sink after it closed threw. Future.timeout does not cancel its source, so the abandoned _updateTransactionStatuses goes on issuing confirmation batches while the sync finishes and closes; the throw aborted the batches it had left, invisibly - the fired timeout swallows the error - and those transaction statuses were never written, leaving uploads "pending" for another cycle. add and addError now stop at close. reporting stops; the work does not what sync does is otherwise untouched: same batching, concurrency, watermark, snapshot validation, cancellation, error handling and order of operations. no new network request and no new database query. the tests drive a real SyncRepository over a real in-memory DriveDao with the gateway mocked, both paths. the phase-advance tests now assert distinct values strictly inside a single phase's open interval - the old count over the whole tail was satisfied by the three fixed boundaries alone and stayed green with every intra-phase report deleted. each new test was proved by reverting only the line it guards.
An adversarial review of the whole stack together found six defects no per-PR review could see: unblocking the app made three surfaces reachable that silently did nothing, and the indeterminate bar reintroduced the backwards jump it exists to prevent. Fixed here as one commit rather than rebased into the commits that introduced them; the parent PRs stand. - a drive clicked during a background sync answers immediately. openFolder emitted its loading state after awaiting the sync, and changeDrive had already cancelled the folder subscription by then, so a click produced nothing at all - no navigation, no spinner, the previous drive still on screen - for as long as the sync ran. The emit moves above the wait; the wait itself stays, because a folder must not be read from a half-written database. - the sync modal's bar keeps one widget type across both phases. Two types in one slot meant Widget.canUpdate failed on the swap, the element was destroyed, and percent_indicator replayed its fill from zero: 0.97 -> 0.0 -> 1.0 over a second, for anyone with pending transactions. - SyncCompleteWithErrors carries a trigger like every other terminal state, and the overlay honours it. A background sync that failed reports at the top bar, drawn as a failure, and keeps Retry Failed in the sync menu after the announcement has gone - instead of dropping a scrim over whatever the user had moved on to. A sync the user asked for keeps its modal exactly. - the resync items are visibly unavailable while a sync runs, because startSync turns the request away. A disabled item has no closure, so no Plausible event is recorded for a sync that never starts. - multi-select stays locked while a sync rewrites rows, but says so: one quiet line above the file list, rather than a ctrl-click that does nothing at all. - the sync button keeps one widget type at its slot in every state, so a menu open when a sync starts or ends is still open afterwards instead of vanishing from under the thumb reaching for Resync. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
Logging in walked every drive's whole history, on every unlock, because syncAllDrivesOnLogin shipped as true and nothing consulted whether there was anything to find. - default syncAllDrivesOnLogin to false, in the model and in the repository's read of an empty store. A stored value either way is an explicit choice and is honoured; only the never-touched case changes. The settings toggle is untouched. - keep refreshing the drive list on the quiet path: syncMetadataOnly is updateUserDrives and nothing more, so a drive created or renamed elsewhere still appears. - sync anyway when a transaction is still unresolved, since nothing but a sync resolves one. The signal is a new local read, SyncRepository.hasPendingTransactions - LIMIT 1 against the status index, no network request - and the sync it starts is a background one, exactly like the login sync it stands in for. - when the sync is skipped the user is told nothing. Silence is the point; Resync stays in the top bar. Nothing about what a sync does once started is touched.
`DriveDetailLoadInProgress` drew a bare `CircularProgressIndicator`: no words, no progress, no drive name, no time bound. That was survivable while a sync held a scrim over the whole app, because nobody could reach the screen during one. Now that a background sync leaves the app usable, and that opening a folder says it heard the click before it waits for the sync, a drive clicked mid-sync sits on that spinner for the length of the sync. A silent failure traded for a blank wait. - new `DriveDetailSyncingCard`, drawn in that slot on both mobile and desktop, with the content the modal used to carry: what is being synced, the drive that will open, the phase the sync names for itself, the modal's own `ProgressBar` - indeterminate treatment included - and the seconds elapsed - `ProgressBar` takes an optional `initialPercentage`. This bar mounts during a sync and the cubit replays nothing, so without a seed it read empty until the next event - up to half a minute in the unmeasurable phase. The modal passes nothing and is unchanged - the top bar's elapsed counter moves to `SyncElapsedTime` so both surfaces count from the same `SyncCubit.syncStartTime` - `DriveDetailUnsyncedCard` is untouched: a drive waiting on a decision still reads as one, and must not be confused with a wait Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
The app rendered both of those as "there is nothing". On every login with an empty local database it drew "Getting Started", two create-a-drive buttons and an empty sidebar for the whole length of the drive-list fetch, on the success path - because `DrivesCubit` reports Drift's immediate empty-table read before `updateUserDrives` returns, and the wait in `showEmptyDriveDetail` did not cover that fetch. - `SyncCubit.waitForDriveListRefresh` waits on the drive list specifically. `waitCurrentSync` still treats `SyncLoadingDrives` as finished, deliberately, so folder opens do not hang behind a refresh - which is exactly the hole the empty screen fell through - three states where there was one: still looking keeps the loading panel, looked and found nothing keeps today's `NoDrivesPage`, and could not look gets `DriveDetailDrivesUnavailable` - a screen that says the drive list could not be read, offers Retry, and neither claims the user has no drives nor offers one to create. Drawn by `DriveDetailSyncingCard` in its own frame, not a new one-off widget - `SyncFailure` gets a persistent surface at the top bar: the red triangle, the same menu header a partial failure gets, and a Try Again that re-runs `syncMetadataOnly`. It was falling through to the idle refresh icon, and `autoSync` is false in all three flavours, so the app looked fully synced while showing nothing, permanently - `DriveDetailLoadUnsynced` carries `syncFoundNothing`, so a sync that ran and found no root metadata says what happened instead of re-rendering the card the user just pressed Sync Now on Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
DrivesCubit reported Drift's first, immediate read of an empty drives table as DrivesLoadSuccess - long before updateUserDrives had said whether the user owns anything. The sidebar, the router's drive-selection listener and the explorer all treat that as fact, so every login with an empty local database claimed the user had no drives for the whole length of the fetch. - gate the emission on SyncCubit.waitForDriveListRefresh() when, and only when, the table is empty; a returning user whose drives are already local answers immediately as before - re-read allDrives() after the wait and stand down if drives arrived, so a stale empty snapshot cannot land on top of the firing that found them - share one wait across every firing of the watch, guard emits with isClosed, and answer anyway if the wait itself throws, so none of the three ways this ends can leave the sidebar quiet for good - wire SyncCubit into DrivesCubit at its one construction site - replace the skipped, @tags(['broken']) DrivesCubit suite with a working harness. It was broken for two reasons, both of them unstubbed mocks whose TypeErrors were swallowed by the async stream listener: ArDriveAuth.onAuthStateChanged() and UserPreferencesRepository.load() - eight tests, each proved to fail with the change reverted
…g PE-9205 An empty nav is indistinguishable from a wallet that has no drives, which is exactly what a returning user sees on a device the app has not read yet. The state underneath is now honest; this makes the nav say so.
Nearly every way a login stranded someone had one cause: the app opened a
drive before it knew enough to do so honestly. "Getting Started" over a list
that was still loading, "Drive Not Synced" as a first impression, a blank
screen for a bookmarked id, a hidden drive restored from lastSelectedDriveId.
A list of drives has none of those, because the drive list is the one thing a
login actually fetches - and it is fetched whether sync-on-login is on or not.
- a new /drives route, and where a login with no link to honour lands
- deep links bypass it entirely: /drives/{id}, /drives/{id}/folders/{id} and
the shared file routes resolve exactly as they always have
- each row: name, public/private, a shared-with-me marker, sync state live
from SyncCubit, items, size and the drive's creation date
- items and size are withheld until a drive has actually been walked. Both
count local rows, and for a drive nothing has looked at that count is zero
for a reason that has nothing to do with the drive
- per-drive "last synced", written when a sync finishes for that drive, kept
in the key-value preferences store rather than a new drives column
- the four states the PR beneath this built: loading, empty, could not be
loaded, populated - with the same words, and "could not be loaded" still
never offering to create a drive
- one "Sync All Drives" offer, on the one login where nothing has ever been
synced, and withdrawn as soon as one drive has been
- opening a never-synced drive from the list syncs it in the background; the
explorer's panel reports it from there
- desktop and phone are two layouts, chosen on real width, not one squeezed
The sidebar is untouched: it stays navigation, and the drive metadata lives
here so the two cannot drift apart.
Review fixes:
- the retry no longer says the account is empty while it is running. Try Again
starts a drive-list refresh, and syncMetadataOnly emits SyncLoadingDrives
before its request - which woke the list, found an empty table and landed on
"Getting Started" for the whole retry. The cubit now withholds emptiness
while a refresh it started is in flight, and again while the drives cubit
lags the table that refresh has just written
- a sidebar drive tap opens that drive. selectDrive announces the choice, the
list navigates on it, and the tile for the already-selected drive selects
rather than returning early - so on the drives list, where the selected drive
is not what is drawn, the tap is no longer silent
- one source of truth for the columns/stacked decision: the page decides on the
width the row will actually lay out in, and tells the header and every row.
A table header over stacked cards was reachable at a 972-1004px window
- the last-synced column is wide enough for the sentence it carries: the
breakpoint and the flexes are now fixed by that measurement rather than by eye
- the sync-everything button has a width, instead of expanding to a 1200px slab
of primary colour on the one screen that says nothing is wrong
- the whole page scrolls. At 568x264 the sync card filled the viewport and the
list was given no height at all
- withheld figures are dropped from the stacked layout, where no heading and no
hover tooltip can explain them, and the mark itself is ASCII: Wavehaus has no
em dash, en dash or ellipsis
- the list stops growing past 1200px
- DriveListRow.isSelected, never passed and describing behaviour the page does
not have, is gone
Browser-test fixes:
- a single-drive sync no longer shows a motionless "0% complete" for the whole
first GraphQL round trip. The walk's first fraction is read off a block
height and none exists until the gateway answers, so that stretch now names
itself - "Reading the drive history..." - and tells the bar it has nothing
to measure. Both are given up the moment the walk reports a fraction, and
the percentage takes over exactly as before. No invented ramp
- a sync that fails no longer ends still naming the phase it died in
- the drives-list sync trigger is decided at the moment of the tap, off the
sync cubit, rather than off the item a row happened to be drawn with: that
item is a snapshot whose isSyncing is true for every row while an all-drives
sync runs, and deciding from it is how a tap could silently start nothing.
Now guarded by drives_list_open_syncs_test
- the paragraph under "Your Drives" is gone. It said nothing here had been
fetched from the network, which stopped being true when opening a drive
became the act that fetches it
- no first person anywhere on the page: the drive list that could not be read,
the offer to sync everything, and the top bar's sync-failure line
- the sync-everything card is two columns - words left, button right - down to
a breakpoint measured off the title in Wavehaus (173.1px) by a test that
loads the real face, and stacked below it
- a per-drive overflow menu: sync this drive, rename, share, hide/unhide,
detach. Every one of them calls the drive detail page's own implementation;
no new dialogs. Owner-only and shared-only actions are absent rather than
inert. 48px tap target, sitting in a gutter the header reserves too, and it
adds no height to a row on a phone
- a stacked row shrink-wraps its height instead of filling whatever box it is
given
In a perfect world it just works, and the app says nothing. If it is not
working, the user can keep drilling in. Every phase and every state is still
accounted for - it moves from "always on screen" to "one tap away".
Level 0 - the spinner, and nothing else. The always-visible SyncStrip and
SyncNarration are gone: no banner, no reserved slot, no hairline progress
line. The top bar's ring is the whole of what a running sync costs the
chrome, and the page reaches both layouts unwrapped.
Level 1 - tap the spinner. Restore the status header in the sync dropdown,
carrying what nothing else in the app says: which sync this is - naming the
drive, from SyncCubit.syncingDriveId and DrivesCubit, which the strip never
did - the phase or "Reading {completed} of {total}..." while a metadata fetch
is in flight, and the elapsed time. A tap on both breakpoints, because a
phone has no pointer.
Level 2 - sync history, in the Troubleshooting modal, beside the diagnostic
logs a user with a problem is already there to send. Each entry: when it
started, how long it took, what asked for it, which drive, what it found,
what it could not read and the error text where there is one. The sync
dropdown gains ONE row that opens it - the dropdown sizes itself as
items.length * 48 and closes on any tap inside, so it is the wrong container
for a scrolling record.
- store the history as JSON in the key-value preferences store, the way
per-drive last-synced is stored: no .drift change, survives a reload,
cleared on logout. Capped at 20 runs, trimmed on the way in.
- record a run from SyncCubit at each terminal point, fire and forget and
wrapped in its own try/catch, exactly like the per-drive timestamps beside
it. A sync refused before it started is not recorded: nothing was fetched.
- move SyncTrigger to its own file, re-exported by SyncCubit, so a record on
disk does not depend on the cubit that wrote it.
- fix a 320px/text-scale-2.0 overflow this surfaced: every ArDriveDropdown
item was laid out at its own intrinsic width, so "Deep Resync" ran 58px off
the right edge of every menu in the app. The item is Expanded now and its
label can ellipsize.
- fix the same class of overflow on the support modal's resource links, and
let that modal scroll now that it is taller than a phone.
Nothing about what a sync does, when it starts, its batching, watermark,
snapshot validation, progress arithmetic or error handling is changed, and no
new network request is made.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
… PE-9205 The menu row labelled "Sync history" called openHelp: it opened a modal titled Help and left the reader to scroll past support email, Help Center, Discord, Docs and Troubleshooting to reach the record. A row that names one thing and opens another is worse than a row that is hard to find. - showSyncHistoryModal: the record, its own title, its own scroll - the sync menu's row opens that, so the label and the destination agree - Help keeps a one-line link to it beside the logs, not the panel itself - the description of what the record covers moves with the record Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
It sat between the New button and the drive list, indented 43px to line up with the accordion's uppercase category headings but written in sentence case with an icon - so it read as a fourth category rather than as the page it opens. - moved directly under the logo, above the New button: destination, then the action that creates one, then the drives themselves - lit with containerL1 at radius 4 when the drives list is on screen, the same tokens a selected drive row is lit with, because it answers the same question - aligned to the drive rows' left edge so the two highlights agree - the explorer's breadcrumb now starts at "Your Drives /", which is where a file manager puts the way up and where a reader looks for it - one name for one destination: nav, page and breadcrumb all say Your Drives Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
Five defects from the adversarial review, all of them consequences of the login default moving to metadata-only: the full sync was quietly doing this cleanup, and nothing took over when it stopped running. - updateUserDrives cached a *completed* fetch on a repository that outlives the session, so logging out (which drops every local table) and back in joined a fetch that had already happened, wrote nothing, and left the app stating the user has no drives. Same wallet, same tab was enough. It now only joins a fetch still in flight. - _recordSyncRun and _recordDrivesSynced fire unawaited and landed after logout cleared the store, resurrecting the previous wallet's history for the next one to read. Both check isClosed. - syncMetadataOnly emitted a terminal state unconditionally, landing it on top of a full sync the user had started underneath it - releasing every waitCurrentSync against a half-written database. It now reports only if nothing else has taken the state off it. - onError fell from SyncFailure straight back to SyncIdle in the same turn, so driveListRefreshFailed was never true and an unreadable drive list was drawn as 'Getting Started'. SyncFailure now rests. - waiters asked for SyncIdle when they meant 'the sync is over'. SyncCompleteWithErrors is not a SyncIdle and nothing clears it, so a share arriving during a sync that lost one drive was dropped in silence. One public predicate now answers that question, and it counts SyncWalletMismatch, which was missing. Also converts the five emits that could still throw after close, and cancels the share listener's subscription once it has fired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
…PE-9205 - a shared drive's name was the child that gave when the row ran out of room: at 320px it collapsed to zero width and vanished while the badge ran off the screen, from about 1.1x text scale. The two now wrap. The row's test harness had no text-scale knob at all, which is why its own '320px does not overflow' case passed; it has one now, and runs at four scales in both themes. - ArDriveDropdown discarded the caller's whole anchor whenever calculateVerticalAlignment was supplied, so the per-drive actions menu - which supplies both - was re-anchored to its button's bottom-left with no shifting and opened 51px off a phone's left edge, every item's icon outside the viewport. Only the follower is the calculation's to decide. - the drives list is offered only to someone who can reach it. An anonymous share-link viewer gets the explorer but not the list, and was handed a nav entry and a breadcrumb root that flipped a flag nothing read: the screen never changed, the address bar claimed /drives, the entry lit up as the page in view, and every later tap was a hard no-op. One predicate now answers for the branch and both doors. - attaching a drive honours the refusal startSyncForDrive now returns instead of discarding it and selecting a drive that was never read. - 'Reading X of X' can differ: the total is the chunk in hand, announced before any of it is fetched, rather than growing one batch at a time to meet the count at every boundary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
SyncCompleteWithErrors has always carried failedDriveIds; only the top bar's retry item read them. So after "1 of 5 drives failed" the list a reader goes to next showed every row as either "Never synced" or a stale timestamp, and the drive that failed looked exactly like the four that did not. DriveListItem carries it as its own fact, because none of the others can stand in for it: a failed drive keeps whatever lastSyncedAt it had. Ranked below a running sync, which is happening now, and above the timestamp. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
_recordSyncedEntities ran inside runTransaction, before the commit, so a transaction that rolled back left its rows counted anyway - and the sync reported changes the database never took, in 'N items changed' and in the history it persists. Staged now, and promoted only once the transaction returns. Discarded otherwise, so a failed batch cannot be counted by the next one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
…ts PE-9205 - the sidebar says nothing while the list is being read. The nav is where a reader looks for drives, not for a report on fetching them; the surfaces actually waiting on it already say so. - 'Loading your drives...' carries a figure once there is one: the drive transactions are listed before any is fetched, so the total is known up front and the count is real rather than a guess. Reported through runPooled's existing onItemDone, so nothing new polls. - the count rides SyncLoadingDrives and DrivesListLoading rather than being read off a cubit at the widget: DrivesListBody draws what it is given and reaches for nothing, which is why its tests need no providers. - one helper words it, because three surfaces say it and they report one sync between them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
…thers PE-9205 The router's profile listener had no ProfileLoggedIn exclusion, so on every profile emission - a sync finishing, a drive being selected, anything that made ProfileCubit republish - it set signingIn for a signed-in user, and the block below then cleared it and took showingDrivesList with it. Two symptoms from one cause: a user thrown back to the drives list at random, and a showDrivesList whose early return found the flag already set and did nothing, so neither the nav entry nor the breadcrumb worked. - the redirect-to-sign-in never fires for somebody already signed in - showDrivesList clears the routes checked ahead of it, and only returns early when the list is demonstrably what is on screen rather than when a flag says so - 'Your Drives' is gone from the sidebar; the breadcrumb is the way home - opening a drive waits only for a sync that could be writing *that* drive, so syncing drive B no longer makes drive A unopenable - pressing Sync on a drive row records as Manual, not Automatic: the trigger was doing double duty and the record was simply wrong - 'Reading N of N' says 'Reading N files...'. The total was only ever what had been asked for so far, which the count met at every batch boundary Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
… PE-9205 The sidebar was the wrong container for it: that sidebar already lists every drive, so an entry pointing at 'all drives' sat directly above a list of all your drives and read as redundant however it was styled. Taking it out left only the explorer's breadcrumb, which _desktopView alone builds - so a phone had no way back to the list at all. The help button's slot is the one place that exists on every screen and both breakpoints, so the house takes it and help moves in beside log out, where a reader looks for it anyway. - HomeButtonTopBar in all four top bars, gated on the same predicate the router uses before it will draw the list - the breadcrumb root becomes the same house, so the two doors to one place read as one thing - Material's house until the icon font has one of its own Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
There were three, and they did not agree: the drives list drew a linear bar in buttonPrimaryDefault, the explorer's panel drew one in textHigh, and the top bar turned a ring - so the same wait read as a red bar on one screen and a white one on the next. The plate stack is the loader this app already uses while it sets up an account and prepares an upload, so it is the mark a user has been taught means ArDrive is working. Sync had simply never used it. - SyncLoadingIndicator: the plates, shared by every surface that waits - the drives list shows it instead of a bar it could not fill - the explorer's panel shows it above the bar, not instead of it: motion means working, the bar means progress, and neither pretends to be the other - the measured bar fills in the same token the ring does Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
The delegate's Navigator held one page under a constant ValueKey('AppShell'),
so however the branch chain resolved the key never changed, the Navigator
treated it as the same page, and it kept the route it already had. The
address bar was right and the screen was wrong: showDrivesList set the flag,
currentConfiguration reported /drives, the URL changed - and the drive the
user was looking at stayed on screen.
The page is keyed by which shell it is, named inside each branch so it cannot
drift out of step with the chain that assigns it.
Also names the second half of the drive-list read. After the pooled fetch
there is a serial loop that, per private drive, reads a signature over the
network, derives a key against the wallet and decrypts the metadata - so the
count reached its total and appeared to hang with nothing said. It now says
'Unlocking your private drives... 2 of 5', and never appears when there are
none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
Info is the one menu item that cannot act from the list. The panel is DriveDetailCubit.selectDataItem, which begins 'this.state as DriveDetailLoadSuccess', and the cubit that page provides is built against no drive - a different instance from the explorer's besides. Nor can the panel be shown in a dialog: it is a side panel, returning a Flexible around its own card for a full-height parent, and every attempt to host it in one overflows. The row cannot stand in for it either, because Activity and Snapshots are tabs that live only there. So Info asks rather than acts, on the same one-shot mechanism a folder deep link uses: the request is recorded on the router, the drive opens by the road every other tap takes, and the explorer's own cubit opens the panel when it reports the drive loaded - where a side panel belongs and where its tabs work. Separately: prompt_to_snapshot_bloc_test waited 250ms for a prompt the bloc schedules at 200ms. Fifty milliseconds is not a margin under a full suite; it failed about one run in two, which is the flakiness the review reported and I had wrongly written off. Three full runs green since. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
showDrivesList kept guessing whether the drives list was already on screen so it could skip a duplicate history entry, and every version of that guess has failed the same way: the flag reads true while something else renders, the request is dropped in silence, and Home is dead for the rest of the session with the address bar insisting /drives over a drive. There is no condition that method can test which proves what is on screen, so it no longer tries. A repeated history entry is the cheaper failure by a wide margin than a navigation control that does nothing. Also logs which shell the router chose and the state that decided it. If this is still reachable, that line turns the next report from a description into a diagnosis. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
ArDriveAppWithDevTools hosted the whole app in an Overlay through initialEntries, which Flutter reads exactly once - when the Overlay is created. Rebuilding produced a new OverlayEntry closing over the new app tree and the Overlay ignored it, so AppRouterDelegate.build() ran, produced the right tree, and had it discarded. The mounted tree then only re-evaluated when some bloc emitted. That is why navigation driven by a cubit worked and navigation driven by the router's own fields did not: showDrivesList set its flag and notified, nothing rebuilt, and the user was dropped on the drives list minutes later when an unrelated sync happened to emit. Every routing fix before this one was correct and had no way to take effect. Also: - opening a drive never starts a sync, from any surface. A tap in the nav is a request to look at something, not to fetch it; a drive with nothing local lands on the card that says so and carries its own Sync button. - every sync started by pressing a control records as Manual. The trigger was overloaded to mean 'do not raise a modal', and that modal is gone. - one indicator per wait: the plates where a phase cannot be measured, the bar where it can, never both. - docs/LOGIN_TO_SYNCED.md traces wallet connect to completed sync, marking what blocks and what does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
…9205 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
Three reviewers over the sync experience - edges, what it tells the user, and how it looks. Four of these I introduced in this stack. - a sync that failed rested in SyncIdle. onError left SyncFailure standing but both tails dropped SyncIdle on top in the same turn, so the ring stopped and a failed sync looked exactly like a successful one. The top bar has a failure branch and was never given a state to render. - startSync and startSyncForDrive returned true after throwing, against their own documented contract - which DriveAttachCubit reads to decide whether to retry. - attaching a drive reliably synced nothing. The attach dialog runs through performUninterruptableActivity, which holds ActivityInProgress until the route finishes closing 200ms after Navigator.pop, and both attempts landed inside that window. It now waits for the activity, bounded. - syncMetadataOnly painted over a running full sync on the way in. The terminal emit had this rule; the entry emit never did. - 'Loading your drives... N of M' counted transactions, not drives: the listing dedupes per GQL page, so a wallet spanning pages read '43 of 137' for twelve drives. - 'Unlocking your private drives... N of M' counted only keys derived here, so it stopped short whenever a signature could not be read and sat at '0 of 5' for a whole second pass with every key already cached. - the explorer's panel chose plates or bar at mount and never revised it, so a panel opened at 99% kept a dead bar after the sync ended. - a failed drive was drawn in the same grey as a success. The top bar reports the failure in red and sends the reader to the list; the list whispered it. - the way home was a 24px target in a 34px box that looked live, and the breadcrumb house vanished exactly when the trail collapsed - two folders deep on a phone, which is when it is most wanted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
… PE-9205 Keying the Navigator's page by which shell was on screen made it replace the route on every move, tearing down the sidebar and the top bar with the body and running a page transition over the top - which reads as the whole page reloading rather than the middle of it changing. The key was added when the router's rebuilt tree was being discarded, before that turned out to be Overlay.initialEntries in ArDriveAppWithDevTools. With the real cause fixed the route updates its own child, so the key goes back to being the constant it was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
…king PE-9205 Four follow-ups, three of them from a staging pass. The onboarding screen showed a grid of white dots with a seam across it. The dots are deliberate texture and the radial gradient over them is meant to fade them out - but at `radius: 2.5` the transparent stop sits two and a half box-widths away, so everything actually drawn came from the first fraction of the gradient. The dots stayed near full strength all the way down and the ClipRect ended them on a hard horizontal line. A radius of 1 puts the transparent stop at the box's own edge, which is what makes it a fade, and dark mode drops from 40% to 20%: texture is what you stop noticing. A transaction that cannot be found is now called failed after two hours rather than eight, which is about sixty Arweave blocks. One that has not been mined in sixty blocks is not waiting its turn - either the network has stalled or the bundler is out, and neither resolves by being waited on longer. The comment above it claimed forty-five minutes while the constant said `60 * 8`, so nobody reading it knew what it did; the reasoning now lives on the constant. The confirmation watch moves to twenty minutes, about ten blocks, which also divides that window evenly - six looks before an upload is given up on. And GraphQL retries now distinguish a failure about the moment from a failure about the request. `package:retry` retries every exception when `retryIf` is not given, and the budget is five attempts with growing backoff, so a request that could not succeed spent six seconds failing five times - and delayed the fallback that might have helped. A 4xx is not retried. Neither is a GraphQLException raised from an `errors` payload: that came back over a *successful* HTTP response, so the endpoint read the question and answered it. Anything with no status to read - a dropped socket, a DNS hiccup, a timeout - still is, because that is what a retry is for. Not changed: skipPendingTxFetch keying off ownership rather than write access. Raised as an inconsistency and it is not one - a drive somebody else owns cannot be uploaded to, so ownership *is* write access here. Worth knowing that predicate is where it would bite if that ever changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
…ong axis PE-9205 Both from CodeRabbit on #2217, both real. The pending-threshold comparison used `>` against a truncating `inMinutes`, so a transaction was not called failed until its 121st minute. The confirmation watch only looks every twenty minutes, so missing the boundary by one minute did not cost a minute - it cost a whole cycle, and a transaction sat pending until 140 minutes while the constant said 120. Both comparison sites move to `>=`, and the constant now carries the reason so it does not get tidied back. The gradient comment claimed the radius is scaled by the box width. It is scaled by the paint bounds' shortest side, which for a full-width box 264 tall is that height at any real viewport - so radius 1 puts the transparent stop 264px below the top-centre origin, exactly the bottom edge, and radius 2.5 put it 660px down, leaving the bottom of the box only 40% through the gradient. The fix was right; the explanation was off by a dimension, which is the kind of comment that misdirects whoever tunes this next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
PE-9205: Fade the onboarding dots, call a failed upload sooner, retry only what a retry can fix
…9205 Two problems on "You're on chain!", the page a brand new drive opens on. Its buttons were `ButtonVariant.secondary`, which on the dark card is `solidGrey700` against a `solidGrey800` background - while the *disabled* button token is `solidGrey600`. The button that worked was less prominent than one that would not, which is a reasonable description of "doesn't look clickable". Both offers are primary now, on both layouts. This page has exactly two cards and its whole job is to get one of them pressed, so two primaries is the design rather than a competition. On a phone the same two offers were 283px squares stacked under a heading and a paragraph: 239px of scroll on a 375x667 screen, with Create Folder sitting at y=866 - two hundred pixels below the fold, on the one page whose job is to say "well done, now do one of these two things". Laid on their side they take about 130px each and the whole page is visible at once. The SingleChildScrollView stays. At a large enough text scale something has to give, and a page that scrolls degrades better than one that overflows. The four-card empty states are left alone: they have the same grey-on-grey problem, but four red buttons is a wall rather than a fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
Coming back to an existing session restores everything from the local database and syncs nothing, which is right: IndexedDB survives a closed tab, so counts, sizes and block heights are all still there, and `autoSync` is false in every flavour. The cost is that the page says nothing about how old any of it is. A reader who closed the tab on Tuesday sees Tuesday's file counts drawn with exactly the confidence of current ones, and the only cue is a `Last synced` cell they have to read and do arithmetic on. The one prompt this page has is gated on `nothingHasEverBeenSynced` - every drive unwalked - so it only ever fires on a first login. Returning readers get silence. So: one query, at the exact moment the app currently goes quiet. `probeActiveDriveIds` already exists and the sync already uses it to skip unchanged drives; `probeDrivesWithChanges` runs the same per-owner query read-only, writes nothing, and starts no sync. If drives have moved, a neutral notice above the table names how many and offers to sync *only those*. If nothing moved, nothing is said. An unanswerable probe says nothing either. That is the opposite of the fallback inside a sync, where `isComplete: false` means sync everything: there the cost of guessing wrong is a slower sync, here it is a banner claiming changes nobody has confirmed, which is a nag. Two bugs found while testing it: `close()` shuts the subject down before it awaits its way to `super.close()`, so there is a window where `isClosed` is still false and adding throws `Cannot add new events after calling close` - out of an un-awaited future, where nothing catches it. Guarded on the subject, not the cubit. The failure swallow lived in the repository, which left the cubit's fire-and-forget call unprotected: an exception there was an unhandled async error rather than a failed request. Caught at the boundary that is actually un-awaited. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
…t PE-9205 A new user creates their first drive, uploads five files, and opens Your Drives. The row said `Never synced`, a dash for files and a dash for size - not a cautious label over correct figures, but the figures withheld. And because that was their only drive, `nothingHasEverBeenSynced` was trivially true, so the card announcing "Nothing has been synced yet - their contents have not been fetched yet" went up over contents they had just put there themselves. Both come from `hasBeenWalked` being asked two questions and answering with one flag. "Has anything read this drive from chain?" is what a block height knows, and it is right about that: `createDrive` inserts without one and the schema defaults it to zero. "Do the local numbers mean anything?" is a different question, and for a drive this device created and uploaded to the answer is yes - `writeFileEntity` put those rows in `fileEntries`, and they are as true as any sync would make them. So the figures are shown when the drive has been walked *or* has local content, where local content means a non-zero count. A zero from a walked drive still means empty; a zero from an unwalked one still means unknown. Only a real count proves the device knows something. `Never synced` stays in the last-synced column, because it is true and nothing here has checked the chain. It just no longer sits next to two dashes, which is what made it read as a failure rather than a fact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
…ktop PE-9205 Desktop search is global chrome - a field in `AppTopBar`, capped at 400px, beside the hide toggle. Mobile search was page furniture inside the drive explorer, taking a 60px band above the file list on the layout with the least vertical room to give. The field also did nothing until it was submitted, at which point it opened the same bottom sheet the icon now opens. So nothing is lost by starting from the sheet, and the band is gained. `showSearch` is opt-in rather than always on, and that is the load-bearing part: `MobileAppBar` is also worn by the drives list and the no-drives page. The drives list subtree provides a `DriveDetailCubit` built against the root path rather than a chosen drive, and the no-drives page has nothing to search - so a bar that offered search everywhere would be offering it where it could only disappoint, or crash. Drive detail asks for it; nothing else does yet. Also removes a `TextEditingController` the explorer held for the field and never disposed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
A file waiting to be confirmed shows a pending dot, and until now the only two ways to change it were to wait up to twenty minutes for the confirmation watch or to run a whole sync. The pass that actually answers the question is already standalone in everything but reach. `_updateTransactionStatuses` runs wallet-wide at the end of `syncAllDrives` and reads its pending transactions out of the local tables rather than out of the walk, so it does not depend on the sync around it, or on which drives that run happened to cover. `refreshTransactionStatuses` exposes exactly that pass and nothing else: no history read, no block-height watermark moved. It cannot find a file somebody else uploaded - only settle the state of one already known about, which is precisely what a pending dot is asking. On the file kebab, and only while that file is pending: a confirmed file has nothing to check, and a menu item that is present and does nothing is worse than one that is absent. Refused while a sync runs, on the standing one-at-a-time rule - that sync ends with this very pass, so a second would ask the same question twice and race its own answer. On a refusal or a failure the row keeps the status it had and the app says nothing, rather than claiming a check it did not complete. Worth recording what this is *not*: the twenty-minute watch was never doing a full walk to confirm an upload. The probe skips drives with no newly-mined activity and the status pass runs regardless, so that path already costs about three queries. What did not exist was any way to ask on demand. The saving here is the twenty minutes, not the queries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
Three from CodeRabbit's review of the first half of this branch, all real. The probe is un-awaited, so a sync can start *and finish* while it is in flight. That sync's completion retires the ids it just read, and the late probe answer then put them straight back - the list offered to sync drives that had only just been synced. Checking the state again after the await does not catch this, because by then the sync is over and the state is idle: only something that counts runs can tell "no sync happened" from "a whole sync happened". A generation is now read before the await and compared after it. `probeDrivesWithChanges` documented an unconfirmable probe as returning nothing and then wrote `continue`, which drops one owner and reports the rest. That is a partial answer through an interface documented to return none: a count that reads as "these are the drives that changed" when it means "these are the ones we could confirm, out of some number we cannot state". One incomplete owner now ends the whole probe. Silence is the fallback this method is built around, and it has to hold for a partial failure too or the contract means nothing. And the probe tests waited fifty milliseconds on an un-awaited future, which is a race dressed as a test. Each stub now completes a `Completer` and the tests await that. The race test was checked by removing the guard, which is worth recording because the first attempt at that check was broken: the formatter had collapsed the condition onto one line, the string replace matched nothing, and a silent no-op looked like a passing vacuity check. With the guard genuinely gone the list shows the synced drive and the test fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
CodeRabbit, Major, and correct: `refreshTransactionStatuses` called `_updateTransactionStatuses` with no cancellation token. That method writes rows through `insertNewNetworkTransactions`, and this repo already knows what that means without a token - `sync_shutdown_test.dart` spells it out. `ArDriveAuth.logout()` empties every table *before* the cubit closes, and `SyncRepository` is an app-level singleton above the auth gate, so anything still in flight spends the next few seconds writing the previous wallet's rows into a database that has just been cleared. The sync path carries a token for exactly this; the new on-demand refresh was a second door into the same hazard with no lock on it. It gets its own token rather than sharing `_currentSyncToken`: that one is cancelled and replaced by every sync, and a refresh must neither be torn down by a sync starting nor survive one that cancelled it. `close` cancels it, and a refresh that finds itself cancelled while the gateway was answering reports false - the wallet it was about is gone, so there is nothing to tell anyone. The batch loop in `_updateTransactionStatuses` already checks the token before each write, so passing one is all that was needed for it to bite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
CodeRabbit, on the token fix itself, and right. `SyncCancellationToken.dispose` closes the token's stream controller and nothing else - it does not set `isCancelled`. So a token disposed without being cancelled answers false forever and `checkCancellation` never throws: two overlapping refreshes, and the replaced one goes on writing statuses underneath the live one. That is the leak the token was added to stop, one layer in. Cancelled then disposed, and the order matters both ways: the reverse throws, because `cancel` adds to a controller `dispose` has already closed. The `close` path happened to have it right already. Left alone: `startSync` has the same shape at its own token. It is not reachable there - the one-at-a-time rule means the previous token has always finished - so this does not widen into pre-existing code to fix something that cannot happen. Worth knowing it is the same shape if that guard ever loosens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
PE-9205: What a returning or brand-new user is told about their drives
"Check upload status" shipped into the row kebab alone. The details panel builds its own copy of that menu - `EntityActionsMenu`, used by `details_panel.dart` - so the surface somebody opens to find out why a file is showing an amber dot could do nothing about it. Half the app had the feature. This is the trap `UnsyncedDriveMenu` carries a comment about, one file over: "One menu, mounted twice... a fix applied to one copy is not a fix, because nothing in either copy says the other exists." Walked into anyway. So it becomes a free function beside `hideFileDropdownItem`, which is a free function for exactly this reason, and both menus call it. The test for it reads the source and counts uses, which is unusual and deliberate: the failure mode is an omission across two copies, and a widget test proves only that the arrangement it happened to build works - it cannot notice the copy nobody wired up. Counting call sites is what actually answers "do both offer this?". Also closes three rendering paths that were built and never looked at: - Dark mode on "You're on chain!". The card sits on `containerL2` and the button colour is picked per theme, so "does it fit" had only ever been asked in one of the two themes it ships in. - 1.6x text scale on the same page. It asserts no RenderFlex overflow and that both offers stay reachable, *not* that it fits - at some scale it cannot, and that is the whole reason the SingleChildScrollView stays. - The unread-changes notice at 375px, where its Wrap has to fall back to one column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
Both from CodeRabbit, and the first one undoes a fix that fixed nothing. "Check upload status" went into `EntityActionsMenu`'s `DriveDataItem` branch rather than its file branch. So the gap this was written to close - the details panel having no way to act on a pending file - was still open, and a drive was being offered an action about a file's upload. The file branch is the fallthrough that casts `item as FileDataTableItem`; it is there now. The parity test passed straight through that, because counting call sites proves "three uses exist" and not "the right branches have them". It now slices `_getItems` into its type branches and asserts the item is in both file branches and in no folder or drive branch. Checked against both bugs that actually happened: omitting it from the second menu fails one test, putting it in the drive branch fails both. Worth recording how the misplacement was caught on the way out, too. Moving the code failed an `assert count == 1`, because both menus carry a byte-identical `promptToDownloadProfileFile` fallthrough - so the edit stopped rather than silently landing in whichever one came first. That is the same silent-no-op that went unnoticed earlier in this stack; the assertion is the difference. And the phone-layout test asserted `findsOneWidget`, which says mounted, not readable: a widget laid out past the right edge passes it and is still invisible to whoever is holding the phone. It now checks real bounds - left at or after zero, right within the screen, non-zero size - and that the button is above the fold, since a notice nobody scrolls to is a notice nobody reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
PE-9205: The item menu exists twice, so the item has to
Every shared drive, folder and file renders as this card, and it carried "Secure, permanent storage." plus five tags, one of them wrong. The copy now says what the product does rather than what category it is in: permanent storage on Arweave, paid once, with nothing to renew. Accurate and specific beats accurate and generic on a card somebody sees before they have heard of you. Four tag fixes: - No `twitter:card`, so X rendered the small summary card and cropped the 1200x630 image to a square. Title, description and image all fall back to the Open Graph tags, so the one line is the whole fix. - `og:image:width` and `height` were absent. The asset was fetched and measured rather than assumed - it really is 1200x630 - so scrapers stop guessing and cropping while the image loads. - `og:image:alt` was absent, which left screen-reader users with nothing. - `og:url` was hardcoded to https://ardrive.io. This page is also served from app.ardrive.io, staging.ardrive.io, PR previews and any AR.IO gateway, so on all but one of those it claimed a canonical identity that was not its own - and a card for a shared file could send a reader to the marketing site instead of the file. Removed rather than corrected: with no og:url a scraper falls back to the address it fetched, which is the only answer that is right on every host. The comment records what this card cannot become, because it is the kind of thing somebody will reasonably want later. Under hash routing a crawler never receives anything after the `#`, so per-link previews are impossible today - and share links carry `n` and `ct`, which are private-file secrets. A per-link preview means a server reading those to render a filename into a public card, so whatever this grows into must never cover private links. The GitHub repository description was empty and is set to match, with the homepage pointed at app.ardrive.io. Topics were already in place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
The description landed on "Permanent storage for files, sites and apps, built on Arweave." after several rounds that all failed the same way, and the failures are the useful part. ardrive.io leads with "Pay once to store your files permanently. No subscriptions, no renewals, no data loss." That is the right pitch for somebody deciding whether to sign up. This card is mostly seen by somebody who was sent a link and is about to open a stranger's file, and telling them there is no subscription answers a question they did not ask. More importantly, two of those claims are the kind that stop being true. "No subscriptions" is a promise about a business model, so it is a hostage to the roadmap. "No data loss" is an absolute about an outcome, so it only has to be wrong once. A brand whose entire product is permanence cannot afford marketing that expires - archived pages and quotes become receipts. Claims about what a thing *is* survive both a pricing change and a bad day; claims about what it will always *do* do not. Everything tried in between overreached in one direction or another: links that never break (they sometimes do, gateways being gateways), nothing that expires, data that is yours to keep, owning your data. Each added something to disagree with. The noun was already carrying the difference. Also drops the pricing lead entirely. Arguing you are a cheaper Dropbox is arguing on Dropbox's terms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
**Search on All Drives.** `DriveDao.search` takes no drive id - it is global across every drive plus ARNS names - so this page had no business being the one screen without a way in. Desktop search lived in `AppTopBar`, which only the explorer mounts; mobile search was opt-in on `MobileAppBar` and this page had not opted in. Adding the field was the easy half. The explorer navigates a result by calling `openFolder` on its own `DriveDetailCubit`, which works there because that cubit is long-lived and switches drives underneath the page. On the drives list it is not: selecting a drive replaces the whole subtree, so the cubit the modal was handed is torn down mid-navigation and the reader lands at the drive root instead of the file they searched for. `AppRouterDelegate.requestFolder` is the road that survives it - asked for before the selection, honoured by `onDriveSelected` when the drive arrives, and one shot so navigating away and back lands at the root. The callback is optional, so the explorer keeps its own path rather than taking a slower road to the same place. Known gap, stated rather than papered over: from the drives list a *file* result opens its containing folder but does not pre-select the file, because the router opens a drive at a folder and has nowhere to put an item to select. **"Pinned" was the third meaning of that word in one product.** An ArDrive File Pin brings a permaweb file into a drive, IPFS pinning is what many readers arrive already knowing, and neither is what this chip meant - a link aimed at one fixed version. It says "This version" now, which is what it is. That rename overflowed the version row by eleven pixels, because the chip was the only thing in it that could not give: the date is Expanded and the size is short and numeric. Any longer translation would have done the same, so the row is the fix rather than a shorter word. **A selection nobody could see.** Both sidebar sections ship expanded, so a wallet with a long public list pushes the private one below the fold - and opening a private drive highlighted a row off screen, which reads as the click having done nothing. It scrolls to the selection now, and only when the selection actually changes: a sync tick alone rebuilds this several times, and scrolling on each would drag the list out from under somebody reading it. Scrolling rather than collapsing the other section, because a reader who wanted their public drives hidden would have collapsed that section themselves. Doing that turned up a real bug underneath: `DriveListTile` forwarded its own `key` to an inner `GestureDetector`, putting one key on two elements. Harmless for a `ValueKey` and an outright duplicate-GlobalKey assertion the moment anything needs to find the tile - which is why an existing test started failing the moment this one did. **"Each row will say what it holds as it arrives"** told the reader to go and watch something, and narrated the interface rather than saying the one thing the screen could not: how long this takes. It says "Large drives can take a few minutes." Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
PE-9205: Give the shared-link card more than one sentence
PE-9205: Search on All Drives, and three things that read wrong
… PE-9205 #2221 shipped search on the drives list with navigation that does not work. A file or folder result lands at the root of the right drive rather than at the thing that was searched for. `requestFolder` set a pending folder and `onDriveSelected` honoured it - but `onDriveSelected` is only mounted on the explorer's branch of the router and never sees a drive opened from the list. The road that actually runs is `OpenDriveOnSelection`, which hears the selection and calls `openDriveFromList`, and that cleared the pending folder outright. The tests passed because they called `requestFolder` then `onDriveSelected` directly: a path that exists, and not the one a reader takes. The new group goes through `openDriveFromList`, and three of its tests fail against what is live on staging now. A first attempt at the fix broke `drives_list_routing_test` - correctly. A folder deep link and a search request were sharing one pair of fields, and they are not the same intent: arriving through `/drives/X/folders/Y` and later tapping X on the list should open X at its root, because a link is not an instruction to resume. A search result is the opposite - the reader picked that folder out of a list of them. Search requests now have their own fields, so `openDriveFromList` honours one and discards the other. The existing test needed no changes, which is what says the invariant was real. **Highlighting the file.** `DriveDetailCubit` takes an `initialSelectedItemId`, carried on the same one-shot road, so a file result now arrives selected the way it does from inside a drive. It is keyed by drive id because `_driveDetailCubit` is built twice over - once for the list against the root path, once for the explorer against the chosen drive - and unkeyed the list's cubit would swallow it. **A crash that was already there.** `openFolder`'s `firstWhere` had no `orElse`, so a selection that is not in the folder threw out of the folder load. There are ordinary reasons for that: the file moved or was deleted between the search and the tap, or hidden items are being filtered. Failing to highlight a row is a disappointment; throwing is a broken screen. **Two more found by attacking this before shipping it**, neither of which the happy path or CI would have caught: - `clearState` clears every sibling pending field on logout and did not clear these, so a search from one session could reach the next person to sign in on that device. - A request honoured in a frame where the explorer never built sat waiting, and would have highlighted a stale file the next time that drive opened. `showDrivesList` abandons it now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
…9205 CodeRabbit, and the interesting part is which half was missing. `showDrivesList` cleared `_selectedItemForFolder` and its drive id - a request that had been honoured - and left `_requestedFolderDriveId`, `_requestedFolderId` and `_requestedItemId` alone. So a request that was never spent could survive a return to the list and jump a later, unrelated row tap into a folder somebody searched for once. No live path is known to reach it: `requestFolder` is always followed immediately by the selection that spends it. This is consistency rather than a second live bug. What makes it worth taking is the shape. The comment above those two lines said "going back to the list abandons whatever a search asked for" while the code abandoned two fields out of five - and a comment claiming more than its code delivers is exactly how the honoured-but-unconsumed case got missed in the first place, one commit ago. Same failure mode, caught earlier. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
PE-9205: A search result from All Drives never actually opened its folder
Ninety-five commits since 2.87.1, and a minor rather than a patch because most of them are things a user can see: search on All Drives, a returning reader being told what changed while they were away, the new drive's empty page, the shared-link card, and a drive's figures no longer being withheld from the person who uploaded them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr
PE-9205: chore(version): bump version to 2.88.0
# Conflicts: # pubspec.yaml
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promotes
devto production. 95 commits since v2.87.1, 17 merged PRs.masteris merged into this branch rather than the other way round, matching #2204 and #2198. The only conflict waspubspec.yaml(2.88.0 against production's 2.87.1), resolved to 2.88.0. The resulting tree is byte-identical todev, so this release is exactly what has been on staging.What's new
A login lands on your drives, not inside one. A new Your Drives page lists every drive with its file count, size and when it was last read, and syncs any selection of them rather than all or one.
Sync you can read. The phases that used to sit silent say what they are doing, a finished sync says what it found, and the progress bar keeps moving or admits when it cannot measure something. A sync nobody asked for no longer holds the app: you can navigate, open drives and upload while one runs.
Search on All Drives. Search has always looked across every drive, but was only reachable from inside one. It is now on the drives list too, desktop and mobile, and a result opens the file where it lives with the file selected.
Coming back to an existing session. Nothing re-syncs and nothing is lost. The app now tells you if drives changed while you were away, and offers to sync just those.
A drive you just made shows what you put in it. Creating a drive and uploading to it showed "Never synced" with no file count and no size, because nothing had read it from chain yet. The files were always there. They are shown now.
Check an upload without a full sync. A file waiting to be confirmed has a Check upload status action.
Smaller things
Under the hood
Notes for upgraders
schemaVersionunchanged at 29configVersionunchanged at 3Pre-flight
schemaVersion.driftchanges. Migration fixtures only cover v17–v19, so an unnoticed bump would ship untestedconfigVersionardrive_uploader,ardrive_crypto,lib/core/upload,lib/core/crypto,lib/blocs/upload: zero lines changeddevAccuracy of the notes
Every claim was checked against
masterrather than written from memory, and two drafts were cut as a result:probeActiveDriveIdsis already inmaster. It shipped earlier; claiming it here would take credit for old work.error.toString()for status codes, so the fallback often never fired. The fix makes it fire when it should.After merging
The merge is the deploy:
production.yamlruns on every push tomaster, and no workflow is triggered by a tag or a release. Once that run is green, publish the GitHub releasev2.88.0pinned to the merge commit, so the tag names exactly what shipped.🤖 Generated with Claude Code
https://claude.ai/code/session_01G3ndA5nwUpt9hGLUx2TAFr