The scan/opening/status/error lines, offline mode and its retry, pinning a collection offline, the sync and thumbnail-sweep state, and the callbacks that route the grid to a rescan, a sync, another library, a panorama merge or an export — the last of what AppWindow still carried under the library- prefix — move onto the `Library` global started earlier on this branch. Rust reaches them through window.global::<Library>() rather than window.set_/get_/on_/invoke_ on the root. library-visible is the one name that stays: it is computed from active-page and active-view, the shell's own routing state, which a global cannot read. AppWindow now declares no other library- property or callback.
718 lines
27 KiB
Rust
718 lines
27 KiB
Rust
//! Keeping a collection's originals on this device: the offline prompt, the
|
|
//! pin fetch, and the connectivity banner every worker in this directory
|
|
//! paints through [`refresh_offline`].
|
|
//!
|
|
//! Split out because it is one coherent user question — "what of this is on
|
|
//! my device, and what would keeping or releasing it cost" — with its own
|
|
//! catalog queries, distinct from loading a window over the grid or scanning
|
|
//! the tree. See `docs/dev/code-health.md` CH-1.
|
|
|
|
use std::rc::Rc;
|
|
|
|
use dr_catalog::Catalog;
|
|
use slint::ComponentHandle;
|
|
|
|
use crate::library;
|
|
use crate::{AppWindow, Library};
|
|
|
|
use super::controller::{stop, LibraryController};
|
|
|
|
/// TRACES: FR-NC-6a | FR-NC-6c
|
|
/// What a collection would cost to take with you, and what it is holding now.
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
struct OfflineSummary {
|
|
/// Photographs in the collection and its children, deduplicated.
|
|
total: usize,
|
|
/// Of those, how many have their original on this device.
|
|
held: usize,
|
|
/// Disk those originals occupy — what releasing would give back.
|
|
held_bytes: u64,
|
|
/// What the rest would cost to fetch, from the sizes the scan recorded.
|
|
/// Zero where nothing has been stat-ed yet, which reads as "unknown"
|
|
/// rather than "free" in the label built from it.
|
|
missing_bytes: u64,
|
|
}
|
|
|
|
impl OfflineSummary {
|
|
fn missing(self) -> usize {
|
|
self.total.saturating_sub(self.held)
|
|
}
|
|
}
|
|
|
|
/// Read the offline summary for a set of images.
|
|
///
|
|
/// One query with the ids inlined as placeholders — the same shape
|
|
/// [`collection_images`] uses, and for the same reason: a collection is tens to
|
|
/// thousands of rows, and a round trip per photograph to answer one dialogue is
|
|
/// not a trade worth making.
|
|
fn offline_summary(catalog: &Catalog, images: &[dr_types::ImageId]) -> OfflineSummary {
|
|
if images.is_empty() {
|
|
return OfflineSummary::default();
|
|
}
|
|
let placeholders = std::iter::repeat_n("?", images.len())
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
// `tier_actual`, never `tier_desired`: the question is what can be opened
|
|
// on the aeroplane, and a pin whose download has not run yet answers no.
|
|
let sql = format!(
|
|
"SELECT count(*),
|
|
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 1 ELSE 0 END), 0),
|
|
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN c.bytes ELSE 0 END), 0),
|
|
coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 0
|
|
ELSE coalesce(i.file_size, 0) END), 0)
|
|
FROM images i
|
|
LEFT JOIN image_cache c ON c.image_id = i.id
|
|
WHERE i.id IN ({placeholders})"
|
|
);
|
|
|
|
let mut params: Vec<rusqlite::types::Value> = vec![rusqlite::types::Value::Integer(
|
|
dr_types::Tier::Original.stored(),
|
|
)];
|
|
params.extend(
|
|
images
|
|
.iter()
|
|
.map(|i| rusqlite::types::Value::Integer(i.0 as i64)),
|
|
);
|
|
|
|
catalog
|
|
.connection()
|
|
.query_row(&sql, rusqlite::params_from_iter(params.iter()), |r| {
|
|
Ok(OfflineSummary {
|
|
total: r.get::<_, i64>(0)? as usize,
|
|
held: r.get::<_, i64>(1)? as usize,
|
|
held_bytes: r.get::<_, i64>(2)? as u64,
|
|
missing_bytes: r.get::<_, i64>(3)? as u64,
|
|
})
|
|
})
|
|
.unwrap_or_else(|e| {
|
|
log::debug!("reading offline summary: {e}");
|
|
OfflineSummary::default()
|
|
})
|
|
}
|
|
|
|
/// TRACES: FR-NC-6a | FR-NC-6c
|
|
/// Ask what should happen to a collection's local copies.
|
|
///
|
|
/// Both answers are expensive — one commits the device to a download of
|
|
/// gigabytes, the other deletes gigabytes it already holds — so this is a
|
|
/// question rather than a toggle, and the counts and sizes go in the buttons
|
|
/// where they are read *before* the tap rather than in a second dialogue after
|
|
/// it (FR-NC-6c: an operation requiring absent data says so, with the size,
|
|
/// before starting).
|
|
pub(super) fn open_offline_prompt(
|
|
window: &AppWindow,
|
|
ctl: &Rc<LibraryController>,
|
|
id: dr_types::CollectionId,
|
|
) {
|
|
let borrow = ctl.catalog.borrow();
|
|
let Some(catalog) = borrow.as_ref() else {
|
|
return;
|
|
};
|
|
|
|
// Descendants, matching what the grid shows when scoped to this row:
|
|
// keeping a parent whose children hold the photographs must keep the
|
|
// photographs, or the answer would appear to do nothing.
|
|
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
|
|
Ok(ids) => ids,
|
|
Err(e) => {
|
|
window
|
|
.global::<Library>()
|
|
.set_library_error(format!("resolving collection: {e}").into());
|
|
return;
|
|
}
|
|
};
|
|
let images = collection_images(catalog, &ids);
|
|
let summary = offline_summary(catalog, &images);
|
|
|
|
let name = dr_catalog::collections::tree(catalog.connection())
|
|
.ok()
|
|
.and_then(|rows| {
|
|
rows.into_iter()
|
|
.find(|r| r.collection.id == id)
|
|
.map(|r| r.collection.name)
|
|
})
|
|
.unwrap_or_else(|| "This collection".to_string());
|
|
|
|
ctl.offline_target.set(Some(id));
|
|
window.set_offline_prompt_title(name.as_str().into());
|
|
window.set_offline_prompt_detail(
|
|
if summary.total == 0 {
|
|
"Nothing in here yet. Put some photographs in it first.".to_string()
|
|
} else if summary.held == summary.total {
|
|
format!(
|
|
"All {} on this device · {}",
|
|
summary.total,
|
|
crate::activity::describe_bytes(summary.held_bytes)
|
|
)
|
|
} else {
|
|
format!(
|
|
"{} photographs · {} already on this device",
|
|
summary.total, summary.held
|
|
)
|
|
}
|
|
.as_str()
|
|
.into(),
|
|
);
|
|
window.set_offline_prompt_keep_label(
|
|
// The size is named where the scan has recorded one. Where it has not,
|
|
// the label says what it will do and not what it will cost, which is
|
|
// honest — a "0 B" download would be a lie about a gigabyte.
|
|
if summary.missing_bytes > 0 {
|
|
format!(
|
|
"Download {} · {}",
|
|
summary.missing(),
|
|
crate::activity::describe_bytes(summary.missing_bytes)
|
|
)
|
|
} else if summary.missing() > 0 {
|
|
format!("Download {}", summary.missing())
|
|
} else {
|
|
"Everything is already here".to_string()
|
|
}
|
|
.as_str()
|
|
.into(),
|
|
);
|
|
window.set_offline_prompt_release_label(
|
|
format!(
|
|
"Remove {} local copies · frees {}",
|
|
summary.held,
|
|
crate::activity::describe_bytes(summary.held_bytes)
|
|
)
|
|
.as_str()
|
|
.into(),
|
|
);
|
|
window.set_offline_prompt_can_keep(summary.missing() > 0);
|
|
window.set_offline_prompt_can_release(summary.held > 0);
|
|
window.set_offline_prompt_busy(window.global::<Library>().get_library_pin_total() > 0);
|
|
}
|
|
|
|
/// Close the offline question without answering it.
|
|
pub(super) fn close_offline_prompt(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|
ctl.offline_target.set(None);
|
|
// The title is what the prompt's visibility is bound to: one fact, so a
|
|
// dialogue cannot be up with nothing written on it.
|
|
window.set_offline_prompt_title(slint::SharedString::new());
|
|
}
|
|
|
|
/// TRACES: FR-NC-6a
|
|
/// Keep a collection on this device: record the pin, then start the transfer.
|
|
///
|
|
/// Two separate things, and keeping them separate is what makes the answer feel
|
|
/// immediate — recording the intent is a local catalog write that completes at
|
|
/// once, and downloading the bytes may take a very long time. The pin also
|
|
/// survives the app being closed halfway through, which is what makes the
|
|
/// transfer resumable rather than something to start again.
|
|
pub(super) fn keep_collection_offline(
|
|
window: &AppWindow,
|
|
ctl: &Rc<LibraryController>,
|
|
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
|
) {
|
|
let Some(id) = ctl.offline_target.get() else {
|
|
return;
|
|
};
|
|
let Some(cache) = ctl.cache() else {
|
|
window
|
|
.global::<Library>()
|
|
.set_library_error("No cache directory for this library.".into());
|
|
return;
|
|
};
|
|
|
|
let images = {
|
|
let borrow = ctl.catalog.borrow();
|
|
let Some(catalog) = borrow.as_ref() else {
|
|
return;
|
|
};
|
|
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
|
|
Ok(ids) => ids,
|
|
Err(e) => {
|
|
window
|
|
.global::<Library>()
|
|
.set_library_error(format!("resolving collection: {e}").into());
|
|
return;
|
|
}
|
|
};
|
|
let images = collection_images(catalog, &ids);
|
|
if images.is_empty() {
|
|
window
|
|
.global::<Library>()
|
|
.set_library_error("Nothing in that collection to keep offline.".into());
|
|
return;
|
|
}
|
|
if let Err(e) = cache.pin(catalog.connection(), &images) {
|
|
window
|
|
.global::<Library>()
|
|
.set_library_error(format!("pinning: {e}").into());
|
|
return;
|
|
}
|
|
images
|
|
};
|
|
|
|
log::info!("pinned {} image(s) for offline use", images.len());
|
|
window
|
|
.global::<Library>()
|
|
.set_library_error(slint::SharedString::new());
|
|
if ctl.scope.borrow().as_ref() == Some(&id) {
|
|
window.global::<Library>().set_library_scope_pinned(true);
|
|
}
|
|
close_offline_prompt(window, ctl);
|
|
start_pin_fetch(window, ctl);
|
|
refresh_collection_tree(window, ctl, coll_ctl);
|
|
}
|
|
|
|
/// TRACES: FR-NC-6a | FR-NC-6b
|
|
/// Give the disk back: release the pin *and* delete the originals it held.
|
|
///
|
|
/// Deliberately destructive, where unpinning alone is not. "Remove the local
|
|
/// copies" is asked by someone whose device is full, and answering it by
|
|
/// withdrawing a promise and leaving the gigabytes for a future eviction to
|
|
/// notice is not an answer. Nothing is lost that cannot be fetched again: the
|
|
/// originals are on the server, and the ratings, the edit graph and the
|
|
/// thumbnails are all untouched — they are authoritative and small.
|
|
pub(super) fn release_collection_offline(
|
|
window: &AppWindow,
|
|
ctl: &Rc<LibraryController>,
|
|
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
|
) {
|
|
let Some(id) = ctl.offline_target.get() else {
|
|
return;
|
|
};
|
|
let Some(cache) = ctl.cache() else {
|
|
window
|
|
.global::<Library>()
|
|
.set_library_error("No cache directory for this library.".into());
|
|
return;
|
|
};
|
|
|
|
let released = {
|
|
let borrow = ctl.catalog.borrow();
|
|
let Some(catalog) = borrow.as_ref() else {
|
|
return;
|
|
};
|
|
let ids = match dr_catalog::collections::descendants(catalog.connection(), id) {
|
|
Ok(ids) => ids,
|
|
Err(e) => {
|
|
window
|
|
.global::<Library>()
|
|
.set_library_error(format!("resolving collection: {e}").into());
|
|
return;
|
|
}
|
|
};
|
|
let images = collection_images(catalog, &ids);
|
|
let outcome = match cache.release(catalog.connection(), &images) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
window
|
|
.global::<Library>()
|
|
.set_library_error(format!("removing local copies: {e}").into());
|
|
return;
|
|
}
|
|
};
|
|
|
|
// TRACES: FR-NC-6c
|
|
// On a placeholder library the bookkeeping above owns no files, so it
|
|
// freed nothing — the originals are materialised in the library folder
|
|
// and only the sync client may take them back. Asking it to is what
|
|
// makes unpinning actually return the disk, and it must be a
|
|
// dehydration rather than a delete: removing a file inside a synced
|
|
// tree propagates to the server (ARCH §9.0a).
|
|
//
|
|
// Fire and forget: it is per-file work over a socket, the user has
|
|
// already been told the pin is withdrawn, and a client that refuses
|
|
// leaves the content where it is at no cost but disk.
|
|
if let Some(conn) = ctl.session() {
|
|
let path = library::catalog_path(&conn.account);
|
|
std::mem::drop(library::spawn_dehydrate(conn, path, images));
|
|
}
|
|
outcome
|
|
};
|
|
|
|
let (count, freed) = released;
|
|
log::info!(
|
|
"released {count} image(s), freeing {}",
|
|
crate::activity::describe_bytes(freed)
|
|
);
|
|
window
|
|
.global::<Library>()
|
|
.set_library_error(slint::SharedString::new());
|
|
window.global::<Library>().set_library_status(
|
|
format!(
|
|
"Removed local copies · {} freed",
|
|
crate::activity::describe_bytes(freed)
|
|
)
|
|
.as_str()
|
|
.into(),
|
|
);
|
|
if ctl.scope.borrow().as_ref() == Some(&id) {
|
|
window.global::<Library>().set_library_scope_pinned(false);
|
|
}
|
|
// A download that was still running for this collection has just had its
|
|
// reason withdrawn; the worker checks `pending_pins` per file, so it stops
|
|
// finding work rather than being killed.
|
|
window.global::<Library>().set_library_pin_total(0);
|
|
window.global::<Library>().set_library_pin_done(0);
|
|
close_offline_prompt(window, ctl);
|
|
refresh_local_count(window, ctl);
|
|
refresh_collection_tree(window, ctl, coll_ctl);
|
|
}
|
|
|
|
/// Redraw the sidebar, so the trays reflect what was just kept or released.
|
|
fn refresh_collection_tree(
|
|
window: &AppWindow,
|
|
ctl: &Rc<LibraryController>,
|
|
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
|
) {
|
|
let borrow = ctl.catalog.borrow();
|
|
if let Some(catalog) = borrow.as_ref() {
|
|
crate::collections_ui::refresh_tree(window, coll_ctl, catalog);
|
|
}
|
|
}
|
|
|
|
/// Every image in the given collections, deduplicated.
|
|
///
|
|
/// An image in both a parent and a child is one photograph and must be pinned
|
|
/// once, exactly as the grid draws it once.
|
|
pub(super) fn collection_images(
|
|
catalog: &Catalog,
|
|
ids: &[dr_types::CollectionId],
|
|
) -> Vec<dr_types::ImageId> {
|
|
if ids.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
let placeholders = std::iter::repeat_n("?", ids.len())
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
let sql = format!(
|
|
"SELECT DISTINCT image_id FROM collection_members
|
|
WHERE collection_id IN ({placeholders})"
|
|
);
|
|
let params: Vec<rusqlite::types::Value> = ids
|
|
.iter()
|
|
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
|
|
.collect();
|
|
|
|
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
|
|
return Vec::new();
|
|
};
|
|
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
|
Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64))
|
|
});
|
|
match rows {
|
|
Ok(rows) => rows.flatten().collect(),
|
|
Err(e) => {
|
|
log::debug!("listing collection images: {e}");
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-NC-6a
|
|
/// Download whatever the pins still want, reporting progress.
|
|
fn start_pin_fetch(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
|
return;
|
|
};
|
|
let Some(cache_dir) = ctl.cache_dir() else {
|
|
return;
|
|
};
|
|
|
|
// Offline, there is nothing to download from. The pin is already recorded,
|
|
// so it resumes on reconnect rather than being lost.
|
|
if ctl.is_offline() {
|
|
log::info!("offline: the pin is recorded and will download on reconnect");
|
|
return;
|
|
}
|
|
|
|
let rx = library::spawn_pin_fetch(
|
|
conn.clone(),
|
|
library::catalog_path(&conn.account),
|
|
cache_dir,
|
|
// Pinned originals are exempt from the budget, but a pin fetch also
|
|
// stores passively when it finds an image already cached, so the worker
|
|
// still needs the user's ceiling rather than the catalog's floor.
|
|
ctl.cache_budget.get(),
|
|
);
|
|
|
|
let timer = slint::Timer::default();
|
|
let weak = window.as_weak();
|
|
let ctl_cb = ctl.clone();
|
|
|
|
// The longest-running transfer the app does, and the one most likely to be
|
|
// watched from another view — which is the whole reason the register
|
|
// exists (FR-NC-6, FR-NC-6c).
|
|
let job = ctl.activity.begin(
|
|
crate::activity::Kind::Download,
|
|
"Keeping photographs on this device",
|
|
);
|
|
|
|
timer.start(
|
|
slint::TimerMode::Repeated,
|
|
std::time::Duration::from_millis(300),
|
|
move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
loop {
|
|
let msg = match rx.try_recv() {
|
|
Ok(m) => m,
|
|
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
|
w.global::<Library>().set_library_pin_total(0);
|
|
job.fail("stopped without finishing");
|
|
stop(&ctl_cb.pin_timer);
|
|
return;
|
|
}
|
|
};
|
|
|
|
match msg {
|
|
library::PinMessage::Planned { total } => {
|
|
w.global::<Library>().set_library_pin_total(total as i32);
|
|
w.global::<Library>().set_library_pin_done(0);
|
|
job.total(total);
|
|
}
|
|
library::PinMessage::Stored { done } => {
|
|
w.global::<Library>().set_library_pin_done(done as i32);
|
|
job.progress(done, w.global::<Library>().get_library_pin_total() as usize);
|
|
// The "On this device" count grows as they land, so
|
|
// the chip agrees with the progress line beside it.
|
|
refresh_local_count(&w, &ctl_cb);
|
|
}
|
|
library::PinMessage::Done { stored, bytes } => {
|
|
log::info!(
|
|
"pin complete: {stored} original(s), {:.1} MB",
|
|
bytes as f64 / 1_048_576.0
|
|
);
|
|
job.finish(format!(
|
|
"{stored} photograph(s) · {}",
|
|
crate::activity::describe_bytes(bytes)
|
|
));
|
|
w.global::<Library>().set_library_pin_total(0);
|
|
w.global::<Library>().set_library_pin_done(0);
|
|
refresh_local_count(&w, &ctl_cb);
|
|
stop(&ctl_cb.pin_timer);
|
|
return;
|
|
}
|
|
library::PinMessage::Failed { message, offline } => {
|
|
log::warn!("pin fetch stopped: {message}");
|
|
job.fail(message.clone());
|
|
w.global::<Library>().set_library_pin_total(0);
|
|
if offline {
|
|
ctl_cb
|
|
.reachability
|
|
.borrow_mut()
|
|
.mark_unreachable(message, std::time::Instant::now());
|
|
refresh_offline(&w, &ctl_cb);
|
|
} else {
|
|
w.global::<Library>()
|
|
.set_library_error(format!("keeping offline: {message}").into());
|
|
}
|
|
refresh_local_count(&w, &ctl_cb);
|
|
stop(&ctl_cb.pin_timer);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
*ctl.pin_timer.borrow_mut() = Some(timer);
|
|
}
|
|
|
|
/// Refresh the "On this device" count from the catalog.
|
|
fn refresh_local_count(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|
let borrow = ctl.catalog.borrow();
|
|
let Some(catalog) = borrow.as_ref() else {
|
|
return;
|
|
};
|
|
window
|
|
.global::<Library>()
|
|
.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
|
|
}
|
|
|
|
/// TRACES: FR-NC-6a
|
|
/// Whether every image in the scoped collection is pinned.
|
|
///
|
|
/// Read from the catalog rather than remembered, because a pin outlives the
|
|
/// session that made it: reopening the library must show the button already
|
|
/// active, or the user would pin the same collection twice.
|
|
pub(super) fn scope_is_pinned(catalog: &Catalog, images: &[dr_types::ImageId]) -> bool {
|
|
if images.is_empty() {
|
|
return false;
|
|
}
|
|
let placeholders = std::iter::repeat_n("?", images.len())
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
let params: Vec<rusqlite::types::Value> = images
|
|
.iter()
|
|
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
|
|
.collect();
|
|
|
|
let pinned: i64 = catalog
|
|
.connection()
|
|
.query_row(
|
|
&format!(
|
|
"SELECT count(*) FROM image_cache
|
|
WHERE pinned = 1 AND image_id IN ({placeholders})"
|
|
),
|
|
rusqlite::params_from_iter(params.iter()),
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap_or(0);
|
|
|
|
pinned as usize == images.len()
|
|
}
|
|
|
|
/// TRACES: FR-CAT-9
|
|
/// Paint the connectivity state into the window.
|
|
///
|
|
/// Called wherever reachability may have moved, rather than by the state
|
|
/// itself: `Reachability` is in `dr-sync` and knows nothing about a window,
|
|
/// which is what keeps it testable without a display server.
|
|
pub(super) fn refresh_offline(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|
let reach = ctl.reachability.borrow();
|
|
// TRACES: FR-PLAT-AND-2
|
|
// A lost root wins over a dead network, and does so even when both are
|
|
// true — which is the ordinary case, since the scan that discovered the
|
|
// grant was gone was also the last request the app made. Reported the
|
|
// other way round the user is told to wait for a connection that is
|
|
// working, and the thing that would actually fix it is never mentioned.
|
|
let lost = ctl.root_lost.borrow();
|
|
let offline = reach.is_offline() || lost.is_some();
|
|
|
|
window.global::<Library>().set_library_offline(offline);
|
|
window
|
|
.global::<Library>()
|
|
.set_library_offline_reason(match lost.as_deref() {
|
|
Some(why) => why.into(),
|
|
None => reach.reason().unwrap_or_default().into(),
|
|
});
|
|
window.global::<Library>().set_library_offline_since(
|
|
// A duration is what a network outage has and a revoked permission
|
|
// does not: "for 4 minutes" invites waiting, and waiting is precisely
|
|
// what will not help here.
|
|
if lost.is_some() {
|
|
slint::SharedString::default()
|
|
} else {
|
|
reach
|
|
.offline_for(std::time::Instant::now())
|
|
.map(describe_duration)
|
|
.unwrap_or_default()
|
|
.into()
|
|
},
|
|
);
|
|
drop(lost);
|
|
|
|
// A stale scan error under an offline banner reports one problem twice.
|
|
if offline {
|
|
window.global::<Library>().set_library_error("".into());
|
|
}
|
|
drop(reach);
|
|
|
|
// TRACES: FR-CAT-9
|
|
// Back online: send whatever the outbox is still holding.
|
|
//
|
|
// Hung off the one function that paints connectivity rather than off each
|
|
// of the seven places that move it — a drain that had to be remembered at
|
|
// every call site is a drain that will be forgotten at one of them, and
|
|
// the symptom is an edit that stays queued until the app is restarted.
|
|
//
|
|
// `start_outbox_drain` is a no-op when the outbox is empty and while one
|
|
// is already running, so calling it on every repaint costs a directory
|
|
// walk that finds nothing.
|
|
if !offline {
|
|
start_outbox_drain(window, ctl);
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-CAT-9 | FR-NC-10
|
|
/// Upload the sidecars queued while this device had no connection.
|
|
///
|
|
/// Guarded on the timer rather than on a flag: the timer *is* the "a drain is
|
|
/// running" state, and a second one started beside it would drain the same
|
|
/// channel twice.
|
|
fn start_outbox_drain(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|
if ctl.outbox_timer.borrow().is_some() {
|
|
return;
|
|
}
|
|
if !ctl.outbox_maybe_dirty.get() {
|
|
return;
|
|
}
|
|
let Some(cache_dir) = ctl.sidecar_cache_dir() else {
|
|
return;
|
|
};
|
|
if crate::sidecar_cache::SidecarCache::open(cache_dir.clone())
|
|
.pending()
|
|
.is_empty()
|
|
{
|
|
// Nothing there. Recorded so the next hundred repaints skip the walk;
|
|
// a write that queues sets it again.
|
|
ctl.outbox_maybe_dirty.set(false);
|
|
return;
|
|
}
|
|
let Some((conn, _)) = ctl.session.borrow().clone() else {
|
|
return;
|
|
};
|
|
|
|
let rx = library::spawn_outbox_drain(conn.clone(), cache_dir);
|
|
let job = ctl
|
|
.activity
|
|
.begin(crate::activity::Kind::Upload, "Uploading queued edits");
|
|
|
|
let timer = slint::Timer::default();
|
|
let weak = window.as_weak();
|
|
let ctl_cb = ctl.clone();
|
|
timer.start(
|
|
slint::TimerMode::Repeated,
|
|
std::time::Duration::from_millis(250),
|
|
move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
match rx.try_recv() {
|
|
Ok(library::SidecarMessage::Finished {
|
|
written,
|
|
queued,
|
|
failed,
|
|
last_error,
|
|
}) => {
|
|
if failed > 0 {
|
|
log::warn!(
|
|
"{failed} queued sidecar(s) still undelivered: {}",
|
|
last_error.clone().unwrap_or_default()
|
|
);
|
|
// Not `fail`: the edits are still safely queued, and
|
|
// reporting this as a loss would be wrong.
|
|
job.finish(format!("{written} uploaded · {queued} still queued"));
|
|
} else if written > 0 {
|
|
log::info!("{written} queued sidecar(s) uploaded");
|
|
job.finish(format!("{written} queued edit(s) uploaded"));
|
|
w.global::<Library>().set_library_status(
|
|
format!("{written} queued edit(s) uploaded").into(),
|
|
);
|
|
} else {
|
|
job.finish_quietly();
|
|
}
|
|
ctl_cb.outbox_maybe_dirty.set(queued > 0);
|
|
stop(&ctl_cb.outbox_timer);
|
|
}
|
|
Err(std::sync::mpsc::TryRecvError::Empty) => {}
|
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
|
job.finish_quietly();
|
|
stop(&ctl_cb.outbox_timer);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
*ctl.outbox_timer.borrow_mut() = Some(timer);
|
|
}
|
|
|
|
/// A coarse "how long ago", for the offline banner.
|
|
///
|
|
/// Deliberately imprecise: the user wants to know whether this just happened
|
|
/// or has been true for a while, and a live-counting seconds display would
|
|
/// draw the eye to a number that changes without meaning anything.
|
|
fn describe_duration(d: std::time::Duration) -> String {
|
|
let secs = d.as_secs();
|
|
if secs < 60 {
|
|
"just now".to_string()
|
|
} else if secs < 3600 {
|
|
format!("{}m ago", secs / 60)
|
|
} else {
|
|
format!("{}h ago", secs / 3600)
|
|
}
|
|
}
|