implemented real offline structure, with online drain

This commit is contained in:
nvrl committed 2026-07-17 12:39:03 +02:00
1 parent dcc62ba420
commit 50c96cbbf4
7 files changed
+1333 -195

No files matched your search

+5 -4
View File
@@ -12,6 +12,7 @@ use pdfs_core::cache::ContentCache;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
RefreshScope, Request as CtlRequest, Response as CtlResponse, ShareEntryKind, SyncPhase,
pending_summary,
};
use pdfs_core::db::Db;
@@ -832,13 +833,13 @@ fn cmd_status() -> Result<()> {
pinned,
online,
pending_uploads,
pending_changes,
..
}) => {
let state = if online { "" } else { ", offline" };
let queued = match pending_uploads {
0 => String::new(),
1 => ", 1 upload queued".to_string(),
n => format!(", {n} uploads queued"),
let queued = match pending_summary(pending_uploads, pending_changes) {
Some(s) => format!(", {s}"),
None => String::new(),
};
println!("Mounted at {mountpoint} ({pinned} pinned{state}{queued})");
}
+28 -2
View File
@@ -98,8 +98,8 @@ pub struct StagedWrite {
pub uid: String,
/// Length of the intended new content.
pub len: u64,
/// Size and mtime of the base revision the write was made against, so a
/// retry can tell whether the remote moved on in the meantime.
/// Size and mtime of the revision the file's *untouched* ranges came from,
/// i.e. what a gap-fill reads. Meaningless once `complete` is true.
pub base_size: u64,
pub base_mtime: i64,
/// Locally authored `[start, end)` ranges. Everything else in the file is a
@@ -108,6 +108,28 @@ pub struct StagedWrite {
/// True when `authored` covers the whole file, i.e. the staged bytes are the
/// complete new content and can be uploaded directly.
pub complete: bool,
/// The remote revision this change was made against, if it is known.
///
/// Distinct from `base_size`/`base_mtime`, which describe wherever the
/// untouched bytes are to be read from — for a write that supersedes an
/// earlier queued one, that is the *previous staged blob*, not the remote.
/// This instead always names the server's revision, carried across
/// supersedes, so the drain can tell whether the remote moved on under a
/// queued change and keep a conflict copy rather than clobber it.
///
/// `None` on a sidecar written before this field existed, and for a node
/// that has never existed remotely; both mean "do not conflict-check".
#[serde(default)]
pub based_on: Option<Baseline>,
}
/// The identity of a remote revision, as far as we can observe it: a file whose
/// size and mtime both still match the ones a queued change was made against has
/// not been rewritten by anyone else in the meantime.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub struct Baseline {
pub mtime: i64,
pub size: u64,
}
/// Content cache rooted at a directory, with a sibling pin-registry file.
@@ -1098,6 +1120,10 @@ mod tests {
base_mtime: 100,
authored: vec![(0, 11)],
complete: false,
based_on: Some(Baseline {
mtime: 100,
size: 40,
}),
};
let staged = c.stage_write(&meta, &scratch).unwrap();
assert!(!scratch.exists(), "scratch is moved, not copied");
+44
View File
@@ -625,6 +625,31 @@ fn default_online() -> bool {
true
}
/// Phrase the queue depth of a [`Response::Status`] for a human, or `None` when
/// there is nothing queued and the caller should say nothing at all.
///
/// Lives here, next to the counts it describes, because the tray, the CLI and
/// the manager window all have to draw the same distinction between bytes that
/// have not reached the remote and metadata that has not.
pub fn pending_summary(uploads: u64, changes: u64) -> Option<String> {
let part = |n: u64, one: &str, many: &str| match n {
0 => None,
1 => Some(format!("1 {one}")),
n => Some(format!("{n} {many}")),
};
let parts: Vec<String> = [
part(uploads, "upload", "uploads"),
part(changes, "change", "changes"),
]
.into_iter()
.flatten()
.collect();
match parts.is_empty() {
true => None,
false => Some(format!("{} queued", parts.join(", "))),
}
}
/// The daemon's reply to a [`Request`].
#[derive(Serialize, Deserialize, Debug)]
pub enum Response {
@@ -651,6 +676,11 @@ pub enum Response {
/// still draining, or it cannot drain because we are offline.
#[serde(default)]
pending_uploads: u64,
/// Queued mutations that carry no bytes: `mkdir`, `rename`, `trash`
/// (offline.md Phase 3b). Counted apart from `pending_uploads` because
/// calling a queued `mkdir` an upload is a lie.
#[serde(default)]
pending_changes: u64,
},
/// A human-readable success message.
Ok { message: String },
@@ -732,6 +762,20 @@ pub fn send(socket: &Path, req: &Request) -> Result<Response> {
mod tests {
use super::*;
/// The whole point of the split counts: a queued `mkdir` is work, but it is
/// not an upload and must never be reported as one.
#[test]
fn pending_summary_separates_uploads_from_other_changes() {
assert_eq!(pending_summary(0, 0), None);
assert_eq!(pending_summary(1, 0).as_deref(), Some("1 upload queued"));
assert_eq!(pending_summary(3, 0).as_deref(), Some("3 uploads queued"));
assert_eq!(pending_summary(0, 1).as_deref(), Some("1 change queued"));
assert_eq!(
pending_summary(2, 4).as_deref(),
Some("2 uploads, 4 changes queued")
);
}
/// The mutation requests must survive a line-delimited JSON round-trip, since
/// that is exactly how they cross the control socket.
#[test]
+335 -27
View File
@@ -46,6 +46,37 @@ pub const OP_CREATE: &str = "create";
/// time the child drains.
pub const OP_MKDIR: &str = "mkdir";
/// The `kind` of a [`PendingOp`] that gives a node a new name, a new parent, or
/// both — the queued form of `mv` (offline.md Phase 3b).
///
/// `parent_uid` and `name` hold the node's *desired end state*, not a delta, so
/// a second rename simply replaces the row (see [`Db::enqueue_op`]) and the
/// drain can compare them against the remote and skip whichever half already
/// matches. The parent may be a `local~` placeholder — moving a file into a
/// folder that was itself created offline — in which case it is rewritten by
/// that folder's own drain, exactly as for [`OP_CREATE`].
pub const OP_RENAME: &str = "rename";
/// The `kind` of a [`PendingOp`] that trashes a node the server knows about
/// (offline.md Phase 3b).
///
/// A node that only ever existed locally never gets one of these: there is
/// nothing to trash remotely, so deleting it just drops its queued ops
/// ([`Db::delete_ops_for_uid`]).
pub const OP_TRASH: &str = "trash";
/// Whether at most one op of this `kind` may be queued per node, so that a newer
/// one replaces the older rather than queueing behind it.
///
/// True exactly of the kinds that describe a node's desired *end state* rather
/// than a step towards it: the newest revision already contains every earlier
/// one's bytes, the newest name is the only name wanted, and a trash subsumes
/// anything queued before it. A `create`/`mkdir` is the opposite — it is the one
/// thing that will ever make the node exist, so it must never be replaced.
pub fn op_supersedes(kind: &str) -> bool {
matches!(kind, OP_REVISION | OP_RENAME | OP_TRASH)
}
/// The volume id given to a node that exists only on this machine, so far. A
/// real [`NodeUid`] is `{volume}~{link}`, so a placeholder is `local~<uuid>` and
/// round-trips through the same `Display`/parse path as any other uid.
@@ -88,6 +119,16 @@ pub struct PendingOp {
pub next_attempt_at: i64,
}
/// How much work the queue owes the server, by whether it carries bytes.
#[derive(Debug, Clone, Copy, Default)]
pub struct PendingCounts {
/// Queued `create`/`revision` ops: files whose content is not on the remote.
pub uploads: i64,
/// Queued `mkdir`/`rename`/`trash` ops: metadata the remote has not been
/// told about.
pub changes: i64,
}
/// The outcome of folding freshly written bytes into a queued create.
#[derive(Debug, Clone)]
pub struct AttachedBlob {
@@ -368,6 +409,51 @@ impl Db {
Ok(())
}
/// Write-through multiple nodes in a single database transaction for performance.
pub fn upsert_nodes(&self, nodes: &[Node]) -> Result<()> {
if nodes.is_empty() {
return Ok(());
}
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
for node in nodes {
let json = serde_json::to_string(node)?;
let uid = node.uid.to_string();
tx.execute(
"INSERT INTO nodes
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(uid) DO UPDATE SET
parent_uid = excluded.parent_uid,
name = excluded.name,
is_dir = excluded.is_dir,
size = excluded.size,
mtime = excluded.mtime,
trashed = excluded.trashed,
node_json = excluded.node_json",
params![
uid,
node.parent_uid.as_ref().map(|u| u.to_string()),
node.name,
node.is_folder() as i64,
node_size(node),
node.modification_time,
node.trashed as i64,
json,
],
)?;
tx.execute("DELETE FROM nodes_fts WHERE uid = ?1", params![uid])?;
if !node.trashed {
tx.execute(
"INSERT INTO nodes_fts (uid, name) VALUES (?1, ?2)",
params![uid, node.name],
)?;
}
}
tx.commit()?;
Ok(())
}
/// Drop a node row (delete or trash from the hot cache). Children rows are
/// not cascaded here; the daemon forgets a whole subtree node-by-node.
pub fn delete_node(&self, uid: &NodeUid) -> Result<()> {
@@ -531,27 +617,30 @@ impl Db {
/// everything the older one did. The superseded blob's path is returned so
/// the caller can delete it.
///
/// Only revision rows supersede, and only each other. A create op for the
/// same uid must survive — dropping it would leave a file that exists
/// nowhere but this machine with nothing left to create it. (Writes to a
/// node that is itself still queued fold into the create row instead; see
/// [`Db::attach_blob_to_create`].)
/// Rows supersede only their own kind, and only the kinds
/// [`op_supersedes`] names. A create op for the same uid must survive —
/// dropping it would leave a file that exists nowhere but this machine with
/// nothing left to create it. (Writes to a node that is itself still queued
/// fold into the create row instead; see [`Db::attach_blob_to_create`].)
pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> {
let conn = self.conn.lock().unwrap();
let superseded: Option<String> = conn
.query_row(
"SELECT blob_path FROM pending_op WHERE uid = ?1 AND kind = ?2",
params![op.uid, OP_REVISION],
|r| r.get(0),
)
.optional()?
.flatten();
if op.kind == OP_REVISION {
let superseded: Option<String> = if op_supersedes(&op.kind) {
let blob: Option<String> = conn
.query_row(
"SELECT blob_path FROM pending_op WHERE uid = ?1 AND kind = ?2",
params![op.uid, op.kind],
|r| r.get(0),
)
.optional()?
.flatten();
conn.execute(
"DELETE FROM pending_op WHERE uid = ?1 AND kind = ?2",
params![op.uid, OP_REVISION],
params![op.uid, op.kind],
)?;
}
blob
} else {
None
};
conn.execute(
"INSERT INTO pending_op
(kind, uid, parent_uid, name, blob_path, meta_json, created_at)
@@ -604,23 +693,62 @@ impl Db {
Ok(Some(AttachedBlob { id, superseded }))
}
/// Drop every op targeting a node, returning the staged blobs they held so
/// the caller can delete them.
/// Point a queued create at a new parent and name, for a node renamed or
/// moved before it ever reached the server.
///
/// A `local~` uid means nothing to the API, so there is no rename call to
/// make — the node is still only a queued intent, and rewriting that intent
/// *is* the rename. Returns false when the create has already drained, in
/// which case the node has a real uid and the caller must rename it there
/// instead (offline.md Phase 3b).
pub fn rewrite_op_target(&self, uid: &str, parent_uid: &str, name: &str) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let n = conn.execute(
"UPDATE pending_op SET parent_uid = ?2, name = ?3
WHERE uid = ?1 AND kind IN (?4, ?5)",
params![uid, parent_uid, name, OP_CREATE, OP_MKDIR],
)?;
Ok(n > 0)
}
/// Drop every op targeting a node **or anything queued beneath it**,
/// returning the staged blobs they held so the caller can delete them.
///
/// Used when a node that only ever existed locally is deleted: there is
/// nothing on the server to trash, so the queued work simply stops being
/// wanted.
///
/// The descent is what keeps the queue alive. Deleting a folder created
/// offline drops the `mkdir` that would have given it a real uid, and any
/// op still queued under that placeholder is then unreachable forever: it
/// can never be attempted (its parent is a `local~` uid, so `op_is_ready`
/// refuses it) and nothing is left to remap it. It would sit in the queue,
/// and in the user's pending count, for the life of the database.
///
/// Only `create`/`mkdir` ops carry a `parent_uid`, so for a file this
/// recursion finds nothing and costs one query.
pub fn delete_ops_for_uid(&self, uid: &str) -> Result<Vec<String>> {
const SUBTREE: &str = "
WITH RECURSIVE doomed(uid) AS (
SELECT ?1
UNION
SELECT p.uid FROM pending_op p JOIN doomed d ON p.parent_uid = d.uid
)";
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
let blobs: Vec<String> = {
let mut stmt = tx.prepare(
"SELECT blob_path FROM pending_op WHERE uid = ?1 AND blob_path IS NOT NULL",
)?;
let mut stmt = tx.prepare(&format!(
"{SUBTREE}
SELECT blob_path FROM pending_op
WHERE uid IN (SELECT uid FROM doomed) AND blob_path IS NOT NULL"
))?;
let rows = stmt.query_map(params![uid], |r| r.get::<_, String>(0))?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
};
tx.execute("DELETE FROM pending_op WHERE uid = ?1", params![uid])?;
tx.execute(
&format!("{SUBTREE} DELETE FROM pending_op WHERE uid IN (SELECT uid FROM doomed)"),
params![uid],
)?;
tx.commit()?;
Ok(blobs)
}
@@ -673,10 +801,25 @@ impl Db {
Ok(rows)
}
/// How many uploads are still waiting, for `Response::Status`.
pub fn pending_op_count(&self) -> Result<i64> {
/// How many queued ops there are of each kind, for `Response::Status`.
///
/// Split because "3 uploads queued" has to mean three files whose bytes are
/// not on the remote yet. A queued `mkdir`/`rename`/`trash` is also work the
/// mount owes the server, but it carries no bytes and reporting it as an
/// upload is simply untrue.
pub fn pending_op_counts(&self) -> Result<PendingCounts> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row("SELECT COUNT(*) FROM pending_op", [], |r| r.get(0))?)
let uploads = conn.query_row(
"SELECT COUNT(*) FROM pending_op WHERE kind IN (?1, ?2)",
params![OP_REVISION, OP_CREATE],
|r| r.get(0),
)?;
let changes = conn.query_row(
"SELECT COUNT(*) FROM pending_op WHERE kind NOT IN (?1, ?2)",
params![OP_REVISION, OP_CREATE],
|r| r.get(0),
)?;
Ok(PendingCounts { uploads, changes })
}
/// Drop a queued op, once its upload has actually landed.
@@ -2073,6 +2216,22 @@ mod tests {
}
}
#[test]
fn upsert_nodes_and_load_all_roundtrip() {
let db = Db::open_in_memory().unwrap();
let root = folder("root", None, "My Files");
let child1 = file("f1", "root", "hello.txt", 42);
let child2 = file("f2", "root", "world.txt", 100);
db.upsert_nodes(&[root, child1, child2]).unwrap();
let loaded = db.load_all().unwrap();
assert_eq!(loaded.len(), 3);
let f1 = loaded.iter().find(|s| s.node.uid == uid("f1")).unwrap();
assert_eq!(f1.node.name, "hello.txt");
let f2 = loaded.iter().find(|s| s.node.uid == uid("f2")).unwrap();
assert_eq!(f2.node.name, "world.txt");
}
#[test]
fn upsert_is_idempotent_update() {
let db = Db::open_in_memory().unwrap();
@@ -2366,7 +2525,156 @@ mod tests {
assert_eq!(ops.len(), 1, "one queued upload per node");
assert_eq!(ops[0].id, id2);
assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/second"));
assert_eq!(db.pending_op_count().unwrap(), 1);
assert_eq!(db.pending_op_counts().unwrap().uploads, 1);
}
/// Deleting a folder that was created offline must take the ops queued
/// underneath it with it: they name a placeholder parent that will now never
/// become real, so nothing could ever drain them and nothing is left to
/// rewrite them.
#[test]
fn deleting_a_queued_folder_takes_its_queued_children_with_it() {
let db = Db::open_in_memory().unwrap();
let op = |kind: &str, uid: &str, parent: &str, blob: Option<&str>| PendingOp {
id: 0,
kind: kind.to_string(),
uid: uid.to_string(),
parent_uid: Some(parent.to_string()),
name: Some("n".to_string()),
blob_path: blob.map(str::to_string),
meta_json: None,
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
};
let root = uid("root").to_string();
db.enqueue_op(&op(OP_MKDIR, "local~dir", &root, None))
.unwrap();
db.enqueue_op(&op(OP_MKDIR, "local~sub", "local~dir", None))
.unwrap();
db.enqueue_op(&op(
OP_CREATE,
"local~deep",
"local~sub",
Some("/staging/deep"),
))
.unwrap();
// A sibling outside the doomed subtree must survive.
db.enqueue_op(&op(OP_CREATE, "local~other", &root, Some("/staging/other")))
.unwrap();
let blobs = db.delete_ops_for_uid("local~dir").unwrap();
assert_eq!(
blobs,
vec!["/staging/deep"],
"the subtree's bytes come back"
);
let left: Vec<String> = db
.pending_ops()
.unwrap()
.into_iter()
.map(|o| o.uid)
.collect();
assert_eq!(left, vec!["local~other"]);
}
/// A rename is the node's desired end state, so the newest one is the only
/// one worth performing — but it must not disturb the queued *upload* of the
/// same node, which is unrelated work.
#[test]
fn a_second_rename_supersedes_the_first_but_leaves_the_upload_alone() {
let db = Db::open_in_memory().unwrap();
let rename = |name: &str| PendingOp {
id: 0,
kind: OP_RENAME.to_string(),
uid: uid("a").to_string(),
parent_uid: Some(uid("parent").to_string()),
name: Some(name.to_string()),
blob_path: None,
meta_json: None,
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
};
db.enqueue_op(&PendingOp {
id: 0,
kind: OP_REVISION.to_string(),
uid: uid("a").to_string(),
parent_uid: None,
name: None,
blob_path: Some("/staging/blob".to_string()),
meta_json: Some("{}".to_string()),
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
})
.unwrap();
db.enqueue_op(&rename("first")).unwrap();
let (_, superseded) = db.enqueue_op(&rename("second")).unwrap();
assert_eq!(superseded, None, "a rename owns no blob to clean up");
let ops = db.pending_ops().unwrap();
assert_eq!(ops.len(), 2, "the queued upload survives the rename");
let renames: Vec<_> = ops.iter().filter(|o| o.kind == OP_RENAME).collect();
assert_eq!(renames.len(), 1, "one rename per node");
assert_eq!(renames[0].name.as_deref(), Some("second"));
let counts = db.pending_op_counts().unwrap();
assert_eq!(counts.uploads, 1, "the revision is the only upload");
assert_eq!(counts.changes, 1, "the rename carries no bytes");
}
/// Renaming a node whose create has not drained rewrites the intent rather
/// than queueing a rename against a uid the server has never issued.
#[test]
fn renaming_a_queued_create_rewrites_its_target() {
let db = Db::open_in_memory().unwrap();
let local = "local~abc";
db.enqueue_op(&PendingOp {
id: 0,
kind: OP_CREATE.to_string(),
uid: local.to_string(),
parent_uid: Some(uid("old").to_string()),
name: Some("draft.txt".to_string()),
blob_path: Some("/staging/blob".to_string()),
meta_json: Some("{}".to_string()),
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
})
.unwrap();
let rewritten = db
.rewrite_op_target(local, &uid("new").to_string(), "final.txt")
.unwrap();
assert!(rewritten);
let ops = db.pending_ops().unwrap();
assert_eq!(ops.len(), 1, "a rewrite is not a second op");
assert_eq!(ops[0].name.as_deref(), Some("final.txt"));
assert_eq!(
ops[0].parent_uid.as_deref(),
Some(uid("new").to_string()).as_deref()
);
assert_eq!(
ops[0].blob_path.as_deref(),
Some("/staging/blob"),
"the bytes riding on the create are untouched"
);
// Once the create has drained there is no intent left to rewrite, and the
// caller has to rename the real node instead.
db.delete_op(ops[0].id).unwrap();
assert!(
!db.rewrite_op_target(local, &uid("new").to_string(), "final.txt")
.unwrap()
);
}
#[test]
@@ -2484,7 +2792,7 @@ mod tests {
assert_eq!(db.pending_ops().unwrap()[0].attempts, 2);
db.delete_op(id).unwrap();
assert_eq!(db.pending_op_count().unwrap(), 0);
assert_eq!(db.pending_op_counts().unwrap().uploads, 0);
}
#[test]
File diff suppressed because it is too large. Load diff
+12 -10
View File
@@ -36,7 +36,7 @@ use pdfs_core::control::{
ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, InvitationInfo, JobItem,
PhotoItem, PublicLinkInfo, RefreshScope, Request, Response, SearchHit, ShareEntry,
ShareEntryKind, SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection,
TransferItem, send,
TransferItem, pending_summary, send,
};
use pdfs_core::service;
@@ -1965,19 +1965,21 @@ fn refresh_status(ui: &Rc<Ui>) {
pins,
online,
pending_uploads,
pending_changes,
..
})) => {
set_mounted(&ui, true);
// Queued uploads are the more useful thing to say when there are
// any: they are why a file that looks saved is not on the remote
// yet, and offline is usually the reason they are still queued.
ui.mount_row.set_subtitle(&match (online, pending_uploads) {
(true, 0) => format!("Mounted at {mountpoint}"),
(true, n) => format!("Mounted at {mountpoint} — uploading {n} file(s)"),
(false, 0) => format!("Mounted at {mountpoint} — offline, cached files only"),
(false, n) => {
format!("Mounted at {mountpoint} — offline, {n} file(s) waiting to upload")
// The queue is the more useful thing to say when it has anything
// in it: it is why a file that looks saved is not on the remote
// yet, and offline is usually the reason it is still queued.
let queued = pending_summary(pending_uploads, pending_changes);
ui.mount_row.set_subtitle(&match (online, queued) {
(true, None) => format!("Mounted at {mountpoint}"),
(true, Some(q)) => format!("Mounted at {mountpoint} — {q}"),
(false, None) => {
format!("Mounted at {mountpoint} — offline, cached files only")
}
(false, Some(q)) => format!("Mounted at {mountpoint} — offline, {q}"),
});
let fraction = if budget == 0 {
0.0
+9 -6
View File
@@ -18,7 +18,9 @@ use ksni::menu::StandardItem;
use ksni::{MenuItem, Tray, TrayService};
use pdfs_core::auth;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{JobItem, Request, Response, TransferDirection, TransferItem, send};
use pdfs_core::control::{
JobItem, Request, Response, TransferDirection, TransferItem, pending_summary, send,
};
use pdfs_core::service;
/// How often the tray re-polls the daemon to refresh its menu.
@@ -90,13 +92,14 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
pinned,
online,
pending_uploads,
pending_changes,
..
}) => DriveState {
line: match (online, pending_uploads) {
(true, 0) => format!("Mounted at {mountpoint} ({pinned} pinned)"),
(true, n) => format!("Uploading {n} file(s) ({pinned} pinned)"),
(false, 0) => format!("Offline — cached files only ({pinned} pinned)"),
(false, n) => format!("Offline — {n} file(s) waiting to upload"),
line: match (online, pending_summary(pending_uploads, pending_changes)) {
(true, None) => format!("Mounted at {mountpoint} ({pinned} pinned)"),
(true, Some(q)) => format!("Syncing — {q} ({pinned} pinned)"),
(false, None) => format!("Offline — cached files only ({pinned} pinned)"),
(false, Some(q)) => format!("Offline — {q}"),
},
mounted: true,
mountpoint: PathBuf::from(mountpoint),