Show the folder picker on the platform that needs it most, and upload at once

Two faults, both of my own making, reported from the tablet as "I cannot
select a location" and "it does not upload".

**The picker button was gated on `target-selected == 1`.** That index was
Remote's position while both targets were offered. Making the target list
platform-aware narrowed Android's to Remote alone, so Remote became index 0
and the button disappeared — on the one platform where the picker is the
*only* way to set a destination, since a device folder is not reachable there
at all. It is gated on a boolean derived from the target now. An index into a
list whose length varies is not a fact about the target, and writing it as one
is what made a correct change break the thing it was meant to fix.

**A queued export waited for a sync pass.** Staging first is deliberate — an
export is finished on disk the moment it is written, and offline is then just
a longer queue — but nothing drained the outbox until the next sync, so
"Queued for Exports" sat unchanged and read, fairly, as an upload that never
happened. A finished batch that wrote anything now drains immediately. The
sync-pass drain stays: the first makes an upload feel immediate, the second is
what eventually delivers the exports made in a tunnel.

Committed without the parallel session's in-flight collection work, which is
mid-save and does not compile; verified by stashing it and building this tree
alone. 281 dr-ui tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 12:01:12 +02:00
co-authored by Claude Opus 5
parent a8b28136a6
commit 44f0a4971b
5 changed files with 81 additions and 2 deletions
+58 -1
View File
@@ -376,6 +376,47 @@ fn batch_request(
}
}
/// TRACES: FR-EXP-7 | FR-NC-10
/// Send anything waiting in the outbox to the server.
///
/// Called after an export and again on every sync pass. Both, deliberately:
/// the first is what makes an upload feel immediate, and the second is what
/// eventually delivers the exports made while the train was in a tunnel.
/// Running it twice over an empty outbox costs a directory listing.
fn drain_outbox(library: &Rc<library_ui::LibraryController>) {
// Offline is not a failure worth reporting here — the entries stay
// staged and the next pass takes them.
if library.is_offline() {
return;
}
let Some((creds, session)) = library.session() else {
return;
};
let outbox = export::outbox_dir(&session.server, &session.user_id);
if export::pending_count(&outbox) == 0 {
return;
}
let rx = export::spawn_upload(creds, session.user_id.clone(), session.root.clone(), outbox);
std::thread::spawn(move || {
while let Ok(msg) = rx.recv() {
match msg {
export::UploadMessage::Status(s) => log::info!("export: {s}"),
export::UploadMessage::Finished {
uploaded,
remaining,
error,
} => {
log::info!("export: {uploaded} uploaded, {remaining} still queued");
if let Some(e) = error {
log::warn!("export upload stopped: {e}");
}
}
}
}
});
}
/// What the export button should say, given where an export would go.
///
/// The label carries the destination because the button is the only place the
@@ -1547,7 +1588,23 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
*cancel.borrow_mut() = token.clone();
let rx = export::spawn_batch(request, token);
export::drain_batch(window.as_weak(), &activity, &drain, rx, total, to);
// Upload the moment the batch is done rather than waiting
// for a sync pass. An export bound for the server is
// complete on disk the instant it is staged, but it is not
// where the user asked for it until this runs — and
// "Queued for Exports" sitting unchanged until somebody
// presses Sync reads as an export that did not upload.
let library_for_drain = library.clone();
export::drain_batch(
window.as_weak(),
&activity,
&drain,
rx,
total,
to,
move || drain_outbox(&library_for_drain),
);
},
)
};