added new folder icon, fixed db size (max log size), progress bars

This commit is contained in:
nvrl committed 2026-07-16 18:13:48 +02:00
1 parent a38d0371ec
commit bb9804498c
13 files changed
+3729 -166

No files matched your search

Generated
+81 -3
View File
@@ -1671,6 +1671,15 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "funty" name = "funty"
version = "2.0.0" version = "2.0.0"
@@ -2559,6 +2568,26 @@ dependencies = [
"hashbrown 0.17.1", "hashbrown 0.17.1",
] ]
[[package]]
name = "inotify"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8"
dependencies = [
"bitflags 2.13.0",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "inout" name = "inout"
version = "0.1.4" version = "0.1.4"
@@ -2725,6 +2754,26 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "kqueue"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
"bitflags 2.13.0",
"libc",
]
[[package]] [[package]]
name = "ksni" name = "ksni"
version = "0.2.2" version = "0.2.2"
@@ -2959,6 +3008,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [ dependencies = [
"libc", "libc",
"log",
"wasi", "wasi",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -3035,6 +3085,33 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]]
name = "notify"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags 2.13.0",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.60.2",
]
[[package]]
name = "notify-types"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
dependencies = [
"bitflags 2.13.0",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -3395,6 +3472,7 @@ dependencies = [
"fuser", "fuser",
"image", "image",
"libc", "libc",
"notify",
"pdfs-core", "pdfs-core",
"proton-drive-rs", "proton-drive-rs",
"serde_json", "serde_json",
@@ -3664,7 +3742,7 @@ dependencies = [
[[package]] [[package]]
name = "proton-drive-rs" name = "proton-drive-rs"
version = "0.1.6" version = "0.1.7"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64", "base64",
@@ -3688,7 +3766,7 @@ dependencies = [
[[package]] [[package]]
name = "proton-sdk" name = "proton-sdk"
version = "0.1.6" version = "0.1.7"
dependencies = [ dependencies = [
"aes-gcm 0.11.0", "aes-gcm 0.11.0",
"async-trait", "async-trait",
@@ -3804,7 +3882,7 @@ dependencies = [
"once_cell", "once_cell",
"socket2", "socket2",
"tracing", "tracing",
"windows-sys 0.60.2", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
+119 -3
View File
@@ -10,7 +10,9 @@ use clap::{Parser, Subcommand};
use pdfs_core::auth; use pdfs_core::auth;
use pdfs_core::cache::ContentCache; use pdfs_core::cache::ContentCache;
use pdfs_core::config::AppDirs; use pdfs_core::config::AppDirs;
use pdfs_core::control::{Request as CtlRequest, Response as CtlResponse, ShareEntryKind}; use pdfs_core::control::{
Request as CtlRequest, Response as CtlResponse, ShareEntryKind, SyncPhase,
};
use pdfs_core::db::Db; use pdfs_core::db::Db;
#[derive(Parser)] #[derive(Parser)]
@@ -149,6 +151,11 @@ enum Command {
#[command(subcommand)] #[command(subcommand)]
action: DeviceCmd, action: DeviceCmd,
}, },
/// Sync local folders to this machine's Proton Drive device.
Sync {
#[command(subcommand)]
action: SyncCmd,
},
/// Share a file or folder with Proton and/or external email addresses. /// Share a file or folder with Proton and/or external email addresses.
Share { Share {
/// File/folder path, inside the mountpoint or relative to it. /// File/folder path, inside the mountpoint or relative to it.
@@ -251,6 +258,38 @@ enum DeviceCmd {
Rm { uid: String }, Rm { uid: String },
} }
#[derive(Subcommand)]
enum SyncCmd {
/// Add a local folder to this machine's device and upload its contents.
Add {
/// Local directory to sync.
path: PathBuf,
},
/// List this machine's synced folders.
List,
/// Remove a synced folder by id.
Rm {
/// Synced-folder id (from `sync list`).
id: i64,
/// Also delete the folder's copy in Proton Drive.
#[arg(long)]
delete_remote: bool,
},
/// Force a sync pass now (all folders, or one by id).
Now {
/// Optional synced-folder id; omit to reconcile every folder.
id: Option<i64>,
},
/// Switch a folder between full-copy sync and on-demand (FUSE, no local storage).
Mode {
/// Synced-folder id (from `sync list`).
id: i64,
/// `mirror` (full local copy) or `ondemand` (FUSE mount, reclaims disk).
#[arg(value_parser = ["mirror", "ondemand"])]
mode: String,
},
}
#[derive(Subcommand)] #[derive(Subcommand)]
enum PublicLinkCmd { enum PublicLinkCmd {
/// Create a public link on a node. /// Create a public link on a node.
@@ -338,6 +377,7 @@ fn main() -> Result<()> {
Command::DeleteForever { uids } => cmd_delete_forever(uids), Command::DeleteForever { uids } => cmd_delete_forever(uids),
Command::EmptyTrash => cmd_empty_trash(), Command::EmptyTrash => cmd_empty_trash(),
Command::Devices { action } => cmd_devices(action), Command::Devices { action } => cmd_devices(action),
Command::Sync { action } => cmd_sync(action),
Command::Share { Command::Share {
path, path,
emails, emails,
@@ -389,6 +429,63 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> {
Ok(()) Ok(())
} }
fn cmd_sync(action: SyncCmd) -> Result<()> {
match action {
SyncCmd::Add { path } => {
let abs = std::fs::canonicalize(&path)
.with_context(|| format!("resolve {}", path.display()))?;
let local_path = abs
.to_str()
.ok_or_else(|| anyhow!("path is not valid UTF-8"))?
.to_string();
ok_or_bail(control_request(CtlRequest::AddSyncFolder { local_path })?)?
}
SyncCmd::List => match control_request(CtlRequest::ListSyncFolders)? {
CtlResponse::SyncFolders { items } if items.is_empty() => {
println!("No synced folders.")
}
CtlResponse::SyncFolders { items } => {
for f in items {
let sync = if f.last_sync == 0 {
"never".to_string()
} else {
f.last_sync.to_string()
};
println!(
"[{}] {} ({}, {}, synced: {sync})",
f.id, f.local_path, f.mode, f.state
);
// A pass in flight says what it is doing, indented under it.
if let Some(p) = &f.progress {
match p.phase {
SyncPhase::Scanning => println!(" scanning…"),
SyncPhase::Applying => println!(
" {} of {} {}",
p.done + 1,
p.total.max(p.done + 1),
p.current
),
}
}
}
}
CtlResponse::Error { message } => bail!("{message}"),
other => bail!("unexpected response: {other:?}"),
},
SyncCmd::Rm { id, delete_remote } => {
ok_or_bail(control_request(CtlRequest::RemoveSyncFolder {
id,
delete_remote,
})?)?
}
SyncCmd::Now { id } => ok_or_bail(control_request(CtlRequest::SyncNow { id })?)?,
SyncCmd::Mode { id, mode } => {
ok_or_bail(control_request(CtlRequest::SetSyncFolderMode { id, mode })?)?
}
}
Ok(())
}
fn cmd_share( fn cmd_share(
path: PathBuf, path: PathBuf,
emails: Vec<String>, emails: Vec<String>,
@@ -559,6 +656,8 @@ fn activity_verb(kind: pdfs_core::control::ActivityKind) -> &'static str {
use pdfs_core::control::ActivityKind::*; use pdfs_core::control::ActivityKind::*;
match kind { match kind {
Upload => "upload", Upload => "upload",
Download => "download",
Sync => "sync",
Rename => "rename", Rename => "rename",
Move => "move", Move => "move",
CreateFolder => "mkdir", CreateFolder => "mkdir",
@@ -847,8 +946,25 @@ fn cmd_unpin(path: PathBuf) -> Result<()> {
fn cmd_transfers() -> Result<()> { fn cmd_transfers() -> Result<()> {
use pdfs_core::control::TransferDirection; use pdfs_core::control::TransferDirection;
match control_request(CtlRequest::GetQueueStatus)? { match control_request(CtlRequest::GetQueueStatus)? {
CtlResponse::Transfers { items } if items.is_empty() => println!("No active transfers."), CtlResponse::Transfers { items, jobs } if items.is_empty() && jobs.is_empty() => {
CtlResponse::Transfers { items } => { println!("Nothing in progress.")
}
CtlResponse::Transfers { items, jobs } => {
// Jobs first: they are the context for the transfers under them ("of
// 40 files", "still scanning") rather than a separate list.
for j in jobs {
let count = if j.total > 0 {
format!("{} of {}", j.done, j.total)
} else {
"—".to_string()
};
let detail = if j.detail.is_empty() {
String::new()
} else {
format!(" — {}", j.detail)
};
println!("… {:>9} {}{detail}", count, j.title);
}
for t in items { for t in items {
let arrow = match t.direction { let arrow = match t.direction {
TransferDirection::Download => "↓", TransferDirection::Download => "↓",
+118 -9
View File
@@ -111,9 +111,11 @@ pub enum Request {
/// persist it to config so the next mount keeps it. Replies with /// persist it to config so the next mount keeps it. Replies with
/// [`Response::Ok`]. /// [`Response::Ok`].
SetCacheBudget { bytes: u64 }, SetCacheBudget { bytes: u64 },
/// Snapshot the daemon's in-flight transfers (active uploads/downloads). /// Snapshot what the daemon is working on: in-flight transfers (active
/// Replies with [`Response::Transfers`]. Cheap to poll: the daemon keeps the /// uploads/downloads) and the longer jobs around them (scans, folder
/// registry in memory, so a front-end can render a live progress widget. /// skeletons, the local index, sync passes). Replies with
/// [`Response::Transfers`]. Cheap to poll: the daemon keeps the registry in
/// memory, so a front-end can render a live progress widget.
GetQueueStatus, GetQueueStatus,
/// List what is in the account's trash. Replies with [`Response::Entries`]; /// List what is in the account's trash. Replies with [`Response::Entries`];
/// a trashed node has no path inside the mount, so each entry carries only /// a trashed node has no path inside the mount, so each entry carries only
@@ -138,6 +140,22 @@ pub enum Request {
/// Delete (deregister) a device by its uid. Replies with [`Response::Ok`]. /// Delete (deregister) a device by its uid. Replies with [`Response::Ok`].
DeleteDevice { uid: String }, DeleteDevice { uid: String },
// ---- device folder sync (devices.md) ----------------------------------
/// Add a local folder to this machine's device, uploading its tree and
/// registering the device on first use. Replies with [`Response::Ok`].
AddSyncFolder { local_path: String },
/// List this device's synced folders. Replies with [`Response::SyncFolders`].
ListSyncFolders,
/// Remove a synced folder by id; `delete_remote` also trashes its cloud copy.
/// Replies with [`Response::Ok`].
RemoveSyncFolder { id: i64, delete_remote: bool },
/// Switch a synced folder between `mirror` and `ondemand` (Phase 3). Replies
/// with [`Response::Ok`].
SetSyncFolderMode { id: i64, mode: String },
/// Force a reconcile pass: one folder by id, or all when `id` is `None`.
/// Replies with [`Response::Ok`].
SyncNow { id: Option<i64> },
// ---- sharing a node --------------------------------------------------- // ---- sharing a node ---------------------------------------------------
/// Invite `emails` (Proton and/or external addresses, auto-detected) to the /// Invite `emails` (Proton and/or external addresses, auto-detected) to the
/// node at mountpoint-relative `path` at `role` ("viewer"|"editor"|"admin"), /// node at mountpoint-relative `path` at `role` ("viewer"|"editor"|"admin"),
@@ -220,10 +238,11 @@ pub enum Request {
// ---- activity --------------------------------------------------------- // ---- activity ---------------------------------------------------------
/// Fetch the daemon's recent activity log, newest first, capped at `limit` /// Fetch the daemon's recent activity log, newest first, capped at `limit`
/// entries. Replies with [`Response::Activity`]. The log lives in memory for /// entries. Replies with [`Response::Activity`]. The log is persisted, so it
/// the life of the daemon: it records the mutations and transfers the daemon /// survives a daemon restart: it records the mutations and transfers the
/// performs (uploads, deletes, renames, shares, …), so a front-end can show a /// daemon performs (uploads, downloads, deletes, renames, shares, sync
/// running "what happened" feed without re-deriving it from anywhere. /// passes, …), so a front-end can show a running "what happened" feed
/// without re-deriving it from anywhere.
ListActivity { limit: usize }, ListActivity { limit: usize },
} }
@@ -252,6 +271,54 @@ pub struct DeviceInfo {
pub last_sync: Option<i64>, pub last_sync: Option<i64>,
} }
/// One synced local folder on this machine's device (in [`Response::SyncFolders`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SyncFolderInfo {
/// Row id — the handle for [`Request::RemoveSyncFolder`]/[`Request::SetSyncFolderMode`].
pub id: i64,
/// Absolute local folder path.
pub local_path: String,
/// The uid of the folder's remote root under the device root.
pub remote_uid: String,
/// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount).
pub mode: String,
/// `idle` | `syncing` | `error` | `conflict`.
pub state: String,
/// Last successful sync, epoch seconds; `0` if never.
pub last_sync: i64,
/// What the folder's sync pass is doing right now, or `None` when no pass is
/// running. Live daemon state, not a stored column.
#[serde(default)]
pub progress: Option<SyncProgress>,
}
/// Which stage a running sync pass is in, in a [`SyncProgress`].
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncPhase {
/// Walking the local tree, the remote tree and the stored baseline to work
/// out what changed. No per-item counts exist yet.
Scanning,
/// Applying the diff: creating folders, uploading, downloading, deleting.
Applying,
}
/// A snapshot of a sync pass in flight (in [`SyncFolderInfo::progress`]), so a
/// front-end can say what the daemon is doing rather than just "syncing".
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SyncProgress {
pub phase: SyncPhase,
/// Items applied so far this pass.
pub done: usize,
/// Items known to need applying. This *grows* during a pass: paths are
/// classified depth by depth (a folder must exist remotely before its
/// children can be queued), so the total is what has been discovered so far,
/// not a figure fixed up front.
pub total: usize,
/// The name of an item currently being applied, or empty between items.
/// Several run at once; this is just the most recently started.
pub current: String,
}
/// One member or pending invitation on a node's share (in [`Response::Share`]). /// One member or pending invitation on a node's share (in [`Response::Share`]).
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ShareEntry { pub struct ShareEntry {
@@ -337,6 +404,9 @@ pub struct SharedItem {
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActivityKind { pub enum ActivityKind {
Upload, Upload,
Download,
/// A whole sync pass over one folder, summarising what it moved.
Sync,
Rename, Rename,
Move, Move,
CreateFolder, CreateFolder,
@@ -375,6 +445,23 @@ pub enum TransferDirection {
Upload, Upload,
} }
/// One long-running daemon job in a [`Response::Transfers`] snapshot: work that
/// takes long enough to need reporting but doesn't move bytes over the wire —
/// walking a local tree, building a remote folder skeleton, indexing `$HOME`.
/// Byte-moving work is a [`TransferItem`] instead.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct JobItem {
/// What the job is, as a front-end would title it ("Uploading files").
pub title: String,
/// What it is doing right now ("Scanning Photos/2024"), or empty.
pub detail: String,
/// Steps finished so far.
pub done: u64,
/// Steps known to need doing, or `0` when unknown (indeterminate progress).
/// May *grow* mid-job as more work is discovered.
pub total: u64,
}
/// One in-flight transfer in a [`Response::Transfers`] snapshot. /// One in-flight transfer in a [`Response::Transfers`] snapshot.
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TransferItem { pub struct TransferItem {
@@ -532,10 +619,19 @@ pub enum Response {
/// is true while a scan of the machine is still running, so a front-end can /// is true while a scan of the machine is still running, so a front-end can
/// say "still indexing" instead of "no matches" on a cold first launch. /// say "still indexing" instead of "no matches" on a cold first launch.
LocalResults { hits: Vec<LocalHit>, indexing: bool }, LocalResults { hits: Vec<LocalHit>, indexing: bool },
/// A snapshot of in-flight transfers (reply to [`Request::GetQueueStatus`]). /// A snapshot of what the daemon is working on (reply to
Transfers { items: Vec<TransferItem> }, /// [`Request::GetQueueStatus`]): `items` are byte-moving transfers, `jobs`
/// the longer non-transfer work around them (scans, folder skeletons, the
/// local index, sync passes). Both empty means the daemon is idle.
Transfers {
items: Vec<TransferItem>,
#[serde(default)]
jobs: Vec<JobItem>,
},
/// The account's devices (reply to [`Request::ListDevices`]). /// The account's devices (reply to [`Request::ListDevices`]).
Devices { items: Vec<DeviceInfo> }, Devices { items: Vec<DeviceInfo> },
/// This device's synced folders (reply to [`Request::ListSyncFolders`]).
SyncFolders { items: Vec<SyncFolderInfo> },
/// A node's share: members + pending invitations, and its public link if any /// A node's share: members + pending invitations, and its public link if any
/// (reply to [`Request::ListShare`]). /// (reply to [`Request::ListShare`]).
Share { Share {
@@ -655,6 +751,19 @@ mod tests {
Request::DeleteDevice { Request::DeleteDevice {
uid: "dev-1".into(), uid: "dev-1".into(),
}, },
Request::AddSyncFolder {
local_path: "/home/me/Docs".into(),
},
Request::ListSyncFolders,
Request::RemoveSyncFolder {
id: 3,
delete_remote: true,
},
Request::SetSyncFolderMode {
id: 3,
mode: "ondemand".into(),
},
Request::SyncNow { id: Some(3) },
Request::ShareNode { Request::ShareNode {
path: "a/b".into(), path: "a/b".into(),
emails: vec!["x@proton.me".into(), "y@example.com".into()], emails: vec!["x@proton.me".into(), "y@example.com".into()],
+444 -1
View File
@@ -19,10 +19,20 @@ use proton_drive_rs::{Node, NodeKind};
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use crate::Result; use crate::Result;
use crate::control::{ActivityEntry, ActivityKind};
use crate::localindex::LocalEntry; use crate::localindex::LocalEntry;
/// Current schema version. Bump on every forward migration added below. /// Current schema version. Bump on every forward migration added below.
const SCHEMA_VERSION: i64 = 7; const SCHEMA_VERSION: i64 = 9;
/// How many activity rows to keep. Older rows are pruned on insert, so the feed
/// stays a bounded "recent history" rather than growing without limit.
const ACTIVITY_KEEP: i64 = 2000;
/// Size the WAL is truncated back to after a checkpoint. Comfortably above the
/// steady-state working set (a few MB), so the truncation only claws back the
/// outliers rather than fighting the normal write path for disk.
const WAL_SIZE_LIMIT: i64 = 64 * 1024 * 1024;
/// A photo whose thumbnail state is not known yet: it has never been asked for. /// A photo whose thumbnail state is not known yet: it has never been asked for.
pub const THUMB_UNKNOWN: i64 = 0; pub const THUMB_UNKNOWN: i64 = 0;
@@ -77,6 +87,46 @@ pub struct StoredTrash {
pub mtime: i64, pub mtime: i64,
} }
/// The one Proton Drive Device this machine is registered as (devices.md).
/// Cached so restarts reuse the same device rather than creating a new one.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredDevice {
pub uid: String,
pub share_id: String,
pub root_uid: String,
pub name: String,
pub created: i64,
}
/// One local folder the user added to this device's sync set.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredSyncFolder {
pub id: i64,
pub local_path: String,
pub remote_uid: String,
pub remote_share_id: String,
/// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount).
pub mode: String,
/// `idle` | `syncing` | `error` | `conflict`.
pub state: String,
pub last_sync: i64,
}
/// One per-file sync baseline row: what a path looked like on both sides at the
/// last successful sync, so the next reconcile can tell which side changed
/// (devices.md Phase 2). `remote_rev`/`remote_hash` hold the remote signature —
/// its modification time and size as strings — since no cheap content hash is
/// exposed; change detection is `(mtime, size)` on each side.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredSyncEntry {
pub rel_path: String,
pub remote_uid: Option<String>,
pub local_mtime: i64,
pub local_size: i64,
pub remote_rev: Option<String>,
pub remote_hash: Option<String>,
}
/// Handle to the unified metadata database. /// Handle to the unified metadata database.
/// ///
/// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through /// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through
@@ -95,6 +145,13 @@ impl Db {
conn.pragma_update(None, "journal_mode", "WAL")?; conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?; conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?; conn.pragma_update(None, "foreign_keys", "ON")?;
// Hand the WAL's disk back after a checkpoint. Without a limit SQLite
// reuses the file in place but never shrinks it, so a single large
// transaction (a full-index FTS rebuild in `local_finish_scan` is the
// one that reaches this size) leaves the WAL at its high-water mark
// forever — a multi-GB file next to a database two orders of magnitude
// smaller. Checkpointing is unaffected; only the file is truncated back.
conn.pragma_update(None, "journal_size_limit", WAL_SIZE_LIMIT)?;
let db = Self { let db = Self {
conn: Mutex::new(conn), conn: Mutex::new(conn),
@@ -161,6 +218,12 @@ impl Db {
if current < 7 { if current < 7 {
tx.execute_batch(MIGRATION_V7)?; tx.execute_batch(MIGRATION_V7)?;
} }
if current < 8 {
tx.execute_batch(MIGRATION_V8)?;
}
if current < 9 {
tx.execute_batch(MIGRATION_V9)?;
}
tx.execute( tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value", ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -527,6 +590,266 @@ impl Db {
Ok(items) Ok(items)
} }
// ---- device sync (devices.md) -----------------------------------------
/// The registered device for this machine, if one has been created/cached.
pub fn device_get(&self) -> Result<Option<StoredDevice>> {
let conn = self.conn.lock().unwrap();
conn.query_row(
"SELECT uid, share_id, root_uid, name, created FROM device LIMIT 1",
[],
|r| {
Ok(StoredDevice {
uid: r.get(0)?,
share_id: r.get(1)?,
root_uid: r.get(2)?,
name: r.get(3)?,
created: r.get(4)?,
})
},
)
.optional()
.map_err(Into::into)
}
/// Persist (or replace) this machine's device. The table holds a single row.
pub fn device_set(&self, dev: &StoredDevice) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM device", [])?;
conn.execute(
"INSERT INTO device (uid, share_id, root_uid, name, created)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![dev.uid, dev.share_id, dev.root_uid, dev.name, dev.created],
)?;
Ok(())
}
/// Add a synced folder, returning its new row id.
pub fn sync_folder_add(
&self,
local_path: &str,
remote_uid: &str,
remote_share_id: &str,
) -> Result<i64> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO sync_folder (local_path, remote_uid, remote_share_id)
VALUES (?1, ?2, ?3)",
params![local_path, remote_uid, remote_share_id],
)?;
Ok(conn.last_insert_rowid())
}
/// Every synced folder, oldest first.
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, local_path, remote_uid, remote_share_id, mode, state, last_sync
FROM sync_folder ORDER BY id",
)?;
let rows = stmt.query_map([], |r| {
Ok(StoredSyncFolder {
id: r.get(0)?,
local_path: r.get(1)?,
remote_uid: r.get(2)?,
remote_share_id: r.get(3)?,
mode: r.get(4)?,
state: r.get(5)?,
last_sync: r.get(6)?,
})
})?;
let mut items = Vec::new();
for row in rows {
items.push(row?);
}
Ok(items)
}
/// Look up one synced folder by id.
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
let conn = self.conn.lock().unwrap();
conn.query_row(
"SELECT id, local_path, remote_uid, remote_share_id, mode, state, last_sync
FROM sync_folder WHERE id = ?1",
params![id],
|r| {
Ok(StoredSyncFolder {
id: r.get(0)?,
local_path: r.get(1)?,
remote_uid: r.get(2)?,
remote_share_id: r.get(3)?,
mode: r.get(4)?,
state: r.get(5)?,
last_sync: r.get(6)?,
})
},
)
.optional()
.map_err(Into::into)
}
/// Remove a synced folder and its per-file baseline.
pub fn sync_folder_remove(&self, id: i64) -> Result<bool> {
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
tx.execute("DELETE FROM sync_entry WHERE folder_id = ?1", params![id])?;
let n = tx.execute("DELETE FROM sync_folder WHERE id = ?1", params![id])?;
tx.commit()?;
Ok(n > 0)
}
/// Update a synced folder's mode (`mirror`/`ondemand`).
pub fn sync_folder_set_mode(&self, id: i64, mode: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE sync_folder SET mode = ?2 WHERE id = ?1",
params![id, mode],
)?;
Ok(())
}
/// Update a synced folder's state and stamp `last_sync` to now.
pub fn sync_folder_set_state(&self, id: i64, state: &str, last_sync: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE sync_folder SET state = ?2, last_sync = ?3 WHERE id = ?1",
params![id, state, last_sync],
)?;
Ok(())
}
/// The whole per-file sync baseline for a folder, keyed by relative path.
pub fn sync_entries(&self, folder_id: i64) -> Result<HashMap<String, StoredSyncEntry>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash
FROM sync_entry WHERE folder_id = ?1",
)?;
let rows = stmt.query_map(params![folder_id], |r| {
Ok(StoredSyncEntry {
rel_path: r.get(0)?,
remote_uid: r.get(1)?,
local_mtime: r.get(2)?,
local_size: r.get(3)?,
remote_rev: r.get(4)?,
remote_hash: r.get(5)?,
})
})?;
let mut map = HashMap::new();
for row in rows {
let e = row?;
map.insert(e.rel_path.clone(), e);
}
Ok(map)
}
/// Insert or replace one baseline row.
pub fn sync_entry_upsert(&self, folder_id: i64, e: &StoredSyncEntry) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO sync_entry
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
remote_uid = excluded.remote_uid,
local_mtime = excluded.local_mtime,
local_size = excluded.local_size,
remote_rev = excluded.remote_rev,
remote_hash = excluded.remote_hash",
params![
folder_id,
e.rel_path,
e.remote_uid,
e.local_mtime,
e.local_size,
e.remote_rev,
e.remote_hash,
],
)?;
Ok(())
}
/// Drop one baseline row (a path that left the sync set on both sides).
pub fn sync_entry_remove(&self, folder_id: i64, rel_path: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"DELETE FROM sync_entry WHERE folder_id = ?1 AND rel_path = ?2",
params![folder_id, rel_path],
)?;
Ok(())
}
// ---- activity ---------------------------------------------------------
/// Append one entry to the activity log, pruning back to [`ACTIVITY_KEEP`]
/// rows. `kind` round-trips through serde rather than a hand-written string
/// table, so adding an [`ActivityKind`] variant needs no change here.
pub fn activity_add(&self, entry: &ActivityEntry) -> Result<()> {
let kind = serde_json::to_string(&entry.kind)?;
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
tx.execute(
"INSERT INTO activity (time, kind, target, detail, ok) VALUES (?1, ?2, ?3, ?4, ?5)",
params![entry.time, kind, entry.target, entry.detail, entry.ok],
)?;
tx.execute(
"DELETE FROM activity WHERE id <= (
SELECT id FROM activity ORDER BY id DESC LIMIT 1 OFFSET ?1
)",
params![ACTIVITY_KEEP],
)?;
tx.commit()?;
Ok(())
}
/// The most recent activity, newest first, capped at `limit` entries. Rows
/// whose stored `kind` no longer parses (written by an older build) are
/// skipped rather than failing the whole read.
pub fn activity_list(&self, limit: usize) -> Result<Vec<ActivityEntry>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT time, kind, target, detail, ok FROM activity
ORDER BY id DESC LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit as i64], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, String>(3)?,
r.get::<_, bool>(4)?,
))
})?;
let mut out = Vec::new();
for row in rows {
let (time, kind, target, detail, ok) = row?;
let Ok(kind) = serde_json::from_str::<ActivityKind>(&kind) else {
continue;
};
out.push(ActivityEntry {
time,
kind,
target,
detail,
ok,
});
}
Ok(out)
}
/// Drop the entire baseline for a folder. Used when flipping ondemand→mirror:
/// the local tree was evicted, so the old baseline is stale and would make the
/// next reconcile mistake "locally deleted" for "must re-download". Clearing it
/// leaves an empty baseline + full remote = pure download (devices.md P3).
pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"DELETE FROM sync_entry WHERE folder_id = ?1",
params![folder_id],
)?;
Ok(())
}
/// Children of `parent`, for the offline `ensure_children` fast path when a /// Children of `parent`, for the offline `ensure_children` fast path when a
/// listed folder's `children` map entry was trimmed mid-run. Returns `None` /// listed folder's `children` map entry was trimmed mid-run. Returns `None`
/// when the folder is not marked `listed` (listing unknown, must re-fetch). /// when the folder is not marked `listed` (listing unknown, must re-fetch).
@@ -1106,9 +1429,86 @@ CREATE TABLE trash (
); );
"; ";
/// Schema v8: device sync (devices.md). This machine registers as one Proton Drive
/// **Device** (a share + root folder on the main volume); `device` is a singleton
/// row cached so we reuse the same device across restarts instead of creating a
/// new one each run. `sync_folder` is one row per local folder the user added,
/// each mapped to a folder under the device root; `mode` is `mirror` (full local
/// copy, two-way synced) or `ondemand` (FUSE mount at `local_path`, no local
/// storage). `sync_entry` is the per-file baseline for three-way merge — added in
/// this migration so Phase 2 has the table, though Phase 1 leaves it empty.
const MIGRATION_V8: &str = "
CREATE TABLE device (
uid TEXT PRIMARY KEY,
share_id TEXT NOT NULL,
root_uid TEXT NOT NULL,
name TEXT NOT NULL,
created INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE sync_folder (
id INTEGER PRIMARY KEY,
local_path TEXT NOT NULL UNIQUE,
remote_uid TEXT NOT NULL,
remote_share_id TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'mirror',
state TEXT NOT NULL DEFAULT 'idle',
last_sync INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE sync_entry (
folder_id INTEGER NOT NULL,
rel_path TEXT NOT NULL,
remote_uid TEXT,
local_mtime INTEGER NOT NULL DEFAULT 0,
local_size INTEGER NOT NULL DEFAULT 0,
remote_hash TEXT,
remote_rev TEXT,
PRIMARY KEY (folder_id, rel_path)
);
";
const MIGRATION_V9: &str = "
CREATE TABLE activity (
id INTEGER PRIMARY KEY,
time INTEGER NOT NULL,
kind TEXT NOT NULL,
target TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
ok INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX activity_time ON activity(time DESC);
";
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
/// `Db::open` applies the pragmas that `open_in_memory` skips, so the WAL
/// settings can only be checked against a real file.
#[test]
fn open_bounds_the_wal_size() {
let path = std::env::temp_dir().join(format!("pdfs-db-wal-{}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let db = Db::open(&path).unwrap();
let conn = db.conn.lock().unwrap();
let mode: String = conn
.query_row("PRAGMA journal_mode", [], |r| r.get(0))
.unwrap();
assert_eq!(mode, "wal");
// Without this the WAL is reused in place but never shrinks, so one
// oversized transaction strands its high-water mark on disk forever.
let limit: i64 = conn
.query_row("PRAGMA journal_size_limit", [], |r| r.get(0))
.unwrap();
assert_eq!(limit, WAL_SIZE_LIMIT);
drop(conn);
drop(db);
let _ = std::fs::remove_file(&path);
}
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId}; use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
fn uid(link: &str) -> NodeUid { fn uid(link: &str) -> NodeUid {
@@ -1490,6 +1890,49 @@ mod tests {
assert_eq!(db.search("unique", 10).unwrap().len(), 0); assert_eq!(db.search("unique", 10).unwrap().len(), 0);
} }
fn activity(target: &str, kind: ActivityKind, ok: bool) -> ActivityEntry {
ActivityEntry {
time: 1700,
kind,
target: target.into(),
detail: "detail".into(),
ok,
}
}
#[test]
fn activity_reads_back_newest_first() {
let db = Db::open_in_memory().unwrap();
db.activity_add(&activity("a.txt", ActivityKind::Upload, true))
.unwrap();
db.activity_add(&activity("b.txt", ActivityKind::Download, false))
.unwrap();
let items = db.activity_list(10).unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0].target, "b.txt");
assert_eq!(items[0].kind, ActivityKind::Download);
assert!(!items[0].ok);
assert_eq!(items[0].detail, "detail");
assert_eq!(items[0].time, 1700);
assert_eq!(items[1].target, "a.txt");
assert_eq!(db.activity_list(1).unwrap().len(), 1);
}
#[test]
fn activity_prunes_to_the_keep_limit() {
let db = Db::open_in_memory().unwrap();
for i in 0..(ACTIVITY_KEEP + 10) {
db.activity_add(&activity(&format!("f{i}"), ActivityKind::Upload, true))
.unwrap();
}
let items = db.activity_list(ACTIVITY_KEEP as usize * 2).unwrap();
assert_eq!(items.len(), ACTIVITY_KEEP as usize);
// The newest survive; the oldest are the ones dropped.
assert_eq!(items[0].target, format!("f{}", ACTIVITY_KEEP + 9));
}
#[test] #[test]
fn opens_and_migrates() { fn opens_and_migrates() {
let db = Db::open_in_memory().unwrap(); let db = Db::open_in_memory().unwrap();
+1
View File
@@ -16,3 +16,4 @@ fuser.workspace = true
libc.workspace = true libc.workspace = true
serde_json.workspace = true serde_json.workspace = true
image.workspace = true image.workspace = true
notify = "8"
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+110 -12
View File
@@ -1,12 +1,17 @@
//! In-memory registry of in-flight transfers, behind [`Request::GetQueueStatus`]. //! In-memory registry of in-flight work, behind [`Request::GetQueueStatus`].
//! //!
//! The daemon downloads and uploads whole files through the SDK's streaming //! Two kinds of work live here. **Transfers** move bytes: the daemon downloads
//! [`Read`]/[`Write`] variants. Wrapping that reader/writer in a //! and uploads whole files through the SDK's streaming [`Read`]/[`Write`]
//! variants, and wrapping that reader/writer in a
//! [`CountingReader`]/[`CountingWriter`] lets each block of bytes tick a //! [`CountingReader`]/[`CountingWriter`] lets each block of bytes tick a
//! per-transfer counter without the SDK knowing anything about progress. A //! per-transfer counter without the SDK knowing anything about progress.
//! [`TransferGuard`] registers a transfer on creation and deregisters it on //! **Jobs** ([`JobGuard`]) cover the long stretches around them that move no
//! drop, so the registry always reflects exactly what is in flight — even if a //! bytes — walking a local tree, creating the remote folder skeleton, indexing
//! transfer fails partway and unwinds. //! `$HOME` — which otherwise look to a front-end exactly like an idle daemon.
//!
//! Both register on creation and deregister on guard drop, so the registry
//! always reflects exactly what is in flight — even if the work fails partway
//! and unwinds.
//! //!
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus //! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
@@ -16,7 +21,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Instant; use std::time::Instant;
use pdfs_core::control::{TransferDirection, TransferItem}; use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
/// One registered transfer. `done` is bumped from the I/O wrapper without the /// One registered transfer. `done` is bumped from the I/O wrapper without the
/// registry lock; the rest is immutable for the transfer's lifetime. /// registry lock; the rest is immutable for the transfer's lifetime.
@@ -29,11 +34,22 @@ struct Entry {
started: Instant, started: Instant,
} }
/// The set of transfers currently in flight. Cloned `Arc`-style across the FUSE /// One registered job. `detail`/`done`/`total` all move as the job runs; `total`
/// session and the control-socket task (both share one registry via [`Core`]). /// may grow when more work is discovered mid-job.
struct JobEntry {
title: String,
detail: Mutex<String>,
done: AtomicU64,
total: AtomicU64,
}
/// The set of transfers and jobs currently in flight. Cloned `Arc`-style across
/// the FUSE session and the control-socket task (both share one registry via
/// [`Core`]).
#[derive(Default)] #[derive(Default)]
pub struct TransferRegistry { pub struct TransferRegistry {
inner: Mutex<HashMap<u64, Arc<Entry>>>, inner: Mutex<HashMap<u64, Arc<Entry>>>,
jobs: Mutex<HashMap<u64, Arc<JobEntry>>>,
next: AtomicU64, next: AtomicU64,
} }
@@ -68,14 +84,58 @@ impl TransferRegistry {
} }
} }
/// Snapshot every in-flight transfer for [`Response::Transfers`]. Speed is the /// Register a long-running non-transfer job titled `title`, returning a guard
/// that carries its progress and deregisters the job when dropped. The job
/// starts indeterminate; give it a `total` once one is known.
pub fn begin_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
let id = self.next.fetch_add(1, Ordering::Relaxed);
let entry = Arc::new(JobEntry {
title: title.into(),
detail: Mutex::new(String::new()),
done: AtomicU64::new(0),
total: AtomicU64::new(0),
});
self.jobs.lock().unwrap().insert(id, entry.clone());
JobGuard {
reg: self.clone(),
id,
entry,
}
}
/// Snapshot every in-flight job for [`Response::Transfers`], oldest first, so
/// a front-end's rows keep their order across polls.
///
/// [`Response::Transfers`]: pdfs_core::control::Response::Transfers
pub fn jobs_snapshot(&self) -> Vec<JobItem> {
let map = self.jobs.lock().unwrap();
let mut ids: Vec<_> = map.keys().copied().collect();
ids.sort_unstable();
ids.iter()
.map(|id| {
let e = &map[id];
JobItem {
title: e.title.clone(),
detail: e.detail.lock().unwrap().clone(),
done: e.done.load(Ordering::Relaxed),
total: e.total.load(Ordering::Relaxed),
}
})
.collect()
}
/// Snapshot every in-flight transfer for [`Response::Transfers`], oldest
/// first so a front-end's rows keep their order across polls. Speed is the
/// running average since the transfer began — simple, and stable enough for a /// running average since the transfer began — simple, and stable enough for a
/// progress widget without per-tick sampling state. /// progress widget without per-tick sampling state.
/// ///
/// [`Response::Transfers`]: pdfs_core::control::Response::Transfers /// [`Response::Transfers`]: pdfs_core::control::Response::Transfers
pub fn snapshot(&self) -> Vec<TransferItem> { pub fn snapshot(&self) -> Vec<TransferItem> {
let map = self.inner.lock().unwrap(); let map = self.inner.lock().unwrap();
map.values() let mut ids: Vec<_> = map.keys().copied().collect();
ids.sort_unstable();
ids.iter()
.map(|id| &map[id])
.map(|e| { .map(|e| {
let done = e.done.load(Ordering::Relaxed); let done = e.done.load(Ordering::Relaxed);
let secs = e.started.elapsed().as_secs_f64(); let secs = e.started.elapsed().as_secs_f64();
@@ -120,6 +180,44 @@ impl Drop for TransferGuard {
} }
} }
/// Lifetime handle for a registered job: describe what it is doing with
/// [`detail`], size it with [`set_total`], advance it with [`step`], and the job
/// leaves the registry when this drops. Shared across the tasks of one batch as
/// an `Arc`, so a concurrent phase can report a single "N of M" line.
///
/// [`detail`]: JobGuard::detail
/// [`set_total`]: JobGuard::set_total
/// [`step`]: JobGuard::step
pub struct JobGuard {
reg: Arc<TransferRegistry>,
id: u64,
entry: Arc<JobEntry>,
}
impl JobGuard {
/// Say what the job is working on right now; empty clears it.
pub fn detail(&self, what: impl Into<String>) {
*self.entry.detail.lock().unwrap() = what.into();
}
/// Set how many steps the job now knows it has. Safe to raise mid-job as more
/// work is discovered.
pub fn set_total(&self, total: u64) {
self.entry.total.store(total, Ordering::Relaxed);
}
/// Mark one more step done.
pub fn step(&self) {
self.entry.done.fetch_add(1, Ordering::Relaxed);
}
}
impl Drop for JobGuard {
fn drop(&mut self) {
self.reg.jobs.lock().unwrap().remove(&self.id);
}
}
/// A [`Write`] that tallies bytes written to a [`TransferGuard`] (download path). /// A [`Write`] that tallies bytes written to a [`TransferGuard`] (download path).
pub struct CountingWriter<'a, W> { pub struct CountingWriter<'a, W> {
inner: W, inner: W,
+28
View File
@@ -0,0 +1,28 @@
use std::env;
use std::process::Command;
fn main() {
let out_dir = env::var("OUT_DIR").unwrap();
let status = Command::new("glib-compile-resources")
.args([
"--target",
&format!("{}/pdfs.gresource", out_dir),
"--sourcedir",
"resources/icons",
"resources/pdfs.gresource.xml",
])
.status()
.expect("Failed to execute glib-compile-resources");
if !status.success() {
panic!(
"glib-compile-resources failed with exit code: {:?}",
status.code()
);
}
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
println!(
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg"
);
}
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Upload-a-folder action: a folder silhouette with an up arrow knocked out of
it (one path, fill-rule evenodd, so symbolic recolouring keeps working).
Matches the Papirus folder shape (width 14, height 12) with a pixel-aligned,
proportionate up arrow cutout for size consistency. -->
<path fill="#222222" fill-rule="evenodd" d="M 1,3 V 14 C 1,14 1,15 2,15 H 14 C 14,15 15,15 15,14 V 5 C 15,4 14,4 14,4 H 9 L 7,2 H 2 C 2,2 1,2 1,3 Z M 7,12 V 8 H 5 L 8,5 L 11,8 H 9 V 12 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 593 B

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<!-- Icons we ship ourselves because no icon theme has them. Registered as an
icon-theme resource path at startup, so they resolve by name (and recolour
with the theme) exactly like a stock symbolic icon. -->
<gresource prefix="/de/nils/protondrivelinux/icons">
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
</gresource>
</gresources>
+531 -88
View File
@@ -33,9 +33,9 @@ use gtk4::glib::BoxedAnyObject;
use pdfs_core::auth; use pdfs_core::auth;
use pdfs_core::config::AppDirs; use pdfs_core::config::AppDirs;
use pdfs_core::control::{ use pdfs_core::control::{
ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, InvitationInfo, PhotoItem, ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, InvitationInfo, JobItem,
PublicLinkInfo, Request, Response, SearchHit, ShareEntry, ShareEntryKind, SharedItem, PhotoItem, PublicLinkInfo, Request, Response, SearchHit, ShareEntry, ShareEntryKind,
TransferDirection, TransferItem, send, SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, TransferItem, send,
}; };
use pdfs_core::service; use pdfs_core::service;
@@ -109,15 +109,24 @@ struct PinRow {
unpin: Option<gtk4::Button>, unpin: Option<gtk4::Button>,
} }
/// One rendered transfer row in the Activity group: a name + speed line over a /// One rendered row in the Activity group: a description over a progress bar.
/// progress bar. Retained so [`repaint_transfers`] can update the bar and label /// Retained so [`repaint_transfers`] can update the bar and label in place each
/// in place each tick when the active set is unchanged, instead of rebuilding. /// tick when the active set is unchanged, instead of rebuilding.
struct TransferRow { struct TransferRow {
row: adw::PreferencesRow, row: adw::PreferencesRow,
label: gtk4::Label, label: gtk4::Label,
bar: gtk4::ProgressBar, bar: gtk4::ProgressBar,
} }
/// What one Activity row should say this tick, and how far along it is —
/// `None` meaning "no total known", which the bar shows by pulsing. Jobs and
/// transfers both render to this, so the group is one list in the order the
/// daemon reports: the jobs that frame the work, then the files moving under it.
struct ActivityLine {
text: String,
fraction: Option<f64>,
}
/// All widgets the periodic refresh and the action handlers mutate, plus the /// All widgets the periodic refresh and the action handlers mutate, plus the
/// resolved paths they act on. Wrapped in an [`Rc`] so handlers and the timeout /// resolved paths they act on. Wrapped in an [`Rc`] so handlers and the timeout
/// closure share one instance. /// closure share one instance.
@@ -327,12 +336,15 @@ struct Ui {
/// When the Shared page last painted good data. `None` = never / invalidated, /// When the Shared page last painted good data. `None` = never / invalidated,
/// forcing a fetch on next visit. See [`PAGE_TTL`]. /// forcing a fetch on next visit. See [`PAGE_TTL`].
shared_loaded_at: Cell<Option<Instant>>, shared_loaded_at: Cell<Option<Instant>>,
// Devices page: one section, rebuilt wholesale on each load. // Devices page: two sections (synced folders + other devices), rebuilt
// wholesale on each load.
devices_content: gtk4::Stack, devices_content: gtk4::Stack,
devices_status: adw::StatusPage, devices_status: adw::StatusPage,
devices_retry: gtk4::Button, devices_retry: gtk4::Button,
devices_group: adw::PreferencesGroup, devices_group: adw::PreferencesGroup,
devices_rows: RefCell<Vec<gtk4::Widget>>, devices_rows: RefCell<Vec<gtk4::Widget>>,
devices_sync_group: adw::PreferencesGroup,
devices_sync_rows: RefCell<Vec<gtk4::Widget>>,
devices_inflight: Cell<bool>, devices_inflight: Cell<bool>,
devices_loaded_at: Cell<Option<Instant>>, devices_loaded_at: Cell<Option<Instant>>,
// Shared (by me) page: one section listing the items I've shared, each with a // Shared (by me) page: one section listing the items I've shared, each with a
@@ -351,6 +363,11 @@ struct Ui {
activity_group: adw::PreferencesGroup, activity_group: adw::PreferencesGroup,
activity_rows: RefCell<Vec<gtk4::Widget>>, activity_rows: RefCell<Vec<gtk4::Widget>>,
activity_inflight: Cell<bool>, activity_inflight: Cell<bool>,
/// Fingerprint of the feed currently on screen (see [`activity_key`]). The
/// page polls every couple of seconds and usually gets back exactly what it
/// is already showing; rebuilding all 200 rows for that would throw away the
/// user's scroll position several times a minute.
activity_key: RefCell<Option<String>>,
} }
impl Ui { impl Ui {
@@ -483,6 +500,15 @@ fn spawn_tray() {
/// purple, app-wide. Named-colour overrides recolour the stock widgets (switch, /// purple, app-wide. Named-colour overrides recolour the stock widgets (switch,
/// buttons, progress fill) without per-widget styling. /// buttons, progress fill) without per-widget styling.
fn load_proton_theme() { fn load_proton_theme() {
// Compile-in and register our custom GResources (e.g. custom icons)
let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/pdfs.gresource"));
let resource_data = glib::Bytes::from_static(bytes);
if let Ok(resource) = gio::Resource::from_data(&resource_data) {
gio::resources_register(&resource);
} else {
tracing::error!("failed to load gresource bundle");
}
let css = format!( let css = format!(
"@define-color accent_bg_color {PROTON_PURPLE};\n\ "@define-color accent_bg_color {PROTON_PURPLE};\n\
@define-color accent_color {PROTON_PURPLE};\n\ @define-color accent_color {PROTON_PURPLE};\n\
@@ -532,6 +558,10 @@ fn load_proton_theme() {
&provider, &provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
); );
// Register custom icons directory from our GResource with the icon theme
let icon_theme = gtk4::IconTheme::for_display(&display);
icon_theme.add_resource_path("/de/nils/protondrivelinux/icons");
} }
} }
@@ -691,6 +721,8 @@ fn build_window(app: &adw::Application) {
devices_retry: devices_widgets.retry.clone(), devices_retry: devices_widgets.retry.clone(),
devices_group: devices_widgets.group.clone(), devices_group: devices_widgets.group.clone(),
devices_rows: RefCell::new(Vec::new()), devices_rows: RefCell::new(Vec::new()),
devices_sync_group: devices_widgets.sync_group.clone(),
devices_sync_rows: RefCell::new(Vec::new()),
devices_inflight: Cell::new(false), devices_inflight: Cell::new(false),
devices_loaded_at: Cell::new(None), devices_loaded_at: Cell::new(None),
shared_by_me_content: shared_by_me_widgets.content.clone(), shared_by_me_content: shared_by_me_widgets.content.clone(),
@@ -706,6 +738,7 @@ fn build_window(app: &adw::Application) {
activity_group: activity_widgets.group.clone(), activity_group: activity_widgets.group.clone(),
activity_rows: RefCell::new(Vec::new()), activity_rows: RefCell::new(Vec::new()),
activity_inflight: Cell::new(false), activity_inflight: Cell::new(false),
activity_key: RefCell::new(None),
}); });
ui.load_ratios(); ui.load_ratios();
@@ -730,7 +763,7 @@ fn build_window(app: &adw::Application) {
wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty); wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty);
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark); wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
wire_shared_by_me(&ui, &shared_by_me_widgets.retry); wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
wire_devices(&ui, &devices_widgets.retry); wire_devices(&ui, &devices_widgets.retry, &devices_widgets.add_folder);
wire_activity(&ui, &activity_widgets.retry); wire_activity(&ui, &activity_widgets.retry);
wire_retry(&ui); wire_retry(&ui);
@@ -1612,6 +1645,13 @@ fn refresh(ui: &Rc<Ui>) {
refresh_status(ui); refresh_status(ui);
refresh_transfers(ui); refresh_transfers(ui);
// Both of these pages show work as it happens, so they follow the tick while
// they are on screen. Every other page loads on navigation only.
match ui.stack.visible_child_name().as_deref() {
Some("devices") => refresh_sync_folders(ui),
Some("activity") => refresh_activity(ui),
_ => {}
}
} }
/// Record the mount state seen by the last status poll: gate every control that /// Record the mount state seen by the last status poll: gate every control that
@@ -1683,21 +1723,30 @@ fn refresh_transfers(ui: &Rc<Ui>) {
let result = rx.recv().await; let result = rx.recv().await;
ui.transfers_inflight.set(false); ui.transfers_inflight.set(false);
match result { match result {
Ok(Ok(Response::Transfers { items })) => repaint_transfers(&ui, &items), Ok(Ok(Response::Transfers { items, jobs })) => repaint_transfers(&ui, &items, &jobs),
// Daemon unreachable or odd reply: clear the section rather than // Daemon unreachable or odd reply: clear the section rather than
// leave stale progress bars frozen on screen. // leave stale progress bars frozen on screen.
_ => repaint_transfers(&ui, &[]), _ => repaint_transfers(&ui, &[], &[]),
} }
}); });
} }
/// Render the Activity group from `items`. Rebuilds the rows only when the set of /// Render the Activity group from a work snapshot: the daemon's jobs (scans,
/// transfers changes (count differs); on the common steady tick it updates each /// batch counts, the local index) above the files moving under them. Rebuilds the
/// bar's fraction and the name/speed label in place, so progress animates without /// rows only when the set changes (count differs); on the common steady tick it
/// flicker. Hides the whole group when nothing is in flight. /// updates each bar's fraction and the label in place, so progress animates
fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem]) { /// without flicker. Hides the whole group when the daemon is idle.
fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem], jobs: &[JobItem]) {
let lines: Vec<ActivityLine> = jobs
.iter()
.map(job_line)
.chain(items.iter().map(transfer_line))
.collect();
// The wire carries in-flight transfers only, with no completion event: the // The wire carries in-flight transfers only, with no completion event: the
// count falling to zero is what "the sync finished" looks like from here. // count falling to zero is what "the sync finished" looks like from here.
// Jobs are deliberately not counted — a bulk upload retires its scan job and
// starts its upload job mid-flight, which is not a thing finishing.
let previous = ui.active_transfers.replace(items.len()); let previous = ui.active_transfers.replace(items.len());
if items.is_empty() && previous > 0 { if items.is_empty() && previous > 0 {
let files = if previous == 1 { let files = if previous == 1 {
@@ -1715,7 +1764,7 @@ fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem]) {
reload_listing(ui); reload_listing(ui);
} }
if items.is_empty() { if lines.is_empty() {
if !ui.transfer_rows.borrow().is_empty() { if !ui.transfer_rows.borrow().is_empty() {
for tr in ui.transfer_rows.borrow_mut().drain(..) { for tr in ui.transfer_rows.borrow_mut().drain(..) {
ui.transfers_group.remove(&tr.row); ui.transfers_group.remove(&tr.row);
@@ -1728,11 +1777,11 @@ fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem]) {
ui.transfers_group.set_visible(true); ui.transfers_group.set_visible(true);
// Rebuild rows only when the count changes; otherwise reuse them in place. // Rebuild rows only when the count changes; otherwise reuse them in place.
if ui.transfer_rows.borrow().len() != items.len() { if ui.transfer_rows.borrow().len() != lines.len() {
for tr in ui.transfer_rows.borrow_mut().drain(..) { for tr in ui.transfer_rows.borrow_mut().drain(..) {
ui.transfers_group.remove(&tr.row); ui.transfers_group.remove(&tr.row);
} }
for _ in items { for _ in &lines {
let row_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); let row_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
row_box.set_margin_top(8); row_box.set_margin_top(8);
row_box.set_margin_bottom(8); row_box.set_margin_bottom(8);
@@ -1752,30 +1801,57 @@ fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem]) {
} }
} }
for (item, tr) in items.iter().zip(ui.transfer_rows.borrow().iter()) { for (line, tr) in lines.iter().zip(ui.transfer_rows.borrow().iter()) {
let arrow = match item.direction { tr.label.set_text(&line.text);
TransferDirection::Download => "↓", match line.fraction {
TransferDirection::Upload => "↑", Some(f) => tr.bar.set_fraction(f),
}; // No total to divide by: pulse so the bar still reads as "working".
if item.bytes_total == 0 { None => tr.bar.pulse(),
// Unknown size: pulse instead of a fraction, and omit the percentage. }
tr.bar.pulse(); }
tr.label.set_text(&format!( }
/// One Activity row for a daemon job: its title, plus whatever it can say about
/// where it is — a count when it has one, else what it is currently chewing on.
fn job_line(j: &JobItem) -> ActivityLine {
let text = match (j.total > 0, j.detail.is_empty()) {
(true, true) => format!("{} — {} of {}", j.title, j.done, j.total),
(true, false) => format!("{} — {} ({} of {})", j.title, j.detail, j.done, j.total),
(false, true) => format!("{}…", j.title),
(false, false) => format!("{} — {}…", j.title, j.detail),
};
ActivityLine {
text,
fraction: (j.total > 0).then(|| (j.done as f64 / j.total as f64).min(1.0)),
}
}
/// One Activity row for a file in flight: which way it's going, how far, how fast.
fn transfer_line(t: &TransferItem) -> ActivityLine {
let arrow = match t.direction {
TransferDirection::Download => "↓",
TransferDirection::Upload => "↑",
};
if t.bytes_total == 0 {
ActivityLine {
text: format!(
"{arrow} {} — {} ({}/s)", "{arrow} {} — {} ({}/s)",
item.name, t.name,
human_bytes(item.bytes_completed), human_bytes(t.bytes_completed),
human_bytes(item.speed_bytes_sec), human_bytes(t.speed_bytes_sec),
)); ),
} else { fraction: None,
let fraction = (item.bytes_completed as f64 / item.bytes_total as f64).min(1.0); }
tr.bar.set_fraction(fraction); } else {
tr.label.set_text(&format!( ActivityLine {
text: format!(
"{arrow} {} — {} of {} ({}/s)", "{arrow} {} — {} of {} ({}/s)",
item.name, t.name,
human_bytes(item.bytes_completed), human_bytes(t.bytes_completed),
human_bytes(item.bytes_total), human_bytes(t.bytes_total),
human_bytes(item.speed_bytes_sec), human_bytes(t.speed_bytes_sec),
)); ),
fraction: Some((t.bytes_completed as f64 / t.bytes_total as f64).min(1.0)),
} }
} }
} }
@@ -1999,7 +2075,7 @@ fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
.build(); .build();
upload.add_css_class("flat"); upload.add_css_class("flat");
let upload_folder = gtk4::Button::builder() let upload_folder = gtk4::Button::builder()
.icon_name("send-to-symbolic") .icon_name("pdfs-folder-upload-symbolic")
.tooltip_text("Upload folder") .tooltip_text("Upload folder")
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
@@ -3502,7 +3578,11 @@ struct SharedWidgets {
struct DevicesWidgets { struct DevicesWidgets {
content: gtk4::Stack, content: gtk4::Stack,
status: adw::StatusPage, status: adw::StatusPage,
/// "This computer" — the local folders synced to this machine's device.
sync_group: adw::PreferencesGroup,
/// "Other devices" — the account's other registered devices (read-only).
group: adw::PreferencesGroup, group: adw::PreferencesGroup,
add_folder: gtk4::Button,
retry: gtk4::Button, retry: gtk4::Button,
} }
@@ -3590,20 +3670,41 @@ fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
) )
} }
/// The Devices page: one section listing every device registered to the account, /// The Computers page: a "This computer" section listing the local folders synced
/// each row offering Rename and Remove. /// to this machine's device (each row offering a mode toggle and Remove), plus an
/// "Other devices" section listing the account's other registered devices.
fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
let title = gtk4::Label::builder() let title = gtk4::Label::builder()
.label("Devices") .label("Computers")
.halign(gtk4::Align::Start) .halign(gtk4::Align::Start)
.build(); .build();
title.add_css_class("title-2"); title.add_css_class("title-2");
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
header.set_hexpand(true); titles.set_hexpand(true);
header.append(&title); titles.append(&title);
let group = adw::PreferencesGroup::new(); let add_folder = gtk4::Button::builder()
let clamp = adw::Clamp::builder().child(&group).build(); .label("Add Folder")
.valign(gtk4::Align::Center)
.build();
add_folder.add_css_class("flat");
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.append(&titles);
header.append(&add_folder);
let sync_group = adw::PreferencesGroup::builder()
.title("This computer")
.description("Local folders backed up to this device.")
.build();
let group = adw::PreferencesGroup::builder()
.title("Other devices")
.build();
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
groups.append(&sync_group);
groups.append(&group);
let clamp = adw::Clamp::builder().child(&groups).build();
let scroll = gtk4::ScrolledWindow::builder() let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true) .vexpand(true)
.child(&clamp) .child(&clamp)
@@ -3642,7 +3743,9 @@ fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
DevicesWidgets { DevicesWidgets {
content, content,
status, status,
sync_group,
group, group,
add_folder,
retry, retry,
}, },
) )
@@ -4391,13 +4494,15 @@ fn run_shared_mutation(ui: &Rc<Ui>, req: Request, done: &'static str, failed: &'
// ---- Devices page --------------------------------------------------------- // ---- Devices page ---------------------------------------------------------
/// Install the Devices page's retry button. /// Install the Devices page's retry button and the "Add Folder" action.
fn wire_devices(ui: &Rc<Ui>, retry: &gtk4::Button) { fn wire_devices(ui: &Rc<Ui>, retry: &gtk4::Button, add_folder: &gtk4::Button) {
let ui_retry = ui.clone(); let ui_retry = ui.clone();
retry.connect_clicked(move |_| { retry.connect_clicked(move |_| {
service::restart(); service::restart();
load_devices(&ui_retry); load_devices(&ui_retry);
}); });
let ui_add = ui.clone();
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
} }
/// Show a status page in place of the Devices list. /// Show a status page in place of the Devices list.
@@ -4409,7 +4514,9 @@ fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry
ui.devices_content.set_visible_child_name("status"); ui.devices_content.set_visible_child_name("status");
} }
/// Fetch the device list and repaint the page. /// Fetch this machine's synced folders and the account's other devices, then
/// repaint both sections. The two requests are chained so a single unreachable
/// daemon collapses the whole page to a status view.
fn load_devices(ui: &Rc<Ui>) { fn load_devices(ui: &Rc<Ui>) {
if ui.devices_inflight.get() { if ui.devices_inflight.get() {
return; return;
@@ -4423,35 +4530,64 @@ fn load_devices(ui: &Rc<Ui>) {
false, false,
); );
ui.busy_begin(); ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), Request::ListDevices); let rx = spawn_request(ui.dirs.control_socket(), Request::ListSyncFolders);
let ui = ui.clone();
glib::spawn_future_local(async move {
let sync = rx.recv().await;
let folders = match sync {
Ok(Ok(Response::SyncFolders { items })) => items,
// The daemon answered but not as expected — treat as empty and carry
// on to the devices list rather than failing the whole page.
Ok(Ok(_)) => Vec::new(),
Ok(Err(_)) | Err(_) => {
ui.busy_end();
ui.devices_inflight.set(false);
ui.devices_loaded_at.set(None);
devices_unreachable(&ui);
return;
}
};
// Daemon reachable: show the list and paint the synced-folders section.
ui.devices_content.set_visible_child_name("list");
repaint_sync_folders(&ui, &folders);
let rx2 = spawn_request(ui.dirs.control_socket(), Request::ListDevices);
let devices = match rx2.recv().await {
Ok(Ok(Response::Devices { items })) => items,
_ => Vec::new(),
};
ui.busy_end();
ui.devices_inflight.set(false);
repaint_devices(&ui, &devices);
ui.devices_loaded_at.set(Some(Instant::now()));
});
}
/// Refresh just the synced-folders section, with no status flash, no spinner and
/// no devices round-trip. Driven by the periodic tick while the Devices page is
/// on screen: a folder's sync progress is only live if something re-reads it.
///
/// A hiccup leaves the page as it is — the next tick tries again, and a real
/// outage is reported by [`load_devices`] on the next visit.
fn refresh_sync_folders(ui: &Rc<Ui>) {
if ui.devices_inflight.get() {
return;
}
ui.devices_inflight.set(true);
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSyncFolders);
let ui = ui.clone(); let ui = ui.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let result = rx.recv().await; let result = rx.recv().await;
ui.busy_end();
ui.devices_inflight.set(false); ui.devices_inflight.set(false);
match result { let Ok(Ok(Response::SyncFolders { items })) = result else {
Ok(Ok(Response::Devices { items })) => { return;
repaint_devices(&ui, &items); };
ui.devices_loaded_at.set(Some(Instant::now())); // The page may have been navigated away from, or collapsed to a status
} // view, while the request was in flight.
Ok(Ok(Response::Error { message })) => devices_status( if ui.stack.visible_child_name().as_deref() == Some("devices")
&ui, && ui.devices_content.visible_child_name().as_deref() == Some("list")
"dialog-warning-symbolic", {
"Couldn't read devices", repaint_sync_folders(&ui, &items);
&message,
false,
),
Ok(Ok(_)) => devices_status(
&ui,
"dialog-warning-symbolic",
"Couldn't read devices",
"Unexpected reply from the mount service.",
false,
),
Ok(Err(_)) | Err(_) => {
ui.devices_loaded_at.set(None);
devices_unreachable(&ui);
}
} }
}); });
} }
@@ -4483,22 +4619,23 @@ fn devices_unreachable(ui: &Rc<Ui>) {
}); });
} }
/// Rebuild the Devices section from a fresh listing. /// Rebuild the "Other devices" section from a fresh listing. Empty is a normal
/// state (this machine may be the only device), so it shows a placeholder row
/// rather than collapsing the page.
fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) { fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
for row in ui.devices_rows.borrow_mut().drain(..) { for row in ui.devices_rows.borrow_mut().drain(..) {
ui.devices_group.remove(&row); ui.devices_group.remove(&row);
} }
if devices.is_empty() { if devices.is_empty() {
devices_status( let row = adw::ActionRow::builder()
ui, .title("No other devices")
"computer-symbolic", .subtitle("Desktop apps syncing to this account appear here.")
"No devices", .build();
"Desktop apps that sync a folder to this account show up here.", row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
false, ui.devices_group.add(&row);
); *ui.devices_rows.borrow_mut() = vec![row.upcast()];
return; return;
} }
ui.devices_content.set_visible_child_name("list");
let mut rows: Vec<gtk4::Widget> = Vec::new(); let mut rows: Vec<gtk4::Widget> = Vec::new();
for dev in devices { for dev in devices {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
@@ -4534,6 +4671,263 @@ fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
*ui.devices_rows.borrow_mut() = rows; *ui.devices_rows.borrow_mut() = rows;
} }
/// Rebuild the "This computer" section from a fresh synced-folders listing.
fn repaint_sync_folders(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
for row in ui.devices_sync_rows.borrow_mut().drain(..) {
ui.devices_sync_group.remove(&row);
}
if folders.is_empty() {
let row = adw::ActionRow::builder()
.title("No synced folders")
.subtitle("Add a folder to back it up to this device.")
.build();
row.add_prefix(&gtk4::Image::from_icon_name("folder-symbolic"));
ui.devices_sync_group.add(&row);
*ui.devices_sync_rows.borrow_mut() = vec![row.upcast()];
return;
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
for f in folders {
// A running pass describes itself ("Uploading photo.jpg — 12 of 40");
// otherwise fall back to the folder's resting state.
let status = match &f.progress {
Some(p) => sync_progress_label(p),
None => sync_state_label(&f.state).to_string(),
};
let subtitle = match f.mode.as_str() {
"ondemand" => format!("On-demand · {status}"),
_ => format!("Synced · {status}"),
};
let row = adw::ActionRow::builder()
.title(&f.local_path)
.subtitle(&subtitle)
.build();
row.add_prefix(&gtk4::Image::from_icon_name("folder-symbolic"));
let id = f.id;
// A pass that is applying knows how far it has got, so show it: the
// subtitle alone ("syncing photo.jpg — 12 of 40") never conveys that the
// end is near. A scanning pass has no counts to draw, and this row is
// repainted on the 2s tick — too slow for a pulse to read as motion — so
// it stays text-only until the counts exist.
if let Some(p) = &f.progress
&& p.phase == SyncPhase::Applying
&& p.total > 0
{
let bar = gtk4::ProgressBar::builder()
.fraction((p.done as f64 / p.total as f64).min(1.0))
.valign(gtk4::Align::Center)
.width_request(120)
.build();
row.add_suffix(&bar);
}
// Sync now — only meaningful for mirror folders (on-demand has no local
// copy to reconcile). The engine also syncs on file changes and on a
// 120s poll; this is the "don't wait" button.
if f.mode != "ondemand" {
let sync_now = gtk4::Button::builder()
.icon_name("view-refresh-symbolic")
.tooltip_text("Sync this folder now")
.valign(gtk4::Align::Center)
.build();
sync_now.add_css_class("flat");
let ui_sync = ui.clone();
sync_now.connect_clicked(move |_| {
let rx = spawn_request(
ui_sync.dirs.control_socket(),
Request::SyncNow { id: Some(id) },
);
let ui_sync = ui_sync.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => toast(&ui_sync, "Syncing folder…"),
Ok(Ok(Response::Error { message })) => {
toast_error(&ui_sync, "Couldn't sync", &message)
}
_ => toast_error(
&ui_sync,
"Couldn't sync",
"The mount service didn't respond.",
),
}
});
});
row.add_suffix(&sync_now);
}
// On-demand toggle: off = full local copy (mirror), on = FUSE mount that
// frees the disk. Set the state before wiring the handler so painting the
// current mode doesn't fire a spurious request.
let ondemand = gtk4::Switch::builder()
.tooltip_text("On-demand: keep files in the cloud, free local disk")
.valign(gtk4::Align::Center)
.active(f.mode == "ondemand")
.build();
let ui_mode = ui.clone();
ondemand.connect_state_set(move |_, on| {
set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" });
glib::Propagation::Proceed
});
row.add_suffix(&ondemand);
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Stop syncing this folder")
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let ui_rm = ui.clone();
let path = f.local_path.clone();
remove.connect_clicked(move |_| prompt_remove_sync_folder(&ui_rm, id, &path));
row.add_suffix(&remove);
ui.devices_sync_group.add(&row);
rows.push(row.upcast());
}
*ui.devices_sync_rows.borrow_mut() = rows;
}
/// Human label for a synced folder's `state` column.
fn sync_state_label(state: &str) -> &str {
match state {
"syncing" => "syncing…",
"error" => "sync error",
"conflict" => "needs attention",
_ => "up to date",
}
}
/// Human label for a sync pass in flight: what it is doing, to which file, and
/// how far along it is. The total grows as deeper paths are classified, so this
/// reads "12 of 40" rather than showing a percentage that could go backwards.
fn sync_progress_label(p: &SyncProgress) -> String {
match p.phase {
SyncPhase::Scanning => "checking for changes…".to_string(),
SyncPhase::Applying => {
let count = format!("{} of {}", p.done + 1, p.total.max(p.done + 1));
if p.current.is_empty() {
format!("syncing {count}")
} else {
format!("syncing {} — {count}", p.current)
}
}
}
}
/// Pick a local folder and hand it to the daemon to sync to this device.
fn prompt_add_sync_folder(ui: &Rc<Ui>) {
let win = ui_window(ui);
let dialog = gtk4::FileDialog::builder()
.title("Add Folder to Sync")
.build();
let ui = ui.clone();
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
let Ok(folder) = res else { return };
let Some(local_path) = folder.path().and_then(|p| p.to_str().map(str::to_string)) else {
return;
};
let rx = spawn_request(
ui.dirs.control_socket(),
Request::AddSyncFolder {
local_path: local_path.clone(),
},
);
let ui = ui.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => {
toast(&ui, "Syncing folder…");
// The upload runs off-socket; reload shortly so the new row
// appears once the daemon has registered it.
let ui = ui.clone();
glib::timeout_add_local_once(std::time::Duration::from_secs(2), move || {
if ui.stack.visible_child_name().as_deref() == Some("devices") {
load_devices(&ui);
}
});
}
Ok(Ok(Response::Error { message })) => {
toast_error(&ui, "Couldn't add folder", &message)
}
_ => toast_error(
&ui,
"Couldn't add folder",
"The mount service didn't respond.",
),
}
});
});
}
/// Flip a synced folder between `mirror` and `ondemand`. Reloads after so the
/// row's subtitle and switch reflect the daemon's real state (the request may be
/// rejected, e.g. switching to on-demand while a folder is mid-sync).
fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &str) {
let rx = spawn_request(
ui.dirs.control_socket(),
Request::SetSyncFolderMode {
id,
mode: mode.to_string(),
},
);
let ui = ui.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Ok { message })) => toast(&ui, &message),
Ok(Ok(Response::Error { message })) => {
toast_error(&ui, "Couldn't change mode", &message)
}
_ => toast_error(
&ui,
"Couldn't change mode",
"The mount service didn't respond.",
),
}
if ui.stack.visible_child_name().as_deref() == Some("devices") {
load_devices(&ui);
}
});
}
/// Confirm, then stop syncing a folder. Offers to also delete the cloud copy.
fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading("Stop syncing folder")
.body(format!(
"Stop syncing “{path}”? Your local files stay; choose whether to also \
delete the copy in Proton Drive."
))
.build();
let group = adw::PreferencesGroup::new();
let delete_remote = adw::SwitchRow::builder()
.title("Also delete from Proton Drive")
.active(false)
.build();
group.add(&delete_remote);
dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel");
dialog.add_response("remove", "Stop Syncing");
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
dialog.connect_response(None, move |_, resp| {
if resp == "remove" {
run_devices_mutation(
&ui,
Request::RemoveSyncFolder {
id,
delete_remote: delete_remote.is_active(),
},
"Stopped syncing folder",
"Couldn't stop syncing the folder",
);
}
});
dialog.present(win.as_ref());
}
/// Prompt for a new device name and rename it. /// Prompt for a new device name and rename it.
fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) { fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
let win = ui_window(ui); let win = ui_window(ui);
@@ -4985,9 +5379,38 @@ fn activity_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retr
ui.activity_status.set_description(Some(description)); ui.activity_status.set_description(Some(description));
ui.activity_retry.set_visible(retry); ui.activity_retry.set_visible(retry);
ui.activity_content.set_visible_child_name("status"); ui.activity_content.set_visible_child_name("status");
// The list is no longer what is on screen, so the next repaint must not skip
// itself as a no-op and leave this status view up.
*ui.activity_key.borrow_mut() = None;
}
/// Refresh the Activity feed in place, with no status flash and no spinner.
/// Driven by the periodic tick while the page is on screen, so a running sync
/// pass fills the feed as it works rather than only once it is done. Anything
/// other than a good answer leaves the rows alone until the next tick.
fn refresh_activity(ui: &Rc<Ui>) {
if ui.activity_inflight.get() {
return;
}
ui.activity_inflight.set(true);
let rx = spawn_request(
ui.dirs.control_socket(),
Request::ListActivity { limit: 200 },
);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.activity_inflight.set(false);
// The page may have been navigated away from while the request was in
// flight.
if let Ok(Ok(Response::Activity { items })) = result
&& ui.stack.visible_child_name().as_deref() == Some("activity")
{
repaint_activity(&ui, &items);
}
});
} }
/// Fetch the recent activity and repaint the page.
fn load_activity(ui: &Rc<Ui>) { fn load_activity(ui: &Rc<Ui>) {
if ui.activity_inflight.get() { if ui.activity_inflight.get() {
return; return;
@@ -5058,8 +5481,24 @@ fn activity_unreachable(ui: &Rc<Ui>) {
}); });
} }
/// Rebuild the Activity section from a fresh log. /// A cheap fingerprint of a feed: how many entries, and what the newest one is.
/// The log is append-only and newest-first, so a feed with the same length and
/// the same newest entry is the same feed.
fn activity_key(items: &[ActivityEntry]) -> String {
match items.first() {
Some(a) => format!("{}:{}:{}:{}", items.len(), a.time, a.target, a.detail),
None => String::new(),
}
}
/// Rebuild the Activity section from a fresh log, unless it is already showing
/// exactly this feed.
fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) { fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) {
let key = activity_key(items);
if ui.activity_key.borrow().as_deref() == Some(key.as_str()) {
return;
}
*ui.activity_key.borrow_mut() = Some(key);
for row in ui.activity_rows.borrow_mut().drain(..) { for row in ui.activity_rows.borrow_mut().drain(..) {
ui.activity_group.remove(&row); ui.activity_group.remove(&row);
} }
@@ -5101,6 +5540,8 @@ fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) {
fn activity_verb(kind: ActivityKind) -> &'static str { fn activity_verb(kind: ActivityKind) -> &'static str {
match kind { match kind {
ActivityKind::Upload => "Uploaded", ActivityKind::Upload => "Uploaded",
ActivityKind::Download => "Downloaded",
ActivityKind::Sync => "Synced",
ActivityKind::Rename => "Renamed", ActivityKind::Rename => "Renamed",
ActivityKind::Move => "Moved", ActivityKind::Move => "Moved",
ActivityKind::CreateFolder => "Created folder", ActivityKind::CreateFolder => "Created folder",
@@ -5118,6 +5559,8 @@ fn activity_verb(kind: ActivityKind) -> &'static str {
fn activity_icon(kind: ActivityKind) -> &'static str { fn activity_icon(kind: ActivityKind) -> &'static str {
match kind { match kind {
ActivityKind::Upload => "document-send-symbolic", ActivityKind::Upload => "document-send-symbolic",
ActivityKind::Download => "document-save-symbolic",
ActivityKind::Sync => "emblem-synchronizing-symbolic",
ActivityKind::Rename => "document-edit-symbolic", ActivityKind::Rename => "document-edit-symbolic",
ActivityKind::Move => "go-jump-symbolic", ActivityKind::Move => "go-jump-symbolic",
ActivityKind::CreateFolder => "folder-new-symbolic", ActivityKind::CreateFolder => "folder-new-symbolic",
+12 -6
View File
@@ -18,7 +18,7 @@ use ksni::menu::StandardItem;
use ksni::{MenuItem, Tray, TrayService}; use ksni::{MenuItem, Tray, TrayService};
use pdfs_core::auth; use pdfs_core::auth;
use pdfs_core::config::AppDirs; use pdfs_core::config::AppDirs;
use pdfs_core::control::{Request, Response, TransferDirection, TransferItem, send}; use pdfs_core::control::{JobItem, Request, Response, TransferDirection, TransferItem, send};
use pdfs_core::service; use pdfs_core::service;
/// How often the tray re-polls the daemon to refresh its menu. /// How often the tray re-polls the daemon to refresh its menu.
@@ -38,12 +38,18 @@ struct DriveState {
sync: String, sync: String,
} }
/// Summarise a transfer snapshot into one menu line, or empty when idle. A single /// Summarise a work snapshot into one menu line, or empty when idle. A single
/// transfer names the file and its percentage; several collapse to counts so the /// transfer names the file and its percentage; several collapse to counts so the
/// menu stays one line regardless of queue depth. /// menu stays one line regardless of queue depth. With nothing moving bytes, a
fn sync_line(items: &[TransferItem]) -> String { /// running job speaks for itself — a scan or an index rebuild is still "busy",
/// and the tray saying nothing there reads as "finished".
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
match items { match items {
[] => String::new(), [] => match jobs.first() {
Some(j) if j.total > 0 => format!("{} ({} of {})", j.title, j.done, j.total),
Some(j) => format!("{}…", j.title),
None => String::new(),
},
[t] => { [t] => {
let arrow = match t.direction { let arrow = match t.direction {
TransferDirection::Download => "↓", TransferDirection::Download => "↓",
@@ -87,7 +93,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
mountpoint: PathBuf::from(mountpoint), mountpoint: PathBuf::from(mountpoint),
// Same daemon is up, so a cheap follow-up poll gives the sync line. // Same daemon is up, so a cheap follow-up poll gives the sync line.
sync: match send(socket, &Request::GetQueueStatus) { sync: match send(socket, &Request::GetQueueStatus) {
Ok(Response::Transfers { items }) => sync_line(&items), Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
_ => String::new(), _ => String::new(),
}, },
}, },