mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
added new folder icon, fixed db size (max log size), progress bars
This commit is contained in:
1 parent
a38d0371ec
commit
bb9804498c
13 files changed
+3729
-166
No files matched your search
Generated
+81
-3
@@ -1671,6 +1671,15 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
@@ -2559,6 +2568,26 @@ dependencies = [
|
||||
"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]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
@@ -2725,6 +2754,26 @@ dependencies = [
|
||||
"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]]
|
||||
name = "ksni"
|
||||
version = "0.2.2"
|
||||
@@ -2959,6 +3008,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -3035,6 +3085,33 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -3395,6 +3472,7 @@ dependencies = [
|
||||
"fuser",
|
||||
"image",
|
||||
"libc",
|
||||
"notify",
|
||||
"pdfs-core",
|
||||
"proton-drive-rs",
|
||||
"serde_json",
|
||||
@@ -3664,7 +3742,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.1.6"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
@@ -3688,7 +3766,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.1.6"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.0",
|
||||
"async-trait",
|
||||
@@ -3804,7 +3882,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+119
-3
@@ -10,7 +10,9 @@ use clap::{Parser, Subcommand};
|
||||
use pdfs_core::auth;
|
||||
use pdfs_core::cache::ContentCache;
|
||||
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;
|
||||
|
||||
#[derive(Parser)]
|
||||
@@ -149,6 +151,11 @@ enum Command {
|
||||
#[command(subcommand)]
|
||||
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 {
|
||||
/// File/folder path, inside the mountpoint or relative to it.
|
||||
@@ -251,6 +258,38 @@ enum DeviceCmd {
|
||||
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)]
|
||||
enum PublicLinkCmd {
|
||||
/// Create a public link on a node.
|
||||
@@ -338,6 +377,7 @@ fn main() -> Result<()> {
|
||||
Command::DeleteForever { uids } => cmd_delete_forever(uids),
|
||||
Command::EmptyTrash => cmd_empty_trash(),
|
||||
Command::Devices { action } => cmd_devices(action),
|
||||
Command::Sync { action } => cmd_sync(action),
|
||||
Command::Share {
|
||||
path,
|
||||
emails,
|
||||
@@ -389,6 +429,63 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> {
|
||||
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(
|
||||
path: PathBuf,
|
||||
emails: Vec<String>,
|
||||
@@ -559,6 +656,8 @@ fn activity_verb(kind: pdfs_core::control::ActivityKind) -> &'static str {
|
||||
use pdfs_core::control::ActivityKind::*;
|
||||
match kind {
|
||||
Upload => "upload",
|
||||
Download => "download",
|
||||
Sync => "sync",
|
||||
Rename => "rename",
|
||||
Move => "move",
|
||||
CreateFolder => "mkdir",
|
||||
@@ -847,8 +946,25 @@ fn cmd_unpin(path: PathBuf) -> Result<()> {
|
||||
fn cmd_transfers() -> Result<()> {
|
||||
use pdfs_core::control::TransferDirection;
|
||||
match control_request(CtlRequest::GetQueueStatus)? {
|
||||
CtlResponse::Transfers { items } if items.is_empty() => println!("No active transfers."),
|
||||
CtlResponse::Transfers { items } => {
|
||||
CtlResponse::Transfers { items, jobs } if items.is_empty() && jobs.is_empty() => {
|
||||
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 {
|
||||
let arrow = match t.direction {
|
||||
TransferDirection::Download => "↓",
|
||||
|
||||
@@ -111,9 +111,11 @@ pub enum Request {
|
||||
/// persist it to config so the next mount keeps it. Replies with
|
||||
/// [`Response::Ok`].
|
||||
SetCacheBudget { bytes: u64 },
|
||||
/// Snapshot the daemon's in-flight transfers (active uploads/downloads).
|
||||
/// Replies with [`Response::Transfers`]. Cheap to poll: the daemon keeps the
|
||||
/// registry in memory, so a front-end can render a live progress widget.
|
||||
/// Snapshot what the daemon is working on: in-flight transfers (active
|
||||
/// uploads/downloads) and the longer jobs around them (scans, folder
|
||||
/// 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,
|
||||
/// 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
|
||||
@@ -138,6 +140,22 @@ pub enum Request {
|
||||
/// Delete (deregister) a device by its uid. Replies with [`Response::Ok`].
|
||||
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 ---------------------------------------------------
|
||||
/// Invite `emails` (Proton and/or external addresses, auto-detected) to the
|
||||
/// node at mountpoint-relative `path` at `role` ("viewer"|"editor"|"admin"),
|
||||
@@ -220,10 +238,11 @@ pub enum Request {
|
||||
|
||||
// ---- activity ---------------------------------------------------------
|
||||
/// Fetch the daemon's recent activity log, newest first, capped at `limit`
|
||||
/// entries. Replies with [`Response::Activity`]. The log lives in memory for
|
||||
/// the life of the daemon: it records the mutations and transfers the daemon
|
||||
/// performs (uploads, deletes, renames, shares, …), so a front-end can show a
|
||||
/// running "what happened" feed without re-deriving it from anywhere.
|
||||
/// entries. Replies with [`Response::Activity`]. The log is persisted, so it
|
||||
/// survives a daemon restart: it records the mutations and transfers the
|
||||
/// daemon performs (uploads, downloads, deletes, renames, shares, sync
|
||||
/// passes, …), so a front-end can show a running "what happened" feed
|
||||
/// without re-deriving it from anywhere.
|
||||
ListActivity { limit: usize },
|
||||
}
|
||||
|
||||
@@ -252,6 +271,54 @@ pub struct DeviceInfo {
|
||||
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`]).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ShareEntry {
|
||||
@@ -337,6 +404,9 @@ pub struct SharedItem {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ActivityKind {
|
||||
Upload,
|
||||
Download,
|
||||
/// A whole sync pass over one folder, summarising what it moved.
|
||||
Sync,
|
||||
Rename,
|
||||
Move,
|
||||
CreateFolder,
|
||||
@@ -375,6 +445,23 @@ pub enum TransferDirection {
|
||||
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.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
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
|
||||
/// say "still indexing" instead of "no matches" on a cold first launch.
|
||||
LocalResults { hits: Vec<LocalHit>, indexing: bool },
|
||||
/// A snapshot of in-flight transfers (reply to [`Request::GetQueueStatus`]).
|
||||
Transfers { items: Vec<TransferItem> },
|
||||
/// A snapshot of what the daemon is working on (reply to
|
||||
/// [`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`]).
|
||||
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
|
||||
/// (reply to [`Request::ListShare`]).
|
||||
Share {
|
||||
@@ -655,6 +751,19 @@ mod tests {
|
||||
Request::DeleteDevice {
|
||||
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 {
|
||||
path: "a/b".into(),
|
||||
emails: vec!["x@proton.me".into(), "y@example.com".into()],
|
||||
|
||||
+444
-1
@@ -19,10 +19,20 @@ use proton_drive_rs::{Node, NodeKind};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
use crate::Result;
|
||||
use crate::control::{ActivityEntry, ActivityKind};
|
||||
use crate::localindex::LocalEntry;
|
||||
|
||||
/// 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.
|
||||
pub const THUMB_UNKNOWN: i64 = 0;
|
||||
@@ -77,6 +87,46 @@ pub struct StoredTrash {
|
||||
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.
|
||||
///
|
||||
/// 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, "synchronous", "NORMAL")?;
|
||||
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 {
|
||||
conn: Mutex::new(conn),
|
||||
@@ -161,6 +218,12 @@ impl Db {
|
||||
if current < 7 {
|
||||
tx.execute_batch(MIGRATION_V7)?;
|
||||
}
|
||||
if current < 8 {
|
||||
tx.execute_batch(MIGRATION_V8)?;
|
||||
}
|
||||
if current < 9 {
|
||||
tx.execute_batch(MIGRATION_V9)?;
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -527,6 +590,266 @@ impl Db {
|
||||
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
|
||||
/// listed folder's `children` map entry was trimmed mid-run. Returns `None`
|
||||
/// 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)]
|
||||
mod tests {
|
||||
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};
|
||||
|
||||
fn uid(link: &str) -> NodeUid {
|
||||
@@ -1490,6 +1890,49 @@ mod tests {
|
||||
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]
|
||||
fn opens_and_migrates() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
|
||||
@@ -16,3 +16,4 @@ fuser.workspace = true
|
||||
libc.workspace = true
|
||||
serde_json.workspace = true
|
||||
image.workspace = true
|
||||
notify = "8"
|
||||
+812
-44
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
//! [`Read`]/[`Write`] variants. Wrapping that reader/writer in a
|
||||
//! Two kinds of work live here. **Transfers** move bytes: the daemon downloads
|
||||
//! 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
|
||||
//! per-transfer counter without the SDK knowing anything about progress. A
|
||||
//! [`TransferGuard`] registers a transfer on creation and deregisters it on
|
||||
//! drop, so the registry always reflects exactly what is in flight — even if a
|
||||
//! transfer fails partway and unwinds.
|
||||
//! per-transfer counter without the SDK knowing anything about progress.
|
||||
//! **Jobs** ([`JobGuard`]) cover the long stretches around them that move no
|
||||
//! bytes — walking a local tree, creating the remote folder skeleton, indexing
|
||||
//! `$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
|
||||
|
||||
@@ -16,7 +21,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
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
|
||||
/// registry lock; the rest is immutable for the transfer's lifetime.
|
||||
@@ -29,11 +34,22 @@ struct Entry {
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
/// The set of transfers currently in flight. Cloned `Arc`-style across the FUSE
|
||||
/// session and the control-socket task (both share one registry via [`Core`]).
|
||||
/// One registered job. `detail`/`done`/`total` all move as the job runs; `total`
|
||||
/// 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)]
|
||||
pub struct TransferRegistry {
|
||||
inner: Mutex<HashMap<u64, Arc<Entry>>>,
|
||||
jobs: Mutex<HashMap<u64, Arc<JobEntry>>>,
|
||||
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
|
||||
/// progress widget without per-tick sampling state.
|
||||
///
|
||||
/// [`Response::Transfers`]: pdfs_core::control::Response::Transfers
|
||||
pub fn snapshot(&self) -> Vec<TransferItem> {
|
||||
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| {
|
||||
let done = e.done.load(Ordering::Relaxed);
|
||||
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).
|
||||
pub struct CountingWriter<'a, W> {
|
||||
inner: W,
|
||||
|
||||
@@ -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
@@ -33,9 +33,9 @@ use gtk4::glib::BoxedAnyObject;
|
||||
use pdfs_core::auth;
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, InvitationInfo, PhotoItem,
|
||||
PublicLinkInfo, Request, Response, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
||||
TransferDirection, TransferItem, send,
|
||||
ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, InvitationInfo, JobItem,
|
||||
PhotoItem, PublicLinkInfo, Request, Response, SearchHit, ShareEntry, ShareEntryKind,
|
||||
SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, TransferItem, send,
|
||||
};
|
||||
use pdfs_core::service;
|
||||
|
||||
@@ -109,15 +109,24 @@ struct PinRow {
|
||||
unpin: Option<gtk4::Button>,
|
||||
}
|
||||
|
||||
/// One rendered transfer row in the Activity group: a name + speed line over a
|
||||
/// progress bar. Retained so [`repaint_transfers`] can update the bar and label
|
||||
/// in place each tick when the active set is unchanged, instead of rebuilding.
|
||||
/// One rendered row in the Activity group: a description over a progress bar.
|
||||
/// Retained so [`repaint_transfers`] can update the bar and label in place each
|
||||
/// tick when the active set is unchanged, instead of rebuilding.
|
||||
struct TransferRow {
|
||||
row: adw::PreferencesRow,
|
||||
label: gtk4::Label,
|
||||
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
|
||||
/// resolved paths they act on. Wrapped in an [`Rc`] so handlers and the timeout
|
||||
/// closure share one instance.
|
||||
@@ -327,12 +336,15 @@ struct Ui {
|
||||
/// When the Shared page last painted good data. `None` = never / invalidated,
|
||||
/// forcing a fetch on next visit. See [`PAGE_TTL`].
|
||||
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_status: adw::StatusPage,
|
||||
devices_retry: gtk4::Button,
|
||||
devices_group: adw::PreferencesGroup,
|
||||
devices_rows: RefCell<Vec<gtk4::Widget>>,
|
||||
devices_sync_group: adw::PreferencesGroup,
|
||||
devices_sync_rows: RefCell<Vec<gtk4::Widget>>,
|
||||
devices_inflight: Cell<bool>,
|
||||
devices_loaded_at: Cell<Option<Instant>>,
|
||||
// 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_rows: RefCell<Vec<gtk4::Widget>>,
|
||||
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 {
|
||||
@@ -483,6 +500,15 @@ fn spawn_tray() {
|
||||
/// purple, app-wide. Named-colour overrides recolour the stock widgets (switch,
|
||||
/// buttons, progress fill) without per-widget styling.
|
||||
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!(
|
||||
"@define-color accent_bg_color {PROTON_PURPLE};\n\
|
||||
@define-color accent_color {PROTON_PURPLE};\n\
|
||||
@@ -532,6 +558,10 @@ fn load_proton_theme() {
|
||||
&provider,
|
||||
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_group: devices_widgets.group.clone(),
|
||||
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_loaded_at: Cell::new(None),
|
||||
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_rows: RefCell::new(Vec::new()),
|
||||
activity_inflight: Cell::new(false),
|
||||
activity_key: RefCell::new(None),
|
||||
});
|
||||
ui.load_ratios();
|
||||
|
||||
@@ -730,7 +763,7 @@ fn build_window(app: &adw::Application) {
|
||||
wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty);
|
||||
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
||||
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_retry(&ui);
|
||||
|
||||
@@ -1612,6 +1645,13 @@ fn refresh(ui: &Rc<Ui>) {
|
||||
|
||||
refresh_status(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
|
||||
@@ -1683,21 +1723,30 @@ fn refresh_transfers(ui: &Rc<Ui>) {
|
||||
let result = rx.recv().await;
|
||||
ui.transfers_inflight.set(false);
|
||||
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
|
||||
// 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
|
||||
/// transfers changes (count differs); on the common steady tick it updates each
|
||||
/// bar's fraction and the name/speed label in place, so progress animates without
|
||||
/// flicker. Hides the whole group when nothing is in flight.
|
||||
fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem]) {
|
||||
/// Render the Activity group from a work snapshot: the daemon's jobs (scans,
|
||||
/// batch counts, the local index) above the files moving under them. Rebuilds the
|
||||
/// rows only when the set changes (count differs); on the common steady tick it
|
||||
/// updates each bar's fraction and the label in place, so progress animates
|
||||
/// 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
|
||||
// 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());
|
||||
if items.is_empty() && previous > 0 {
|
||||
let files = if previous == 1 {
|
||||
@@ -1715,7 +1764,7 @@ fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem]) {
|
||||
reload_listing(ui);
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
if lines.is_empty() {
|
||||
if !ui.transfer_rows.borrow().is_empty() {
|
||||
for tr in ui.transfer_rows.borrow_mut().drain(..) {
|
||||
ui.transfers_group.remove(&tr.row);
|
||||
@@ -1728,11 +1777,11 @@ fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem]) {
|
||||
ui.transfers_group.set_visible(true);
|
||||
|
||||
// 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(..) {
|
||||
ui.transfers_group.remove(&tr.row);
|
||||
}
|
||||
for _ in items {
|
||||
for _ in &lines {
|
||||
let row_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
row_box.set_margin_top(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()) {
|
||||
let arrow = match item.direction {
|
||||
TransferDirection::Download => "↓",
|
||||
TransferDirection::Upload => "↑",
|
||||
};
|
||||
if item.bytes_total == 0 {
|
||||
// Unknown size: pulse instead of a fraction, and omit the percentage.
|
||||
tr.bar.pulse();
|
||||
tr.label.set_text(&format!(
|
||||
for (line, tr) in lines.iter().zip(ui.transfer_rows.borrow().iter()) {
|
||||
tr.label.set_text(&line.text);
|
||||
match line.fraction {
|
||||
Some(f) => tr.bar.set_fraction(f),
|
||||
// No total to divide by: pulse so the bar still reads as "working".
|
||||
None => tr.bar.pulse(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)",
|
||||
item.name,
|
||||
human_bytes(item.bytes_completed),
|
||||
human_bytes(item.speed_bytes_sec),
|
||||
));
|
||||
} else {
|
||||
let fraction = (item.bytes_completed as f64 / item.bytes_total as f64).min(1.0);
|
||||
tr.bar.set_fraction(fraction);
|
||||
tr.label.set_text(&format!(
|
||||
t.name,
|
||||
human_bytes(t.bytes_completed),
|
||||
human_bytes(t.speed_bytes_sec),
|
||||
),
|
||||
fraction: None,
|
||||
}
|
||||
} else {
|
||||
ActivityLine {
|
||||
text: format!(
|
||||
"{arrow} {} — {} of {} ({}/s)",
|
||||
item.name,
|
||||
human_bytes(item.bytes_completed),
|
||||
human_bytes(item.bytes_total),
|
||||
human_bytes(item.speed_bytes_sec),
|
||||
));
|
||||
t.name,
|
||||
human_bytes(t.bytes_completed),
|
||||
human_bytes(t.bytes_total),
|
||||
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();
|
||||
upload.add_css_class("flat");
|
||||
let upload_folder = gtk4::Button::builder()
|
||||
.icon_name("send-to-symbolic")
|
||||
.icon_name("pdfs-folder-upload-symbolic")
|
||||
.tooltip_text("Upload folder")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
@@ -3502,7 +3578,11 @@ struct SharedWidgets {
|
||||
struct DevicesWidgets {
|
||||
content: gtk4::Stack,
|
||||
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,
|
||||
add_folder: 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,
|
||||
/// each row offering Rename and Remove.
|
||||
/// The Computers page: a "This computer" section listing the local folders synced
|
||||
/// 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) {
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Devices")
|
||||
.label("Computers")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header.set_hexpand(true);
|
||||
header.append(&title);
|
||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
titles.set_hexpand(true);
|
||||
titles.append(&title);
|
||||
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let clamp = adw::Clamp::builder().child(&group).build();
|
||||
let add_folder = gtk4::Button::builder()
|
||||
.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()
|
||||
.vexpand(true)
|
||||
.child(&clamp)
|
||||
@@ -3642,7 +3743,9 @@ fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||
DevicesWidgets {
|
||||
content,
|
||||
status,
|
||||
sync_group,
|
||||
group,
|
||||
add_folder,
|
||||
retry,
|
||||
},
|
||||
)
|
||||
@@ -4391,13 +4494,15 @@ fn run_shared_mutation(ui: &Rc<Ui>, req: Request, done: &'static str, failed: &'
|
||||
|
||||
// ---- Devices page ---------------------------------------------------------
|
||||
|
||||
/// Install the Devices page's retry button.
|
||||
fn wire_devices(ui: &Rc<Ui>, retry: >k4::Button) {
|
||||
/// Install the Devices page's retry button and the "Add Folder" action.
|
||||
fn wire_devices(ui: &Rc<Ui>, retry: >k4::Button, add_folder: >k4::Button) {
|
||||
let ui_retry = ui.clone();
|
||||
retry.connect_clicked(move |_| {
|
||||
service::restart();
|
||||
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.
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
if ui.devices_inflight.get() {
|
||||
return;
|
||||
@@ -4423,35 +4530,64 @@ fn load_devices(ui: &Rc<Ui>) {
|
||||
false,
|
||||
);
|
||||
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();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
ui.devices_inflight.set(false);
|
||||
match result {
|
||||
Ok(Ok(Response::Devices { items })) => {
|
||||
repaint_devices(&ui, &items);
|
||||
ui.devices_loaded_at.set(Some(Instant::now()));
|
||||
}
|
||||
Ok(Ok(Response::Error { message })) => devices_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read devices",
|
||||
&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);
|
||||
}
|
||||
let Ok(Ok(Response::SyncFolders { items })) = result else {
|
||||
return;
|
||||
};
|
||||
// The page may have been navigated away from, or collapsed to a status
|
||||
// view, while the request was in flight.
|
||||
if ui.stack.visible_child_name().as_deref() == Some("devices")
|
||||
&& ui.devices_content.visible_child_name().as_deref() == Some("list")
|
||||
{
|
||||
repaint_sync_folders(&ui, &items);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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]) {
|
||||
for row in ui.devices_rows.borrow_mut().drain(..) {
|
||||
ui.devices_group.remove(&row);
|
||||
}
|
||||
if devices.is_empty() {
|
||||
devices_status(
|
||||
ui,
|
||||
"computer-symbolic",
|
||||
"No devices",
|
||||
"Desktop apps that sync a folder to this account show up here.",
|
||||
false,
|
||||
);
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No other devices")
|
||||
.subtitle("Desktop apps syncing to this account appear here.")
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||
ui.devices_group.add(&row);
|
||||
*ui.devices_rows.borrow_mut() = vec![row.upcast()];
|
||||
return;
|
||||
}
|
||||
ui.devices_content.set_visible_child_name("list");
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
for dev in devices {
|
||||
let row = adw::ActionRow::builder()
|
||||
@@ -4534,6 +4671,263 @@ fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
|
||||
*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(>k4::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(>k4::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.
|
||||
fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
|
||||
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_retry.set_visible(retry);
|
||||
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>) {
|
||||
if ui.activity_inflight.get() {
|
||||
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]) {
|
||||
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(..) {
|
||||
ui.activity_group.remove(&row);
|
||||
}
|
||||
@@ -5101,6 +5540,8 @@ fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) {
|
||||
fn activity_verb(kind: ActivityKind) -> &'static str {
|
||||
match kind {
|
||||
ActivityKind::Upload => "Uploaded",
|
||||
ActivityKind::Download => "Downloaded",
|
||||
ActivityKind::Sync => "Synced",
|
||||
ActivityKind::Rename => "Renamed",
|
||||
ActivityKind::Move => "Moved",
|
||||
ActivityKind::CreateFolder => "Created folder",
|
||||
@@ -5118,6 +5559,8 @@ fn activity_verb(kind: ActivityKind) -> &'static str {
|
||||
fn activity_icon(kind: ActivityKind) -> &'static str {
|
||||
match kind {
|
||||
ActivityKind::Upload => "document-send-symbolic",
|
||||
ActivityKind::Download => "document-save-symbolic",
|
||||
ActivityKind::Sync => "emblem-synchronizing-symbolic",
|
||||
ActivityKind::Rename => "document-edit-symbolic",
|
||||
ActivityKind::Move => "go-jump-symbolic",
|
||||
ActivityKind::CreateFolder => "folder-new-symbolic",
|
||||
|
||||
@@ -18,7 +18,7 @@ use ksni::menu::StandardItem;
|
||||
use ksni::{MenuItem, Tray, TrayService};
|
||||
use pdfs_core::auth;
|
||||
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;
|
||||
|
||||
/// How often the tray re-polls the daemon to refresh its menu.
|
||||
@@ -38,12 +38,18 @@ struct DriveState {
|
||||
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
|
||||
/// menu stays one line regardless of queue depth.
|
||||
fn sync_line(items: &[TransferItem]) -> String {
|
||||
/// menu stays one line regardless of queue depth. With nothing moving bytes, a
|
||||
/// 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 {
|
||||
[] => 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] => {
|
||||
let arrow = match t.direction {
|
||||
TransferDirection::Download => "↓",
|
||||
@@ -87,7 +93,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
mountpoint: PathBuf::from(mountpoint),
|
||||
// Same daemon is up, so a cheap follow-up poll gives the sync line.
|
||||
sync: match send(socket, &Request::GetQueueStatus) {
|
||||
Ok(Response::Transfers { items }) => sync_line(&items),
|
||||
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
|
||||
_ => String::new(),
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user