mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69b5ccb28b | ||
|
|
af84ce2168 | ||
|
|
52651caf8e | ||
|
|
0b63a65661 | ||
|
|
7f84ff7dea | ||
|
|
15985f8684 | ||
|
|
dfc48375e2 | ||
|
|
2dde69525e | ||
|
|
f6136ce344 | ||
|
|
4fa61a14c3 | ||
|
|
fe8fb8cdcf | ||
|
|
f457fa68fc | ||
|
|
be8464f4a0 | ||
|
|
a5d29969dc | ||
|
|
43d1afe705 | ||
|
|
29e5769dec | ||
|
|
0fbc6421ba | ||
|
|
abf76cd5e8 | ||
|
|
8d0a575a7c | ||
|
|
91a5f21340 | ||
|
|
8351382ef2 | ||
|
|
b58b60010e | ||
|
|
a86c3e586d | ||
|
|
fdda35918f | ||
|
|
8bc52edeb4 | ||
|
|
792eee75a4 | ||
|
|
067561564e | ||
|
|
cf9e1665ca | ||
|
|
cc308ac25a | ||
|
|
690b91deab | ||
|
|
9cd8a196aa |
No files matched your search
Generated
+8
-8
@@ -3372,7 +3372,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "1.11.1"
|
||||
version = "2.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3387,7 +3387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "1.11.1"
|
||||
version = "2.0.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dbus-secret-service-keyring-store",
|
||||
@@ -3411,7 +3411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "1.11.1"
|
||||
version = "2.0.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"fuser",
|
||||
@@ -3431,7 +3431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "1.11.1"
|
||||
version = "2.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3668,9 +3668,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46cbe05623d9b73bd48b1473f42d43180142b2ee676b1cb9e9a06b562c60ba26"
|
||||
checksum = "34d587f1ef2ffa9183f48f0cca407c4b641b636be516dde8b13e082de5c83757"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3695,9 +3695,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7310d93bed440dfe4fd60306923f6a491901ee0d618b65ace4e5e22ff0387c1f"
|
||||
checksum = "7decd4f7ec188815192a1d2a16a83f2d52944141c5b8e648184ef17061c9ecce"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.1",
|
||||
"async-trait",
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.11.1"
|
||||
version = "2.0.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
||||
categories = ["filesystem", "command-line-utilities"]
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.6.5" }
|
||||
proton-drive-rs = { version = "0.6.5" }
|
||||
proton-sdk = { version = "0.6.6" }
|
||||
proton-drive-rs = { version = "0.6.6" }
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
|
||||
@@ -32,8 +32,8 @@ sudo apt install ./proton-drive-linux_*.deb
|
||||
# Fedora
|
||||
sudo dnf install ./proton-drive-linux-*.rpm
|
||||
|
||||
# Arch Linux
|
||||
cd packaging && makepkg -fi
|
||||
# Arch Linux (AUR)
|
||||
paru -S proton-drive-for-linux # or -git / -bin
|
||||
```
|
||||
|
||||
Packages for each release are attached to the [latest
|
||||
@@ -431,8 +431,24 @@ Install the debian package via `dpkg` or `apt`:
|
||||
sudo apt install ./proton-drive-linux_*.deb
|
||||
```
|
||||
|
||||
### 2. Arch Linux
|
||||
A local `PKGBUILD` is available under the `packaging/` directory. You can build and install it using:
|
||||
### 2. Arch Linux (AUR)
|
||||
Three AUR packages cover the usual choices; they conflict with each other, so
|
||||
install one:
|
||||
|
||||
| Package | Contents |
|
||||
| --- | --- |
|
||||
| [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) | builds the latest tagged release from source |
|
||||
| [`proton-drive-for-linux-git`](https://aur.archlinux.org/packages/proton-drive-for-linux-git) | builds `main` from git |
|
||||
| [`proton-drive-for-linux-bin`](https://aur.archlinux.org/packages/proton-drive-for-linux-bin) | installs the release binaries, no compile |
|
||||
|
||||
```bash
|
||||
paru -S proton-drive-for-linux # or any AUR helper
|
||||
```
|
||||
|
||||
The package sources live in `packaging/aur/`; see
|
||||
[`packaging/aur/README.md`](packaging/aur/README.md) for how they are updated and
|
||||
published. A local `PKGBUILD` that compiles the working tree in place is still
|
||||
available for development builds:
|
||||
```bash
|
||||
cd packaging && makepkg -fi
|
||||
```
|
||||
|
||||
+454
-23
@@ -13,8 +13,9 @@ use pdfs_core::auth;
|
||||
use pdfs_core::cache::ContentCache;
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
ErrorKind, MountKind, RefreshScope, Request as CtlRequest, Response as CtlResponse,
|
||||
RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase, pending_summary,
|
||||
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
|
||||
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase,
|
||||
pending_summary,
|
||||
};
|
||||
use pdfs_core::db::Db;
|
||||
use pdfs_core::service;
|
||||
@@ -145,11 +146,11 @@ enum Command {
|
||||
/// Skip this many photos from the start of the timeline.
|
||||
#[arg(long, default_value_t = 0)]
|
||||
offset: usize,
|
||||
/// List only favourited photos.
|
||||
/// List only favorited photos.
|
||||
#[arg(long)]
|
||||
favorites: bool,
|
||||
},
|
||||
/// Add or remove a photo's favourite mark.
|
||||
/// Add or remove a photo's favorite mark.
|
||||
Favorite {
|
||||
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
||||
uid: String,
|
||||
@@ -187,7 +188,7 @@ enum Command {
|
||||
/// and rewrites them at the right time.
|
||||
///
|
||||
/// Proton cannot edit a sealed capture time, so a rewrite means re-uploading
|
||||
/// the photo and trashing the original — favourites and albums are carried
|
||||
/// the photo and trashing the original — favorites and albums are carried
|
||||
/// across, and the original goes to Proton's trash, not away. Run it with
|
||||
/// `--dry-run` first.
|
||||
RedatePhotos {
|
||||
@@ -229,6 +230,43 @@ enum Command {
|
||||
#[arg(long, default_value_t = 0)]
|
||||
offset: usize,
|
||||
},
|
||||
/// Create a photo album and print its uid.
|
||||
NewAlbum {
|
||||
/// Album name.
|
||||
name: String,
|
||||
},
|
||||
/// Rename a photo album.
|
||||
RenameAlbum {
|
||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||
uid: String,
|
||||
/// New name.
|
||||
name: String,
|
||||
},
|
||||
/// Delete a photo album. Its photos stay in the timeline.
|
||||
DeleteAlbum {
|
||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||
uid: String,
|
||||
/// Also delete photos that exist only in this album (saved from a
|
||||
/// shared album); without it the server refuses to delete such an album.
|
||||
#[arg(long)]
|
||||
with_photos: bool,
|
||||
},
|
||||
/// Add photos to an album.
|
||||
AddToAlbum {
|
||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||
uid: String,
|
||||
/// Photo node uids (from `pdfs photos`).
|
||||
#[arg(required = true)]
|
||||
photos: Vec<String>,
|
||||
},
|
||||
/// Take photos out of an album. They stay in the timeline.
|
||||
RemoveFromAlbum {
|
||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||
uid: String,
|
||||
/// Photo node uids (from `pdfs album <uid>`).
|
||||
#[arg(required = true)]
|
||||
photos: Vec<String>,
|
||||
},
|
||||
/// Download a photo by uid (`volume~link`) and print its cached path.
|
||||
OpenPhoto {
|
||||
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
||||
@@ -241,6 +279,9 @@ enum Command {
|
||||
/// Max hits to return.
|
||||
#[arg(long, default_value_t = 50)]
|
||||
limit: usize,
|
||||
/// Only search below this folder (under the mountpoint).
|
||||
#[arg(long = "in", value_name = "FOLDER")]
|
||||
scope: Option<PathBuf>,
|
||||
},
|
||||
/// Rename a file or folder via the running daemon.
|
||||
Rename {
|
||||
@@ -327,6 +368,11 @@ enum Command {
|
||||
#[command(subcommand)]
|
||||
action: SyncCmd,
|
||||
},
|
||||
/// List `(sync-conflict …)` copies, or resolve one.
|
||||
Conflicts {
|
||||
#[command(subcommand)]
|
||||
action: Option<ConflictCmd>,
|
||||
},
|
||||
/// Share a file or folder with Proton and/or external email addresses.
|
||||
Share {
|
||||
/// File/folder path, inside the mountpoint or relative to it.
|
||||
@@ -357,14 +403,18 @@ enum Command {
|
||||
/// New role: viewer, editor or admin.
|
||||
role: String,
|
||||
},
|
||||
/// Remove a member or pending invitation from a node's share.
|
||||
/// Stop sharing a node, or remove one member or pending invitation.
|
||||
///
|
||||
/// With only a path, removes the public link, every invitation and every
|
||||
/// member. With an id and kind (from `pdfs members`), removes that entry.
|
||||
Unshare {
|
||||
/// File/folder path, inside the mountpoint or relative to it.
|
||||
path: PathBuf,
|
||||
/// Entry id (membership or invitation id), from `pdfs members`.
|
||||
id: String,
|
||||
#[arg(requires = "kind")]
|
||||
id: Option<String>,
|
||||
/// Entry kind: member, proton or external.
|
||||
kind: ShareKindArg,
|
||||
kind: Option<ShareKindArg>,
|
||||
},
|
||||
/// Inspect and restore a file's earlier versions.
|
||||
Versions {
|
||||
@@ -521,6 +571,22 @@ enum DeviceCmd {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ConflictCmd {
|
||||
/// Decide which side of a conflict to keep. Whatever is removed goes to Trash.
|
||||
Resolve {
|
||||
/// The conflict copy, absolute or relative to the mountpoint.
|
||||
path: PathBuf,
|
||||
/// `original` trashes the copy; `copy` trashes the original and gives the
|
||||
/// copy its name; `both` renames the copy to `--name`.
|
||||
#[arg(long, value_parser = ["original", "copy", "both"])]
|
||||
keep: String,
|
||||
/// New name for the copy with `--keep both`.
|
||||
#[arg(long, required_if_eq("keep", "both"))]
|
||||
name: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum SyncCmd {
|
||||
/// Add a local folder to this machine's device and upload its contents.
|
||||
@@ -543,6 +609,38 @@ enum SyncCmd {
|
||||
/// Optional synced-folder id; omit to reconcile every folder.
|
||||
id: Option<i64>,
|
||||
},
|
||||
/// Pause syncing: queued uploads and mirror folders wait, reads keep working.
|
||||
Pause {
|
||||
/// End the pause by itself after this long, e.g. `30m`, `1h`, `2d`.
|
||||
/// Omit to pause until `pdfs sync resume`.
|
||||
#[arg(long = "for", value_parser = parse_pause_duration, conflicts_with = "folder")]
|
||||
duration: Option<u64>,
|
||||
/// Pause only this mirror folder (id from `sync list`) until resumed.
|
||||
#[arg(long)]
|
||||
folder: Option<i64>,
|
||||
},
|
||||
/// Resume syncing after `pdfs sync pause`.
|
||||
Resume {
|
||||
/// Resume only this folder, paused with `sync pause --folder`.
|
||||
#[arg(long)]
|
||||
folder: Option<i64>,
|
||||
},
|
||||
/// Cap upload and download speed, or show the caps with no options.
|
||||
Limit {
|
||||
/// Upload cap per second, e.g. `500K` or `2M`; `0` removes it.
|
||||
#[arg(long, value_parser = parse_rate)]
|
||||
up: Option<u64>,
|
||||
/// Download cap per second, e.g. `500K` or `2M`; `0` removes it.
|
||||
#[arg(long, value_parser = parse_rate)]
|
||||
down: Option<u64>,
|
||||
},
|
||||
/// List uploads and changes not yet on Proton Drive.
|
||||
Queue,
|
||||
/// Retry a queued operation now (by id from `sync queue`), or every failed one.
|
||||
Retry {
|
||||
/// Queue id; omit to retry every failed operation.
|
||||
id: Option<i64>,
|
||||
},
|
||||
/// Switch a folder between full-copy sync and on-demand (FUSE, no local storage).
|
||||
Mode {
|
||||
/// Synced-folder id (from `sync list`).
|
||||
@@ -557,6 +655,10 @@ enum SyncCmd {
|
||||
/// Accept every proposed local path without asking.
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
/// Restore another computer's folders instead, by device uid (from
|
||||
/// `pdfs devices list`).
|
||||
#[arg(long)]
|
||||
device: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -675,8 +777,28 @@ fn main() -> Result<()> {
|
||||
Command::ImportStatus => cmd_import_status(),
|
||||
Command::CancelImport => cmd_cancel_import(),
|
||||
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
|
||||
Command::NewAlbum { name } => cmd_new_album(name),
|
||||
Command::RenameAlbum { uid, name } => {
|
||||
ok_or_bail(control_request(CtlRequest::RenameAlbum { uid, name })?)
|
||||
}
|
||||
Command::DeleteAlbum { uid, with_photos } => {
|
||||
ok_or_bail(control_request(CtlRequest::DeleteAlbum {
|
||||
uid,
|
||||
delete_photos: with_photos,
|
||||
})?)
|
||||
}
|
||||
Command::AddToAlbum { uid, photos } => {
|
||||
cmd_album_change(CtlRequest::AddToAlbum { uid, photos }, "added")
|
||||
}
|
||||
Command::RemoveFromAlbum { uid, photos } => {
|
||||
cmd_album_change(CtlRequest::RemoveFromAlbum { uid, photos }, "removed")
|
||||
}
|
||||
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
||||
Command::Search { query, limit } => cmd_search(query, limit),
|
||||
Command::Search {
|
||||
query,
|
||||
limit,
|
||||
scope,
|
||||
} => cmd_search(query, limit, scope),
|
||||
Command::Rename { path, new_name } => cmd_rename(path, new_name),
|
||||
Command::Move { path, new_parent } => cmd_move(path, new_parent),
|
||||
Command::Rm { path } => cmd_rm(path),
|
||||
@@ -691,6 +813,7 @@ fn main() -> Result<()> {
|
||||
Command::Refresh { target, full } => cmd_refresh(target, full),
|
||||
Command::Devices { action } => cmd_devices(action),
|
||||
Command::Sync { action } => cmd_sync(action),
|
||||
Command::Conflicts { action } => cmd_conflicts(action),
|
||||
Command::Share {
|
||||
path,
|
||||
emails,
|
||||
@@ -798,6 +921,10 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
||||
Some(pending) => format!("{} → {pending}", f.mode),
|
||||
None => f.mode.clone(),
|
||||
};
|
||||
let mode = match f.paused {
|
||||
true => format!("{mode}, paused"),
|
||||
false => mode,
|
||||
};
|
||||
println!(
|
||||
"[{}] {} ({mode}, {}, synced: {sync})",
|
||||
f.id, f.local_path, f.state
|
||||
@@ -834,10 +961,114 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
||||
})?)?
|
||||
}
|
||||
SyncCmd::Now { id } => ok_or_bail(control_request(CtlRequest::SyncNow { id })?)?,
|
||||
SyncCmd::Pause {
|
||||
folder: Some(id), ..
|
||||
} => ok_or_bail(control_request(CtlRequest::SetSyncFolderPaused {
|
||||
id,
|
||||
paused: true,
|
||||
})?)?,
|
||||
SyncCmd::Resume { folder: Some(id) } => {
|
||||
ok_or_bail(control_request(CtlRequest::SetSyncFolderPaused {
|
||||
id,
|
||||
paused: false,
|
||||
})?)?
|
||||
}
|
||||
SyncCmd::Pause {
|
||||
duration,
|
||||
folder: None,
|
||||
} => {
|
||||
let until = duration.map(|secs| unix_now().saturating_add(secs as i64));
|
||||
ok_or_bail(control_request(CtlRequest::SetSyncPaused {
|
||||
paused: true,
|
||||
until,
|
||||
})?)?
|
||||
}
|
||||
SyncCmd::Queue => return cmd_sync_queue(),
|
||||
SyncCmd::Limit { up, down } => return cmd_sync_limit(up, down),
|
||||
SyncCmd::Retry { id } => ok_or_bail(control_request(CtlRequest::RetryPendingOp { id })?)?,
|
||||
SyncCmd::Resume { folder: None } => {
|
||||
ok_or_bail(control_request(CtlRequest::SetSyncPaused {
|
||||
paused: false,
|
||||
until: None,
|
||||
})?)?
|
||||
}
|
||||
SyncCmd::Mode { id, mode } => {
|
||||
ok_or_bail(control_request(CtlRequest::SetSyncFolderMode { id, mode })?)?
|
||||
}
|
||||
SyncCmd::Restore { yes } => return cmd_sync_restore(yes),
|
||||
SyncCmd::Restore { yes, device } => return cmd_sync_restore(yes, device),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pdfs conflicts`: list conflict copies, or resolve one.
|
||||
fn cmd_conflicts(action: Option<ConflictCmd>) -> Result<()> {
|
||||
if let Some(ConflictCmd::Resolve { path, keep, name }) = action {
|
||||
let keep = match keep.as_str() {
|
||||
"original" => ConflictKeep::Original,
|
||||
"copy" => ConflictKeep::Copy,
|
||||
_ => ConflictKeep::Both {
|
||||
name: name.unwrap_or_default(),
|
||||
},
|
||||
};
|
||||
let path = path_arg(&path)?;
|
||||
return ok_or_bail(control_request(CtlRequest::ResolveConflict { path, keep })?);
|
||||
}
|
||||
let response = control_request(CtlRequest::ListConflicts)?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
match response {
|
||||
CtlResponse::Conflicts { items } if items.is_empty() => println!("No conflicts."),
|
||||
CtlResponse::Conflicts { items } => {
|
||||
for c in items {
|
||||
println!("{}", c.path);
|
||||
let verdict = match (c.original_exists, c.identical) {
|
||||
(false, _) => "original is gone".to_string(),
|
||||
(true, true) => format!("identical to {}", c.original_path),
|
||||
(true, false) => format!(
|
||||
"differs from {} ({} vs {})",
|
||||
c.original_path,
|
||||
human_bytes(c.size),
|
||||
human_bytes(c.original_size.unwrap_or_default()),
|
||||
),
|
||||
};
|
||||
println!(" {verdict}");
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pdfs sync queue`: what has not reached Proton Drive yet, and why.
|
||||
fn cmd_sync_queue() -> Result<()> {
|
||||
let response = control_request(CtlRequest::ListPendingOps)?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
match response {
|
||||
CtlResponse::PendingOps { items } if items.is_empty() => {
|
||||
println!("Nothing queued: everything is on Proton Drive.");
|
||||
}
|
||||
CtlResponse::PendingOps { items } => {
|
||||
let now = unix_now();
|
||||
for op in items {
|
||||
let state = match (op.parked, op.next_attempt_at) {
|
||||
(true, _) => "waiting for its final name".to_string(),
|
||||
(false, Some(at)) if at > now => {
|
||||
format!("next try in {}", human_age((at - now) as u64))
|
||||
}
|
||||
(false, _) => "due now".to_string(),
|
||||
};
|
||||
println!("[{}] {} {} ({state})", op.id, op.kind, op.path);
|
||||
if let Some(error) = &op.last_error {
|
||||
println!(" failed {} time(s): {error}", op.attempts);
|
||||
}
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -848,8 +1079,13 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
||||
/// The daemon only ever *proposes* local paths — a path recorded by another
|
||||
/// machine may name a home directory that does not exist here — so the default
|
||||
/// flow is propose-and-confirm. `--yes` accepts the proposals for scripting.
|
||||
fn cmd_sync_restore(yes: bool) -> Result<()> {
|
||||
let response = control_request(CtlRequest::ListRestorableFolders)?;
|
||||
fn cmd_sync_restore(yes: bool, device: Option<String>) -> Result<()> {
|
||||
let response = control_request(match &device {
|
||||
Some(device) => CtlRequest::ListDeviceRestorableFolders {
|
||||
device: device.clone(),
|
||||
},
|
||||
None => CtlRequest::ListRestorableFolders,
|
||||
})?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -899,8 +1135,12 @@ fn cmd_sync_restore(yes: bool) -> Result<()> {
|
||||
println!("Nothing selected.");
|
||||
return Ok(());
|
||||
}
|
||||
ok_or_bail(control_request(CtlRequest::RestoreSyncFolders {
|
||||
items: chosen,
|
||||
ok_or_bail(control_request(match device {
|
||||
Some(device) => CtlRequest::RestoreDeviceFolders {
|
||||
device,
|
||||
items: chosen,
|
||||
},
|
||||
None => CtlRequest::RestoreSyncFolders { items: chosen },
|
||||
})?)
|
||||
}
|
||||
|
||||
@@ -1038,12 +1278,17 @@ fn cmd_share_role(path: PathBuf, id: String, kind: ShareKindArg, role: String) -
|
||||
})?)
|
||||
}
|
||||
|
||||
fn cmd_unshare(path: PathBuf, id: String, kind: ShareKindArg) -> Result<()> {
|
||||
ok_or_bail(control_request(CtlRequest::RemoveShareEntry {
|
||||
path: path_arg(&path)?,
|
||||
id,
|
||||
kind: kind.to_kind(),
|
||||
})?)
|
||||
fn cmd_unshare(path: PathBuf, id: Option<String>, kind: Option<ShareKindArg>) -> Result<()> {
|
||||
let path = path_arg(&path)?;
|
||||
let request = match (id, kind) {
|
||||
(Some(id), Some(kind)) => CtlRequest::RemoveShareEntry {
|
||||
path,
|
||||
id,
|
||||
kind: kind.to_kind(),
|
||||
},
|
||||
_ => CtlRequest::StopSharing { path },
|
||||
};
|
||||
ok_or_bail(control_request(request)?)
|
||||
}
|
||||
|
||||
fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
|
||||
@@ -1472,9 +1717,15 @@ fn cmd_status() -> Result<()> {
|
||||
failing_error,
|
||||
staged_bytes,
|
||||
staged_oldest_secs,
|
||||
paused,
|
||||
paused_until,
|
||||
..
|
||||
}) => {
|
||||
let state = if online { "" } else { ", offline" };
|
||||
let state = match (online, paused) {
|
||||
(_, true) => ", sync paused",
|
||||
(false, false) => ", offline",
|
||||
(true, false) => "",
|
||||
};
|
||||
let queued = match pending_summary(pending_uploads, pending_changes) {
|
||||
Some(s) => format!(", {s}"),
|
||||
None => String::new(),
|
||||
@@ -1490,6 +1741,10 @@ fn cmd_status() -> Result<()> {
|
||||
human_age(staged_oldest_secs),
|
||||
);
|
||||
}
|
||||
if let Some(until) = paused_until {
|
||||
let left = until.saturating_sub(unix_now()).max(0) as u64;
|
||||
println!("Paused sync resumes in {}", human_age(left.max(1)));
|
||||
}
|
||||
if parked_uploads > 0 {
|
||||
println!("Parked {parked_uploads} upload(s) waiting for a final filename");
|
||||
}
|
||||
@@ -1682,6 +1937,8 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
||||
pdfs_fuse::MountOptions {
|
||||
username,
|
||||
sweep_mode: config.resolved_conflict_sweep(),
|
||||
upload_limit: config.upload_limit.unwrap_or(0),
|
||||
download_limit: config.download_limit.unwrap_or(0),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1886,6 +2143,42 @@ fn cmd_albums() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_new_album(name: String) -> Result<()> {
|
||||
match control_request(CtlRequest::CreateAlbum { name })? {
|
||||
CtlResponse::AlbumCreated { uid } => println!("{uid}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send an album add or remove and report it per photo; any failed photo
|
||||
/// fails the command.
|
||||
fn cmd_album_change(request: CtlRequest, verb: &str) -> Result<()> {
|
||||
match control_request(request)? {
|
||||
CtlResponse::AlbumChanged { changed, failed } => {
|
||||
println!("{} {verb}", photo_count(changed.len()));
|
||||
for failure in &failed {
|
||||
eprintln!("{}: {}", failure.uid, failure.message);
|
||||
}
|
||||
if !failed.is_empty() {
|
||||
bail!("{} failed", photo_count(failed.len()));
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn photo_count(n: usize) -> String {
|
||||
if n == 1 {
|
||||
"1 photo".to_string()
|
||||
} else {
|
||||
format!("{n} photos")
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_import_google_photos(archives: Vec<PathBuf>, dry_run: bool, wait: bool) -> Result<()> {
|
||||
// The daemon opens the archives itself off the shared filesystem, so it gets
|
||||
// absolute paths — the same arrangement `pdfs upload` uses.
|
||||
@@ -2112,8 +2405,13 @@ fn cmd_open_photo(uid: String) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_search(query: String, limit: usize) -> Result<()> {
|
||||
match control_request(CtlRequest::Search { query, limit })? {
|
||||
fn cmd_search(query: String, limit: usize, scope: Option<PathBuf>) -> Result<()> {
|
||||
let scope = scope.map(|p| path_arg(&p)).transpose()?;
|
||||
match control_request(CtlRequest::Search {
|
||||
query,
|
||||
limit,
|
||||
scope,
|
||||
})? {
|
||||
CtlResponse::SearchResults { hits } if hits.is_empty() => println!("(no matches)"),
|
||||
CtlResponse::SearchResults { hits } => {
|
||||
for h in hits {
|
||||
@@ -2384,6 +2682,72 @@ fn path_arg(path: &Path) -> Result<String> {
|
||||
|
||||
/// Format an age in seconds for a report line, coarsely — the point of the
|
||||
/// number is whether it is minutes or weeks.
|
||||
/// Parse a `sync pause --for` duration: a whole number followed by `m`, `h` or
|
||||
/// `d`. Returns seconds.
|
||||
fn parse_pause_duration(text: &str) -> Result<u64, String> {
|
||||
let text = text.trim();
|
||||
let split = text.char_indices().last().map_or(0, |(at, _)| at);
|
||||
let (number, unit) = text.split_at(split);
|
||||
let per_unit: u64 = match unit {
|
||||
"m" => 60,
|
||||
"h" => 3600,
|
||||
"d" => 86_400,
|
||||
_ => return Err("use a number followed by m, h or d, e.g. 30m".to_string()),
|
||||
};
|
||||
match number.parse::<u64>() {
|
||||
Ok(n) if n > 0 => Ok(n.saturating_mul(per_unit)),
|
||||
_ => Err("use a positive whole number, e.g. 30m".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a bytes-per-second rate: a whole number with an optional `K`, `M` or
|
||||
/// `G` suffix in the same binary units `human_bytes` prints.
|
||||
fn parse_rate(text: &str) -> Result<u64, String> {
|
||||
let text = text.trim();
|
||||
let (number, scale) = match text.char_indices().last() {
|
||||
Some((at, 'K' | 'k')) => (&text[..at], 1024),
|
||||
Some((at, 'M' | 'm')) => (&text[..at], 1_048_576),
|
||||
Some((at, 'G' | 'g')) => (&text[..at], 1_073_741_824),
|
||||
_ => (text, 1),
|
||||
};
|
||||
number
|
||||
.parse::<u64>()
|
||||
.map(|n| n.saturating_mul(scale))
|
||||
.map_err(|_| "use a whole number with an optional K, M or G, e.g. 2M".to_string())
|
||||
}
|
||||
|
||||
fn human_rate(rate: u64) -> String {
|
||||
if rate == 0 {
|
||||
"unlimited".to_string()
|
||||
} else {
|
||||
format!("{}/s", human_bytes(rate))
|
||||
}
|
||||
}
|
||||
|
||||
/// `pdfs sync limit`: set the bandwidth caps, keeping whichever one is not
|
||||
/// given, or print both when neither is.
|
||||
fn cmd_sync_limit(up: Option<u64>, down: Option<u64>) -> Result<()> {
|
||||
let config = AppDirs::new()?.load_config();
|
||||
let upload = up.or(config.upload_limit).unwrap_or(0);
|
||||
let download = down.or(config.download_limit).unwrap_or(0);
|
||||
if up.is_none() && down.is_none() {
|
||||
println!("Upload {}", human_rate(upload));
|
||||
println!("Download {}", human_rate(download));
|
||||
return Ok(());
|
||||
}
|
||||
ok_or_bail(control_request(CtlRequest::SetBandwidthLimits {
|
||||
upload,
|
||||
download,
|
||||
})?)
|
||||
}
|
||||
|
||||
fn unix_now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn human_age(secs: u64) -> String {
|
||||
const UNITS: [(&str, u64); 4] = [("d", 86_400), ("h", 3_600), ("m", 60), ("s", 1)];
|
||||
for (unit, scale) in UNITS {
|
||||
@@ -2847,6 +3211,40 @@ fn control_request(req: CtlRequest) -> Result<CtlResponse> {
|
||||
.with_context(|| format!("talk to mount daemon at {}", socket.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pause_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pause_durations_read_minutes_hours_and_days() {
|
||||
assert_eq!(parse_pause_duration("30m"), Ok(1800));
|
||||
assert_eq!(parse_pause_duration("1h"), Ok(3600));
|
||||
assert_eq!(parse_pause_duration("2d"), Ok(172_800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_durations_reject_what_they_cannot_read() {
|
||||
assert!(parse_pause_duration("").is_err());
|
||||
assert!(parse_pause_duration("0h").is_err());
|
||||
assert!(parse_pause_duration("1w").is_err());
|
||||
assert!(parse_pause_duration("h").is_err());
|
||||
assert!(parse_pause_duration("-1h").is_err());
|
||||
assert!(parse_pause_duration("1ä").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rates_read_plain_bytes_and_binary_suffixes() {
|
||||
assert_eq!(parse_rate("0"), Ok(0));
|
||||
assert_eq!(parse_rate("1500"), Ok(1500));
|
||||
assert_eq!(parse_rate("500K"), Ok(512_000));
|
||||
assert_eq!(parse_rate("2m"), Ok(2_097_152));
|
||||
assert!(parse_rate("").is_err());
|
||||
assert!(parse_rate("M").is_err());
|
||||
assert!(parse_rate("1.5M").is_err());
|
||||
assert!(parse_rate("2MB").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod diagnose_tests {
|
||||
use super::*;
|
||||
@@ -2977,3 +3375,36 @@ mod login_wait_tests {
|
||||
assert!(shared_by_line(&entry).unwrap().contains("WARNING"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod unshare_tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn unshare_with_only_a_path_stops_sharing() {
|
||||
let cli = Cli::try_parse_from(["pdfs", "unshare", "docs/report.pdf"]).unwrap();
|
||||
assert!(matches!(
|
||||
cli.command,
|
||||
Command::Unshare {
|
||||
id: None,
|
||||
kind: None,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unshare_with_an_id_needs_its_kind() {
|
||||
assert!(Cli::try_parse_from(["pdfs", "unshare", "docs", "abc"]).is_err());
|
||||
let cli = Cli::try_parse_from(["pdfs", "unshare", "docs", "abc", "member"]).unwrap();
|
||||
assert!(matches!(
|
||||
cli.command,
|
||||
Command::Unshare {
|
||||
id: Some(_),
|
||||
kind: Some(_),
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -298,6 +298,10 @@ pub struct Pin {
|
||||
/// which is why it is defaulted.
|
||||
#[serde(default)]
|
||||
pub is_dir: Option<bool>,
|
||||
/// A pinned file whose content is on disk right now. The daemon fills it
|
||||
/// in when it answers `ListPins`; folders and older daemons say `false`.
|
||||
#[serde(default)]
|
||||
pub cached: bool,
|
||||
}
|
||||
|
||||
/// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into
|
||||
@@ -1854,6 +1858,7 @@ impl ContentCache {
|
||||
path: row.path,
|
||||
recursive: row.recursive,
|
||||
is_dir: row.is_dir,
|
||||
cached: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -85,6 +85,15 @@ pub struct AppConfig {
|
||||
/// writes an explicit value here. Defaulted for configs predating the field.
|
||||
#[serde(default)]
|
||||
pub cache_budget: Option<u64>,
|
||||
/// Upload cap in bytes per second shared by every upload. `None` or `0`
|
||||
/// means no cap. Defaulted for configs predating the field.
|
||||
#[serde(default)]
|
||||
pub upload_limit: Option<u64>,
|
||||
/// Download cap in bytes per second shared by every download, FUSE reads
|
||||
/// included. `None` or `0` means no cap. Defaulted for configs predating
|
||||
/// the field.
|
||||
#[serde(default)]
|
||||
pub download_limit: Option<u64>,
|
||||
/// Mountpoint the daemon mounts at. `None` means
|
||||
/// [`AppDirs::default_mountpoint`]; the Settings page writes an explicit
|
||||
/// path here. Defaulted for configs predating the field.
|
||||
@@ -119,6 +128,73 @@ pub struct AppConfig {
|
||||
/// field.
|
||||
#[serde(default)]
|
||||
pub prompt: Option<PromptConfig>,
|
||||
/// Paint the desktop app in Proton purple instead of the system accent
|
||||
/// colour. `None` means "follow the system". Defaulted for configs
|
||||
/// predating the field.
|
||||
#[serde(default)]
|
||||
pub proton_accent: Option<bool>,
|
||||
/// How the desktop app's My Files page lays out and orders a folder.
|
||||
/// Defaulted for configs predating the field.
|
||||
#[serde(default)]
|
||||
pub files_view: FilesView,
|
||||
/// The user hid the tray icon from its own menu. The tray exits at start
|
||||
/// while this is set, and the app does not spawn it; Preferences turns it
|
||||
/// back on. Defaulted for configs predating the field.
|
||||
#[serde(default)]
|
||||
pub tray_hidden: bool,
|
||||
}
|
||||
|
||||
/// The My Files page's view choices, remembered between runs.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct FilesView {
|
||||
/// Columns instead of the icon grid.
|
||||
pub list: bool,
|
||||
pub sort: FileSort,
|
||||
pub descending: bool,
|
||||
/// Folders ahead of files, whatever the sort.
|
||||
pub folders_first: bool,
|
||||
}
|
||||
|
||||
impl Default for FilesView {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
list: false,
|
||||
sort: FileSort::Name,
|
||||
descending: false,
|
||||
folders_first: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The key a My Files listing is ordered by.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FileSort {
|
||||
#[default]
|
||||
Name,
|
||||
Size,
|
||||
Modified,
|
||||
}
|
||||
|
||||
impl FileSort {
|
||||
/// The name used in the desktop app's actions and the config file.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
FileSort::Name => "name",
|
||||
FileSort::Size => "size",
|
||||
FileSort::Modified => "modified",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"name" => Some(FileSort::Name),
|
||||
"size" => Some(FileSort::Size),
|
||||
"modified" => Some(FileSort::Modified),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -127,12 +203,17 @@ impl Default for AppConfig {
|
||||
app_version: APP_VERSION.to_string(),
|
||||
user_agent: USER_AGENT.to_string(),
|
||||
cache_budget: None,
|
||||
upload_limit: None,
|
||||
download_limit: None,
|
||||
mountpoint: None,
|
||||
ignore_patterns: None,
|
||||
device_uid: None,
|
||||
conflict_sweep: None,
|
||||
open_with: None,
|
||||
prompt: None,
|
||||
proton_accent: None,
|
||||
files_view: FilesView::default(),
|
||||
tray_hidden: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -471,17 +552,42 @@ mod tests {
|
||||
app_version: "external-drive-test-client@1.0.0".to_string(),
|
||||
user_agent: "test-agent/1.0".to_string(),
|
||||
cache_budget: Some(1234),
|
||||
upload_limit: Some(500_000),
|
||||
download_limit: None,
|
||||
mountpoint: Some("/tmp/x".to_string()),
|
||||
ignore_patterns: Some(vec!["build/".to_string()]),
|
||||
device_uid: Some("dev-uid".to_string()),
|
||||
conflict_sweep: Some(SweepMode::Enforce),
|
||||
open_with: None,
|
||||
prompt: None,
|
||||
proton_accent: Some(true),
|
||||
files_view: FilesView {
|
||||
list: true,
|
||||
sort: FileSort::Modified,
|
||||
descending: true,
|
||||
folders_first: false,
|
||||
},
|
||||
tray_hidden: true,
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
|
||||
assert_eq!(decoded.user_agent, "test-agent/1.0");
|
||||
assert_eq!(decoded.proton_accent, Some(true));
|
||||
assert!(decoded.tray_hidden);
|
||||
assert_eq!(decoded.upload_limit, Some(500_000));
|
||||
assert_eq!(decoded.download_limit, None);
|
||||
assert_eq!(decoded.files_view, config.files_view);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_without_a_files_view_gets_the_default() {
|
||||
let decoded: AppConfig =
|
||||
serde_json::from_str(r#"{"app_version":"a","user_agent":"b"}"#).unwrap();
|
||||
assert_eq!(decoded.files_view, FilesView::default());
|
||||
let partial: FilesView = serde_json::from_str(r#"{"sort":"size"}"#).unwrap();
|
||||
assert_eq!(partial.sort, FileSort::Size);
|
||||
assert!(partial.folders_first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -554,6 +660,7 @@ mod tests {
|
||||
let json = r#"{"app_version":"v","user_agent":"u"}"#;
|
||||
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(decoded.ignore_patterns.is_none());
|
||||
assert!(!decoded.tray_hidden, "an old config keeps the tray icon");
|
||||
assert_eq!(
|
||||
decoded.resolved_ignore_patterns().len(),
|
||||
DEFAULT_IGNORE_PATTERNS.len(),
|
||||
|
||||
@@ -63,7 +63,7 @@ pub enum Request {
|
||||
/// timeline. Like `kind`, the offset is relative to the filtered set.
|
||||
#[serde(default)]
|
||||
range: Option<(i64, i64)>,
|
||||
/// Restrict the page to favourited photos. Older front-ends omit it and
|
||||
/// Restrict the page to favorited photos. Older front-ends omit it and
|
||||
/// get the whole timeline, as before.
|
||||
#[serde(default)]
|
||||
favorites: bool,
|
||||
@@ -94,6 +94,25 @@ pub enum Request {
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
},
|
||||
/// Create an album named `name`. Replies with [`Response::AlbumCreated`].
|
||||
CreateAlbum { name: String },
|
||||
/// Rename the album `uid`. Replies with [`Response::Ok`].
|
||||
RenameAlbum { uid: String, name: String },
|
||||
/// Delete the album `uid`. Its photos stay in the timeline; with
|
||||
/// `delete_photos` set, photos that exist only in the album (saved from a
|
||||
/// shared album) are deleted with it, which the server otherwise refuses.
|
||||
/// Replies with [`Response::Ok`].
|
||||
DeleteAlbum {
|
||||
uid: String,
|
||||
#[serde(default)]
|
||||
delete_photos: bool,
|
||||
},
|
||||
/// Add timeline photos to the album `uid`. Replies with
|
||||
/// [`Response::AlbumChanged`].
|
||||
AddToAlbum { uid: String, photos: Vec<String> },
|
||||
/// Take photos out of the album `uid`. They stay in the timeline: this is
|
||||
/// not a trash. Replies with [`Response::AlbumChanged`].
|
||||
RemoveFromAlbum { uid: String, photos: Vec<String> },
|
||||
/// Fetch thumbnails for the given photo uids, downloading the ones not
|
||||
/// already cached (one batched round-trip) and replying with their on-disk
|
||||
/// paths. Keep the batch small — it is served on demand, as tiles scroll in.
|
||||
@@ -181,7 +200,7 @@ pub enum Request {
|
||||
/// time by more than a day: `IMG-20230219-WA0001.jpg` filed under 2026 is
|
||||
/// the signature of an import whose metadata sidecars did not match.
|
||||
///
|
||||
/// Favourites and album memberships are carried across; the original is
|
||||
/// Favorites and album memberships are carried across; the original is
|
||||
/// **trashed**, not deleted, so a bad run is recoverable from Proton's
|
||||
/// trash. With `dry_run` nothing is uploaded or trashed and the report says
|
||||
/// what would have been done.
|
||||
@@ -245,8 +264,15 @@ pub enum Request {
|
||||
uid: Option<String>,
|
||||
},
|
||||
/// Full-text search node names against the daemon's local metadata index.
|
||||
/// `limit` caps the number of hits returned. Replies with [`Response::SearchResults`].
|
||||
Search { query: String, limit: usize },
|
||||
/// `limit` caps the number of hits returned. `scope`, a mountpoint-relative
|
||||
/// folder, restricts hits to that subtree; older front-ends omit it and
|
||||
/// search everything. Replies with [`Response::SearchResults`].
|
||||
Search {
|
||||
query: String,
|
||||
limit: usize,
|
||||
#[serde(default)]
|
||||
scope: Option<String>,
|
||||
},
|
||||
/// Search the daemon's index of *local* (non-Drive) files on this machine.
|
||||
/// Independent of [`Request::Search`] so a front-end can fire both at once and
|
||||
/// render whichever lands first. Replies with [`Response::LocalResults`].
|
||||
@@ -299,6 +325,10 @@ pub enum Request {
|
||||
/// persist it to config so the next mount keeps it. Replies with
|
||||
/// [`Response::Ok`].
|
||||
SetCacheBudget { bytes: u64 },
|
||||
/// Cap uploads and downloads at these bytes per second (`0` = no cap),
|
||||
/// live for running transfers, and persist both to config so the next
|
||||
/// mount keeps them. Replies with [`Response::Ok`].
|
||||
SetBandwidthLimits { upload: u64, download: u64 },
|
||||
/// Report on the health of the metadata database and content cache: sizes,
|
||||
/// row counts, and — when `deep` — SQLite's own integrity check. Replies
|
||||
/// with [`Response::CacheReport`].
|
||||
@@ -381,9 +411,31 @@ pub enum Request {
|
||||
/// Switch a synced folder between `mirror` and `ondemand` (Phase 3). Replies
|
||||
/// with [`Response::Ok`].
|
||||
SetSyncFolderMode { id: i64, mode: String },
|
||||
/// Pause or resume one mirror folder's reconcile, independent of the
|
||||
/// global pause. Persisted until resumed. Replies with [`Response::Ok`].
|
||||
SetSyncFolderPaused { id: i64, paused: bool },
|
||||
/// Force a reconcile pass: one folder by id, or all when `id` is `None`.
|
||||
/// Replies with [`Response::Ok`].
|
||||
SyncNow { id: Option<i64> },
|
||||
/// Pause or resume syncing: queued uploads and changes stop draining and
|
||||
/// mirror folders stop reconciling, while reads through the mount keep
|
||||
/// working. `until` is the Unix second a pause ends by itself; `None`
|
||||
/// pauses until resumed. Ignored when resuming. Replies with
|
||||
/// [`Response::Ok`].
|
||||
SetSyncPaused { paused: bool, until: Option<i64> },
|
||||
/// List the queue of uploads and changes not yet on the remote. Replies
|
||||
/// with [`Response::PendingOps`].
|
||||
ListPendingOps,
|
||||
/// Retry a backed-off queued op now instead of waiting out its backoff;
|
||||
/// every failed op when `id` is `None`. Replies with [`Response::Ok`].
|
||||
RetryPendingOp { id: Option<i64> },
|
||||
/// List the `(sync-conflict …)` copies under My Files. Replies with
|
||||
/// [`Response::Conflicts`].
|
||||
ListConflicts,
|
||||
/// Resolve one conflict copy (`path` mountpoint-relative or absolute).
|
||||
/// Everything removed goes to the Proton trash. Replies with
|
||||
/// [`Response::Ok`].
|
||||
ResolveConflict { path: String, keep: ConflictKeep },
|
||||
/// List the folders under this machine's device that can be synced here,
|
||||
/// each with a proposed local path (features.md 5.2). Replies with
|
||||
/// [`Response::RestorableFolders`].
|
||||
@@ -391,6 +443,18 @@ pub enum Request {
|
||||
/// Attach the given remote device folders to local paths and sync them down.
|
||||
/// Replies with [`Response::Ok`].
|
||||
RestoreSyncFolders { items: Vec<RestoreItem> },
|
||||
/// [`Request::ListRestorableFolders`] for another registered device, by
|
||||
/// uid, so its folders can be synced down to this machine. Replies with
|
||||
/// [`Response::RestorableFolders`].
|
||||
ListDeviceRestorableFolders { device: String },
|
||||
/// [`Request::RestoreSyncFolders`] for folders under another device. A
|
||||
/// separate variant rather than an optional field: an older daemon that
|
||||
/// ignored the field would attach the folders under this machine's device
|
||||
/// share instead. Replies with [`Response::Ok`].
|
||||
RestoreDeviceFolders {
|
||||
device: String,
|
||||
items: Vec<RestoreItem>,
|
||||
},
|
||||
|
||||
// ---- sharing a node ---------------------------------------------------
|
||||
/// Invite `emails` (Proton and/or external addresses, auto-detected) to the
|
||||
@@ -466,6 +530,13 @@ pub enum Request {
|
||||
RemovePublicLink { path: String, id: String },
|
||||
/// The by-uid twin of [`Request::RemovePublicLink`].
|
||||
RemovePublicLinkByUid { uid: String, id: String },
|
||||
/// Stop sharing the node at `path`: remove its public link, every pending
|
||||
/// invitation and every member. Each removal is attempted even if an
|
||||
/// earlier one fails. Replies with [`Response::Ok`], or an error naming
|
||||
/// what is still shared.
|
||||
StopSharing { path: String },
|
||||
/// The by-uid twin of [`Request::StopSharing`].
|
||||
StopSharingByUid { uid: String },
|
||||
|
||||
// ---- revisions --------------------------------------------------------
|
||||
/// List the version history of the file at mountpoint-relative `path`,
|
||||
@@ -626,6 +697,60 @@ pub struct DeviceInfo {
|
||||
pub adopted: bool,
|
||||
}
|
||||
|
||||
/// One `(sync-conflict …)` copy and the file it is a copy of (in
|
||||
/// [`Response::Conflicts`]).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ConflictInfo {
|
||||
/// Mountpoint-relative path of the copy.
|
||||
pub path: String,
|
||||
/// Mountpoint-relative path of the file it is a copy of.
|
||||
pub original_path: String,
|
||||
/// False when the original is gone and only the copy is left.
|
||||
pub original_exists: bool,
|
||||
pub size: u64,
|
||||
/// Unix seconds.
|
||||
pub modified: i64,
|
||||
pub original_size: Option<u64>,
|
||||
pub original_modified: Option<i64>,
|
||||
/// Same size and content hash as the original: keeping either loses nothing.
|
||||
pub identical: bool,
|
||||
}
|
||||
|
||||
/// Which side of a conflict to keep (in [`Request::ResolveConflict`]).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ConflictKeep {
|
||||
/// Keep the original; the copy goes to Trash.
|
||||
Original,
|
||||
/// Keep the copy under the original's name; the original goes to Trash.
|
||||
Copy,
|
||||
/// Keep both; the copy is renamed to `name`.
|
||||
Both { name: String },
|
||||
}
|
||||
|
||||
/// One queued upload or change (in [`Response::PendingOps`]).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct PendingOpInfo {
|
||||
/// Queue row id, for [`Request::RetryPendingOp`].
|
||||
pub id: i64,
|
||||
/// `revision`, `create`, `mkdir`, `rename` or `trash`.
|
||||
pub kind: String,
|
||||
/// Mountpoint-relative path of the file or folder, or its name when the
|
||||
/// path is no longer known.
|
||||
pub path: String,
|
||||
/// Failed attempts so far.
|
||||
pub attempts: i64,
|
||||
/// The latest failure, when there has been one.
|
||||
pub last_error: Option<String>,
|
||||
/// Unix second the op was queued.
|
||||
pub queued_at: i64,
|
||||
/// Unix second of the next attempt; `None` while parked.
|
||||
pub next_attempt_at: Option<i64>,
|
||||
/// Held back until a transient file gets its final name.
|
||||
pub parked: bool,
|
||||
/// Failed often enough to count as stuck.
|
||||
pub failing: bool,
|
||||
}
|
||||
|
||||
/// One synced local folder on this machine's device (in [`Response::SyncFolders`]).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct SyncFolderInfo {
|
||||
@@ -651,6 +776,9 @@ pub struct SyncFolderInfo {
|
||||
/// running. Live daemon state, not a stored column.
|
||||
#[serde(default)]
|
||||
pub progress: Option<SyncProgress>,
|
||||
/// The folder is paused on its own (see [`Request::SetSyncFolderPaused`]).
|
||||
#[serde(default)]
|
||||
pub paused: bool,
|
||||
}
|
||||
|
||||
/// A folder under this machine's device offered for restore (in
|
||||
@@ -976,6 +1104,10 @@ pub struct SearchHit {
|
||||
pub size: u64,
|
||||
pub modified: i64,
|
||||
pub pinned: bool,
|
||||
/// Whether a valid copy of the content is in the local cache. Legacy
|
||||
/// daemons omit it; clients then treat the file as online-only.
|
||||
#[serde(default)]
|
||||
pub cached: bool,
|
||||
/// Node uid in `volume~link` form.
|
||||
pub uid: String,
|
||||
/// Absolute path through the most specific active on-demand mount, when the
|
||||
@@ -1308,8 +1440,8 @@ pub struct PhotoItem {
|
||||
/// the split omit it; a front-end then treats everything as a still photo.
|
||||
#[serde(default = "default_photo_kind")]
|
||||
pub kind: PhotoKind,
|
||||
/// Whether the photo is favourited. Older daemons omit it, which a front-end
|
||||
/// reads as "not favourited" — the same thing it would show for a photo whose
|
||||
/// Whether the photo is favorited. Older daemons omit it, which a front-end
|
||||
/// reads as "not favorited" — the same thing it would show for a photo whose
|
||||
/// tags it has not learned yet.
|
||||
#[serde(default)]
|
||||
pub favorite: bool,
|
||||
@@ -1558,6 +1690,13 @@ pub enum Response {
|
||||
/// Age of the oldest staged write, in seconds.
|
||||
#[serde(default)]
|
||||
staged_oldest_secs: u64,
|
||||
/// True while the user has sync paused (see [`Request::SetSyncPaused`]).
|
||||
#[serde(default)]
|
||||
paused: bool,
|
||||
/// The Unix second a timed pause ends; `None` when paused until
|
||||
/// resumed, or not paused.
|
||||
#[serde(default)]
|
||||
paused_until: Option<i64>,
|
||||
},
|
||||
/// A human-readable success message.
|
||||
Ok { message: String },
|
||||
@@ -1644,6 +1783,15 @@ pub enum Response {
|
||||
trashed: Vec<String>,
|
||||
failed: Vec<TrashFailure>,
|
||||
},
|
||||
/// The album a [`Request::CreateAlbum`] made.
|
||||
AlbumCreated { uid: String },
|
||||
/// Outcome of a [`Request::AddToAlbum`] or [`Request::RemoveFromAlbum`],
|
||||
/// per photo like [`Response::Trashed`]: `changed` photos were added or
|
||||
/// removed, `failed` ones were not.
|
||||
AlbumChanged {
|
||||
changed: Vec<String>,
|
||||
failed: Vec<TrashFailure>,
|
||||
},
|
||||
/// A [`Request::FileThumbs`] generation is no longer current. This is
|
||||
/// retryable after reserving a fresh generation and must never be interpreted
|
||||
/// as a permanent "no thumbnail" verdict.
|
||||
@@ -1687,6 +1835,10 @@ pub enum Response {
|
||||
Locations { items: Vec<MountSpec> },
|
||||
/// This device's synced folders (reply to [`Request::ListSyncFolders`]).
|
||||
SyncFolders { items: Vec<SyncFolderInfo> },
|
||||
/// The upload/change queue (reply to [`Request::ListPendingOps`]).
|
||||
PendingOps { items: Vec<PendingOpInfo> },
|
||||
/// Conflict copies (reply to [`Request::ListConflicts`]).
|
||||
Conflicts { items: Vec<ConflictInfo> },
|
||||
/// Folders offered for restore (reply to [`Request::ListRestorableFolders`]).
|
||||
RestorableFolders { items: Vec<RestorableFolder> },
|
||||
/// A node's share: members + pending invitations, and its public link if any
|
||||
@@ -2133,6 +2285,7 @@ mod tests {
|
||||
size: 12,
|
||||
modified: 123,
|
||||
pinned: true,
|
||||
cached: false,
|
||||
uid: "vol~link".into(),
|
||||
mounted_path: Some("/home/me/Videos/report.pdf".into()),
|
||||
score: 985,
|
||||
@@ -2243,7 +2396,7 @@ mod tests {
|
||||
serde_json::from_str(r#"{"Search":{"query":"old","limit":20}}"#).unwrap();
|
||||
assert!(matches!(
|
||||
drive,
|
||||
Request::Search { query, limit } if query == "old" && limit == 20
|
||||
Request::Search { query, limit, scope: None } if query == "old" && limit == 20
|
||||
));
|
||||
|
||||
let local: Request =
|
||||
@@ -2254,6 +2407,15 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_album_keeps_photos_unless_asked() {
|
||||
let request: Request = serde_json::from_str(r#"{"DeleteAlbum":{"uid":"v~a"}}"#).unwrap();
|
||||
assert!(matches!(
|
||||
request,
|
||||
Request::DeleteAlbum { uid, delete_photos: false } if uid == "v~a"
|
||||
));
|
||||
}
|
||||
|
||||
/// The trash requests carry uids rather than paths; they must survive the same
|
||||
/// line-delimited round-trip, since a mangled uid would restore or destroy the
|
||||
/// wrong node.
|
||||
@@ -2320,6 +2482,14 @@ mod tests {
|
||||
id: 3,
|
||||
mode: "ondemand".into(),
|
||||
},
|
||||
Request::SetSyncFolderPaused {
|
||||
id: 3,
|
||||
paused: true,
|
||||
},
|
||||
Request::SetBandwidthLimits {
|
||||
upload: 1_000_000,
|
||||
download: 0,
|
||||
},
|
||||
Request::SyncNow { id: Some(3) },
|
||||
Request::AdoptDevice {
|
||||
uid: Some("dev-1".into()),
|
||||
@@ -2333,6 +2503,17 @@ mod tests {
|
||||
mode: "mirror".into(),
|
||||
}],
|
||||
},
|
||||
Request::ListDeviceRestorableFolders {
|
||||
device: "dev-2".into(),
|
||||
},
|
||||
Request::RestoreDeviceFolders {
|
||||
device: "dev-2".into(),
|
||||
items: vec![RestoreItem {
|
||||
remote_uid: "vol~link".into(),
|
||||
local_path: "/home/me/Work".into(),
|
||||
mode: "ondemand".into(),
|
||||
}],
|
||||
},
|
||||
Request::ShareNode {
|
||||
path: "a/b".into(),
|
||||
emails: vec!["x@proton.me".into(), "y@example.com".into()],
|
||||
@@ -2441,6 +2622,7 @@ mod tests {
|
||||
pending_mode: None,
|
||||
mounted: true,
|
||||
progress: None,
|
||||
paused: false,
|
||||
},
|
||||
MountSpec {
|
||||
id: 2,
|
||||
@@ -2455,6 +2637,7 @@ mod tests {
|
||||
pending_mode: Some(MountMode::OnDemand),
|
||||
mounted: false,
|
||||
progress: None,
|
||||
paused: false,
|
||||
},
|
||||
MountSpec {
|
||||
id: 3,
|
||||
@@ -2476,6 +2659,7 @@ mod tests {
|
||||
total: 5,
|
||||
current: "report.pdf".into(),
|
||||
}),
|
||||
paused: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -2593,7 +2777,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A front-end that predates the favourites filter sends no `favorites`
|
||||
/// A front-end that predates the favorites filter sends no `favorites`
|
||||
/// field, and must keep getting the whole timeline rather than an empty page.
|
||||
#[test]
|
||||
fn timeline_requests_without_the_favorites_filter_still_decode() {
|
||||
@@ -2731,6 +2915,12 @@ mod tests {
|
||||
},
|
||||
"RemovePublicLinkByUid",
|
||||
),
|
||||
(
|
||||
Request::StopSharingByUid {
|
||||
uid: "vol~link".into(),
|
||||
},
|
||||
"StopSharingByUid",
|
||||
),
|
||||
];
|
||||
|
||||
for (request, variant) in cases {
|
||||
@@ -2749,7 +2939,8 @@ mod tests {
|
||||
| Request::UpdateShareRoleByUid { uid, .. }
|
||||
| Request::RemoveShareEntryByUid { uid, .. }
|
||||
| Request::CreatePublicLinkByUid { uid, .. }
|
||||
| Request::RemovePublicLinkByUid { uid, .. } => uid,
|
||||
| Request::RemovePublicLinkByUid { uid, .. }
|
||||
| Request::StopSharingByUid { uid } => uid,
|
||||
other => panic!("decoded into the wrong dispatch variant: {other:?}"),
|
||||
};
|
||||
assert_eq!(decoded_uid, "vol~link");
|
||||
|
||||
@@ -178,7 +178,7 @@ impl Db {
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
// Album rows have no favourite state of their own: an album
|
||||
// Album rows have no favorite state of their own: an album
|
||||
// photo on someone else's volume is not in our timeline, and
|
||||
// favouriting one is not supported yet anyway.
|
||||
favorite: false,
|
||||
@@ -239,7 +239,7 @@ impl Db {
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
// Album rows have no favourite state of their own: an album
|
||||
// Album rows have no favorite state of their own: an album
|
||||
// photo on someone else's volume is not in our timeline, and
|
||||
// favouriting one is not supported yet anyway.
|
||||
favorite: false,
|
||||
|
||||
@@ -684,7 +684,7 @@ CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
|
||||
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
|
||||
";
|
||||
|
||||
/// V20: whether a photo is a favourite.
|
||||
/// V20: whether a photo is a favorite.
|
||||
///
|
||||
/// A column rather than a tag table: `Favorite` is the only Proton photo tag a
|
||||
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
|
||||
@@ -938,7 +938,7 @@ CREATE INDEX IF NOT EXISTS idx_photos_group ON photos(group_key);
|
||||
/// Schema v31: which photos a refresh still has to read from the server.
|
||||
///
|
||||
/// A refresh used to resolve every photo's node to learn its name, media type,
|
||||
/// favourite tag, content hash and photo relation — minutes of round-trips for a
|
||||
/// favorite tag, content hash and photo relation — minutes of round-trips for a
|
||||
/// library of any size, repeated every time the timeline went stale. `resolved_at`
|
||||
/// records that the work was already done, so a refresh only reads the photos it
|
||||
/// has never read and the ones a remote event marked stale (`resolved_at = NULL`).
|
||||
|
||||
@@ -56,8 +56,8 @@ pub use local::LocalFileHit;
|
||||
pub use maintenance::{DbStats, VacuumOutcome};
|
||||
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
|
||||
pub use ops::{
|
||||
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH,
|
||||
PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
||||
AttachedBlob, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION,
|
||||
OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
||||
};
|
||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||
pub use pins::PinRow;
|
||||
|
||||
@@ -171,6 +171,7 @@ impl Db {
|
||||
pending_mode: pending_mode.as_deref().map(MountMode::from),
|
||||
mounted: false,
|
||||
progress: None,
|
||||
paused: false,
|
||||
});
|
||||
}
|
||||
Ok(mounts)
|
||||
|
||||
@@ -355,6 +355,22 @@ impl Db {
|
||||
/// than [`TRIGRAM_MIN`] fall back to a `LIKE` scan since trigram indexes
|
||||
/// nothing below 3 chars.
|
||||
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
|
||||
self.search_in(query, limit, None)
|
||||
}
|
||||
|
||||
/// [`search`](Self::search) restricted to the subtree under the
|
||||
/// mountpoint-relative folder `scope`. `None` or an empty scope searches
|
||||
/// everything.
|
||||
pub fn search_in(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
scope: Option<&str>,
|
||||
) -> Result<Vec<SearchHit>> {
|
||||
let scope = scope.map(|s| s.trim_matches('/')).filter(|s| !s.is_empty());
|
||||
// Rows whose path is not persisted yet pass the SQL filter and are
|
||||
// checked once their path is walked below.
|
||||
let scope_pat = scope.map(|s| format!("{}/%", like_escape(s)));
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -365,9 +381,10 @@ impl Db {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json, uid, path FROM nodes
|
||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
||||
AND (?3 IS NULL OR path IS NULL OR path LIKE ?3 ESCAPE '\\')
|
||||
ORDER BY name LIMIT ?2",
|
||||
)?;
|
||||
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
|
||||
collect_hits(stmt.query_map(params![pat, limit as i64, scope_pat], hit_row)?)?
|
||||
} else {
|
||||
// Escape double quotes and quote each term, then combine with AND so
|
||||
// all terms must match but can appear in any order or position.
|
||||
@@ -380,9 +397,10 @@ impl Db {
|
||||
"SELECT n.node_json, n.uid, n.path
|
||||
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
||||
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||
AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
|
||||
ORDER BY f.rank LIMIT ?2",
|
||||
)?;
|
||||
collect_hits(stmt.query_map(params![phrase, limit as i64], hit_row)?)?
|
||||
collect_hits(stmt.query_map(params![phrase, limit as i64, scope_pat], hit_row)?)?
|
||||
};
|
||||
|
||||
let mut hits = Vec::with_capacity(rows.len());
|
||||
@@ -392,6 +410,13 @@ impl Db {
|
||||
Some(path) => path,
|
||||
None => walk_path_of(&conn, &uid)?,
|
||||
};
|
||||
if let Some(scope) = scope
|
||||
&& !path
|
||||
.strip_prefix(scope)
|
||||
.is_some_and(|rest| rest.starts_with('/'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
hits.push(SearchHit { node, path });
|
||||
}
|
||||
Ok(hits)
|
||||
|
||||
@@ -772,6 +772,31 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make one backed-off op due now, for the user's "Retry now". Parked ops are
|
||||
/// left alone: they wait for a rename, not for time, and un-parking one would
|
||||
/// upload a file its writer has not finished. Returns whether a row changed.
|
||||
pub fn retry_op_now(&self, id: i64, now: i64) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let changed = conn.execute(
|
||||
"UPDATE pending_op SET next_attempt_at = ?2
|
||||
WHERE id = ?1 AND next_attempt_at > ?2 AND next_attempt_at < ?3",
|
||||
params![id, now, PARK_UNTIL],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
/// [`retry_op_now`](Self::retry_op_now) for every op that has failed at
|
||||
/// least once. Returns how many rows changed.
|
||||
pub fn retry_failed_ops_now(&self, now: i64) -> Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
let changed = conn.execute(
|
||||
"UPDATE pending_op SET next_attempt_at = ?1
|
||||
WHERE attempts > 0 AND next_attempt_at > ?1 AND next_attempt_at < ?2",
|
||||
params![now, PARK_UNTIL],
|
||||
)?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Record a failed attempt and when to next try. Leaves the row in place —
|
||||
/// the staged bytes are still the only copy of the user's write.
|
||||
///
|
||||
|
||||
@@ -455,7 +455,7 @@ impl Db {
|
||||
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
|
||||
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
||||
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
|
||||
/// `favorites` restricts the page to favourited photos.
|
||||
/// `favorites` restricts the page to favorited photos.
|
||||
///
|
||||
/// The page holds one row per *group*: the RAW and the JPEG of one shot are
|
||||
/// one tile. The Raw tab is the exception and lists every raw file, because
|
||||
@@ -620,7 +620,7 @@ impl Db {
|
||||
Ok(photos)
|
||||
}
|
||||
|
||||
/// Record a photo's favourite flag locally, after the server accepted the
|
||||
/// Record a photo's favorite flag locally, after the server accepted the
|
||||
/// change. A uid the timeline does not hold is a no-op — an album photo on
|
||||
/// someone else's volume is never in our own `photos` table.
|
||||
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
|
||||
|
||||
@@ -222,7 +222,7 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
|
||||
assert_eq!(
|
||||
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p1"],
|
||||
"only the favourited photo is in a favourites page"
|
||||
"only the favorited photo is in a favorites page"
|
||||
);
|
||||
|
||||
// A local toggle survives a refresh that could not resolve the photo's node
|
||||
@@ -1083,6 +1083,40 @@ fn search_trigram_substring_and_path() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_in_scope_only_returns_hits_below_the_folder() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
db.upsert_node(&folder("work", Some("root"), "Work"))
|
||||
.unwrap();
|
||||
db.upsert_node(&folder("sub", Some("work"), "Old")).unwrap();
|
||||
db.upsert_node(&folder("work2", Some("root"), "Work 2"))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f1", "work", "report.pdf", 1))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f2", "sub", "report-2019.pdf", 1))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f3", "work2", "report-copy.pdf", 1))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f4", "root", "report-top.pdf", 1))
|
||||
.unwrap();
|
||||
|
||||
let mut paths: Vec<String> = db
|
||||
.search_in("report", 10, Some("Work"))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|h| h.path)
|
||||
.collect();
|
||||
paths.sort();
|
||||
// A sibling whose name starts with the scope is not inside it.
|
||||
assert_eq!(paths, vec!["Work/Old/report-2019.pdf", "Work/report.pdf"]);
|
||||
// Short queries take the LIKE path and scope the same way.
|
||||
assert_eq!(db.search_in("re", 10, Some("Work/Old")).unwrap().len(), 1);
|
||||
// No scope, or the root, is everywhere.
|
||||
assert_eq!(db.search_in("report", 10, None).unwrap().len(), 4);
|
||||
assert_eq!(db.search_in("report", 10, Some("")).unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_excludes_trashed_and_respects_limit() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
@@ -4480,3 +4514,58 @@ fn migration_v31_treats_an_existing_timeline_as_resolved() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||||
}
|
||||
|
||||
fn queued_op(db: &Db, uid: &str, failures: i64, next_attempt_at: i64) -> i64 {
|
||||
let (id, _) = db
|
||||
.enqueue_op(&ops::PendingOp {
|
||||
id: 0,
|
||||
kind: ops::OP_REVISION.to_string(),
|
||||
uid: uid.to_string(),
|
||||
parent_uid: None,
|
||||
name: None,
|
||||
blob_path: None,
|
||||
meta_json: None,
|
||||
created_at: 1000,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at,
|
||||
})
|
||||
.unwrap();
|
||||
for _ in 0..failures {
|
||||
db.record_op_failure(id, "boom", next_attempt_at).unwrap();
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// "Retry now" must make a backed-off op due, but never un-park a transient
|
||||
/// file's op: that one waits for its finishing rename, not for time.
|
||||
#[test]
|
||||
fn retry_now_makes_a_backed_off_op_due_but_leaves_parked_ops_parked() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let backed_off = queued_op(&db, "vol~a", 3, 90_000);
|
||||
let parked = queued_op(&db, "vol~b", 0, ops::PARK_UNTIL);
|
||||
|
||||
assert!(db.retry_op_now(backed_off, 5_000).unwrap());
|
||||
assert!(!db.retry_op_now(parked, 5_000).unwrap());
|
||||
|
||||
let ops = db.pending_ops().unwrap();
|
||||
let due = |id: i64| ops.iter().find(|op| op.id == id).unwrap().next_attempt_at;
|
||||
assert_eq!(due(backed_off), 5_000);
|
||||
assert_eq!(due(parked), ops::PARK_UNTIL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_all_touches_only_ops_that_have_failed() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let failed = queued_op(&db, "vol~a", 2, 90_000);
|
||||
let debounced = queued_op(&db, "vol~b", 0, 7_000);
|
||||
let parked = queued_op(&db, "vol~c", 4, ops::PARK_UNTIL);
|
||||
|
||||
assert_eq!(db.retry_failed_ops_now(5_000).unwrap(), 1);
|
||||
|
||||
let ops = db.pending_ops().unwrap();
|
||||
let due = |id: i64| ops.iter().find(|op| op.id == id).unwrap().next_attempt_at;
|
||||
assert_eq!(due(failed), 5_000);
|
||||
assert_eq!(due(debounced), 7_000);
|
||||
assert_eq!(due(parked), ops::PARK_UNTIL);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ pub mod service;
|
||||
pub mod shell;
|
||||
pub mod syncignore;
|
||||
pub mod takeout;
|
||||
pub mod tray;
|
||||
|
||||
pub use access::{Access, access_for, perm_bits};
|
||||
pub use error::{CoreError, CoreResult, Error, Result};
|
||||
|
||||
@@ -110,6 +110,9 @@ pub struct MountSpec {
|
||||
/// Live device sync progress, absent when no pass is running.
|
||||
#[serde(default)]
|
||||
pub progress: Option<SyncProgress>,
|
||||
/// A synced folder paused on its own. Live daemon state, not a stored column.
|
||||
#[serde(default)]
|
||||
pub paused: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -812,7 +812,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_supplies_capture_time_and_favourite() {
|
||||
fn sidecar_supplies_capture_time_and_favorite() {
|
||||
let mut sidecars = HashMap::new();
|
||||
sidecar(
|
||||
&mut sidecars,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Talking to a running `pdfs-tray`.
|
||||
//!
|
||||
//! The tray binds [`AppDirs::tray_socket`] to keep a single instance. The same
|
||||
//! socket takes one command, [`QUIT`], so the desktop app can take the icon
|
||||
//! away when the user hides it or stops Proton Drive.
|
||||
|
||||
use std::io::Write;
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
use crate::config::AppDirs;
|
||||
|
||||
/// The line that asks the tray to exit.
|
||||
pub const QUIT: &str = "quit";
|
||||
|
||||
/// Ask a running tray to exit. False when no tray answers.
|
||||
pub fn quit(dirs: &AppDirs) -> bool {
|
||||
UnixStream::connect(dirs.tray_socket())
|
||||
.and_then(|mut stream| stream.write_all(format!("{QUIT}\n").as_bytes()))
|
||||
.is_ok()
|
||||
}
|
||||
@@ -221,6 +221,84 @@ impl Core {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create an album and return its uid. The listing is refreshed before
|
||||
/// answering, so the next [`Core::albums`] already shows it.
|
||||
pub(crate) fn create_album(&self, name: &str) -> CoreResult<String> {
|
||||
let uid = self
|
||||
.rt
|
||||
.block_on(self.photos().create_album(name))
|
||||
.map_err(|e| CoreError::from_api(&e, "create album"))?;
|
||||
self.after_album_change(None);
|
||||
Ok(uid.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn rename_album(&self, album: &NodeUid, name: &str) -> CoreResult<()> {
|
||||
self.rt
|
||||
.block_on(self.photos().rename_album(album, name))
|
||||
.map_err(|e| CoreError::from_api(&e, "rename album"))?;
|
||||
self.after_album_change(None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete an album. Its photos stay in the timeline unless `delete_photos`,
|
||||
/// which only matters for photos that exist in the album alone.
|
||||
pub(crate) fn delete_album(&self, album: &NodeUid, delete_photos: bool) -> CoreResult<()> {
|
||||
self.rt
|
||||
.block_on(self.photos().delete_album(album, delete_photos))
|
||||
.map_err(|e| CoreError::from_api(&e, "delete album"))?;
|
||||
let key = album.to_string();
|
||||
if let Err(error) = self.db.album_photos_replace(&key, &[]) {
|
||||
warn!(%album, %error, "a deleted album's photos could not be dropped");
|
||||
}
|
||||
self.after_album_change(None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add photos to an album, per photo: `(added, failed)`.
|
||||
pub(crate) fn add_to_album(
|
||||
&self,
|
||||
album: &NodeUid,
|
||||
photos: &[NodeUid],
|
||||
) -> CoreResult<AlbumOutcome> {
|
||||
let outcomes = self
|
||||
.rt
|
||||
.block_on(self.photos().add_photos_to_album(album, photos))
|
||||
.map_err(|e| CoreError::from_api(&e, "add to album"))?;
|
||||
self.after_album_change(Some(album));
|
||||
Ok(album_outcome(outcomes))
|
||||
}
|
||||
|
||||
/// Take photos out of an album, per photo: `(removed, failed)`. The photos
|
||||
/// stay in the timeline.
|
||||
pub(crate) fn remove_from_album(
|
||||
&self,
|
||||
album: &NodeUid,
|
||||
photos: &[NodeUid],
|
||||
) -> CoreResult<AlbumOutcome> {
|
||||
let outcomes = self
|
||||
.rt
|
||||
.block_on(self.photos().remove_photos_from_album(album, photos))
|
||||
.map_err(|e| CoreError::from_api(&e, "remove from album"))?;
|
||||
self.after_album_change(Some(album));
|
||||
Ok(album_outcome(outcomes))
|
||||
}
|
||||
|
||||
/// Bring the persisted listing, and `album`'s contents when given, up to
|
||||
/// date after a change made here, so the reply is followed by a listing
|
||||
/// that shows it. A failed refresh only leaves them stale: the change
|
||||
/// itself went through.
|
||||
fn after_album_change(&self, album: Option<&NodeUid>) {
|
||||
self.invalidate_albums();
|
||||
if let Err(error) = self.rt.block_on(self.refresh_albums()) {
|
||||
warn!(%error, "album listing refresh after a change failed");
|
||||
}
|
||||
if let Some(album) = album
|
||||
&& let Err(error) = self.rt.block_on(self.refresh_album(album))
|
||||
{
|
||||
warn!(%album, %error, "album refresh after a change failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the album listing's and every album's freshness stamp, so the next
|
||||
/// request re-enumerates. Paired with [`Core::invalidate_photos`] — a change
|
||||
/// to the timeline can just as easily be a change to an album.
|
||||
@@ -230,6 +308,20 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// Photos an album change applied to, and the ones it failed for with why.
|
||||
pub(crate) type AlbumOutcome = (Vec<String>, Vec<(String, String)>);
|
||||
|
||||
fn album_outcome(outcomes: Vec<pdfs_core::batch::Outcome>) -> AlbumOutcome {
|
||||
let (done, failed) = pdfs_core::batch::split(outcomes);
|
||||
(
|
||||
done.iter().map(|uid| uid.to_string()).collect(),
|
||||
failed
|
||||
.into_iter()
|
||||
.map(|(uid, error)| (uid.to_string(), error.to_string()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Project a persisted album into the wire item a front-end paints.
|
||||
fn album_info(album: StoredAlbum) -> AlbumInfo {
|
||||
AlbumInfo {
|
||||
|
||||
@@ -511,7 +511,7 @@ impl PhotoEventPlan {
|
||||
///
|
||||
/// `is_echo` claims an event as this daemon's own write. The feed replays our
|
||||
/// writes back at us and a `NodeUpdated` carries no revision id, so without this
|
||||
/// every favourite the user toggles here would mark its own photo stale and buy
|
||||
/// every favorite the user toggles here would mark its own photo stale and buy
|
||||
/// a resolve of a node we just wrote.
|
||||
fn classify_photo_events(
|
||||
events: &[DriveEvent],
|
||||
@@ -529,7 +529,7 @@ fn classify_photo_events(
|
||||
if *is_trashed {
|
||||
plan.gone.push(node_uid.to_string());
|
||||
} else if !is_echo(node_uid) {
|
||||
// An untrash, a rename, a favourite toggled on the phone: the
|
||||
// An untrash, a rename, a favorite toggled on the phone: the
|
||||
// event says which node changed but never what about it, so
|
||||
// the uid is marked stale and the next refresh reads it.
|
||||
plan.touched.push(node_uid.to_string());
|
||||
@@ -974,7 +974,7 @@ mod tests {
|
||||
},
|
||||
DriveEvent::NodeUpdated {
|
||||
id: DriveEventId::from("e3"),
|
||||
node_uid: uid("favourited"),
|
||||
node_uid: uid("favorited"),
|
||||
parent_node_uid: None,
|
||||
is_trashed: false,
|
||||
is_shared: false,
|
||||
@@ -986,7 +986,7 @@ mod tests {
|
||||
plan,
|
||||
PhotoEventPlan {
|
||||
gone: vec![uid("deleted").to_string(), uid("trashed").to_string()],
|
||||
touched: vec![uid("favourited").to_string()],
|
||||
touched: vec![uid("favorited").to_string()],
|
||||
full: false,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Listing and resolving `(sync-conflict …)` copies for the user.
|
||||
//!
|
||||
//! The sweep ([`crate::sweep`]) removes copies it can prove identical and flags
|
||||
//! the rest once in the activity feed. That leaves divergent copies sitting in
|
||||
//! folders with nothing but a name to find them by. This module lists them with
|
||||
//! the file they are a copy of, and resolves one the way the user chooses:
|
||||
//!
|
||||
//! - **Keep the original**: trash the copy.
|
||||
//! - **Keep the copy**: trash the original, then give the copy its name.
|
||||
//! - **Keep both**: rename the copy to a name of the user's choosing.
|
||||
//!
|
||||
//! Every resolution goes through the Proton trash, never a delete, so a wrong
|
||||
//! choice is undone by restoring from Trash. Resolution refuses to touch a file
|
||||
//! that is open, being written, or still owed an upload, for the same reason the
|
||||
//! sweep does: the node is about to change under the decision.
|
||||
//!
|
||||
//! Only copies under My Files are handled here. A copy inside a mirror folder is
|
||||
//! a local file first; the sync engine owns it.
|
||||
|
||||
use super::*;
|
||||
use pdfs_core::control::{ConflictInfo, ConflictKeep};
|
||||
use sweep::conflict_base_name;
|
||||
|
||||
impl Core {
|
||||
/// Every conflict copy under My Files, with the file it is a copy of.
|
||||
pub(crate) fn list_conflicts(&self) -> CoreResult<Vec<ConflictInfo>> {
|
||||
let stored = self
|
||||
.db
|
||||
.load_all()
|
||||
.map_err(|e| CoreError::internal(format!("listing conflicts: {e:?}")))?;
|
||||
let nodes: Vec<Node> = stored
|
||||
.into_iter()
|
||||
.map(|stored| stored.node)
|
||||
.filter(|node| !node.trashed && !node.is_folder() && self.is_own_or_virtual(&node.uid))
|
||||
.collect();
|
||||
let mut by_parent_name: HashMap<(Option<&NodeUid>, &str), &Node> = HashMap::new();
|
||||
for node in &nodes {
|
||||
by_parent_name.insert((node.parent_uid.as_ref(), node.name.as_str()), node);
|
||||
}
|
||||
let root = self.primary_root_uid.to_string();
|
||||
let mut items = Vec::new();
|
||||
for node in &nodes {
|
||||
let Some(base) = conflict_base_name(&node.name) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(Some(path)) = self.db.path_relative_to(&root, &node.uid.to_string()) else {
|
||||
continue;
|
||||
};
|
||||
let original = by_parent_name
|
||||
.get(&(node.parent_uid.as_ref(), base.as_str()))
|
||||
.copied();
|
||||
items.push(ConflictInfo {
|
||||
original_path: sibling_path(&path, &base),
|
||||
original_exists: original.is_some(),
|
||||
size: node_size(node),
|
||||
modified: node.modification_time,
|
||||
original_size: original.map(node_size),
|
||||
original_modified: original.map(|o| o.modification_time),
|
||||
identical: original.is_some_and(|o| {
|
||||
node_size(o) == node_size(node)
|
||||
&& node_content_sha1(node)
|
||||
.is_some_and(|sha| node_content_sha1(o).as_deref() == Some(&sha))
|
||||
}),
|
||||
path,
|
||||
});
|
||||
}
|
||||
items.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Resolve the conflict copy at mountpoint-relative `copy`. Returns a line
|
||||
/// for the user saying what happened.
|
||||
pub(crate) fn resolve_conflict(&self, copy: &Path, keep: &ConflictKeep) -> CoreResult<String> {
|
||||
let copy_name = copy
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| CoreError::invalid("the conflict copy has no file name"))?;
|
||||
let base = conflict_base_name(copy_name)
|
||||
.ok_or_else(|| CoreError::invalid(format!("{copy_name} is not a conflict copy")))?;
|
||||
let original = copy.with_file_name(&base);
|
||||
self.require_settled(copy)?;
|
||||
let message = match keep {
|
||||
ConflictKeep::Original => {
|
||||
self.delete(copy)?;
|
||||
self.log_activity(ActivityKind::Trash, copy_name, format!("kept {base}"), true);
|
||||
format!("kept {base}; the copy is in Trash")
|
||||
}
|
||||
ConflictKeep::Copy => {
|
||||
self.require_settled(&original)?;
|
||||
self.delete(&original)?;
|
||||
// The original is already in Trash. A failed rename leaves the
|
||||
// copy under its conflict name, which loses nothing — say so
|
||||
// rather than leave the user guessing where their file went.
|
||||
self.rename(copy, &base).map_err(|e| {
|
||||
e.context(&format!(
|
||||
"{base} was moved to Trash, but the copy kept its conflict name"
|
||||
))
|
||||
})?;
|
||||
self.log_activity(
|
||||
ActivityKind::Rename,
|
||||
&base,
|
||||
format!("kept the conflict copy {copy_name}"),
|
||||
true,
|
||||
);
|
||||
format!("kept the copy as {base}; the previous version is in Trash")
|
||||
}
|
||||
ConflictKeep::Both { name } => {
|
||||
let name = name.trim();
|
||||
if name == base || name == copy_name {
|
||||
return Err(CoreError::invalid("choose a name that no other file has"));
|
||||
}
|
||||
self.rename(copy, name)?;
|
||||
self.log_activity(ActivityKind::Rename, name, format!("was {copy_name}"), true);
|
||||
format!("kept both; the copy is now {name}")
|
||||
}
|
||||
};
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
/// Refuse to resolve around a file that is open, being written, or still
|
||||
/// owed an upload: the decision would be about content that is changing.
|
||||
fn require_settled(&self, rel: &Path) -> CoreResult<()> {
|
||||
let (_, uid) = self.resolve(rel)?;
|
||||
let queued = self.db.has_any_op(&uid.to_string()).unwrap_or(true);
|
||||
if queued || self.is_busy(&uid) {
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{} is still being written or uploaded; try again once it has synced",
|
||||
rel.display()
|
||||
)));
|
||||
}
|
||||
self.conflict_notified.lock().remove(&uid);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The path of `name` next to the file at `path`.
|
||||
fn sibling_path(path: &str, name: &str) -> String {
|
||||
match path.rsplit_once('/') {
|
||||
Some((dir, _)) => format!("{dir}/{name}"),
|
||||
None => name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_original_sits_next_to_its_copy() {
|
||||
assert_eq!(
|
||||
sibling_path("a/b/f (sync-conflict 1).txt", "f.txt"),
|
||||
"a/b/f.txt"
|
||||
);
|
||||
assert_eq!(sibling_path("f (sync-conflict 1).txt", "f.txt"), "f.txt");
|
||||
}
|
||||
}
|
||||
+199
-16
@@ -29,6 +29,26 @@ use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
|
||||
|
||||
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
||||
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// Answer a [`CtlRequest::StopSharing`]: log it, and turn a partial removal
|
||||
/// into an error that names what is still shared.
|
||||
fn stop_sharing_reply(
|
||||
core: &Core,
|
||||
target: &str,
|
||||
result: CoreResult<super::sharing::StopSharingOutcome>,
|
||||
) -> CtlResponse {
|
||||
match result.map(|outcome| outcome.into_result()) {
|
||||
Ok(Ok(message)) => {
|
||||
core.log_activity(ActivityKind::Unshare, target, "stopped sharing", true);
|
||||
CtlResponse::Ok { message }
|
||||
}
|
||||
Ok(Err(left)) => {
|
||||
core.log_activity(ActivityKind::Unshare, target, &left, false);
|
||||
CtlResponse::error(CoreError::remote(left))
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// A client that connects, sends a request and then never reads its reply used
|
||||
/// to park a handler thread forever in `write_all`; 64 of those exhaust
|
||||
/// [`MAX_CONTROL_HANDLERS`] permanently. Generous enough for the largest
|
||||
@@ -46,6 +66,32 @@ const MAX_ROW_LIMIT: usize = 1000;
|
||||
fn clamp_limit(limit: usize) -> usize {
|
||||
limit.min(MAX_ROW_LIMIT)
|
||||
}
|
||||
/// Answer an album add or remove: parse the uids, run `change`, and report
|
||||
/// per photo.
|
||||
fn album_change(
|
||||
uid: &str,
|
||||
photos: &[String],
|
||||
change: impl FnOnce(&NodeUid, &[NodeUid]) -> CoreResult<super::albums::AlbumOutcome>,
|
||||
) -> CtlResponse {
|
||||
let Some(album) = parse_uid(uid) else {
|
||||
return CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}")));
|
||||
};
|
||||
let photos = match Core::parse_uids(photos) {
|
||||
Ok(photos) => photos,
|
||||
Err(e) => return CtlResponse::error(e),
|
||||
};
|
||||
match change(&album, &photos) {
|
||||
Ok((changed, failed)) => CtlResponse::AlbumChanged {
|
||||
changed,
|
||||
failed: failed
|
||||
.into_iter()
|
||||
.map(|(uid, message)| TrashFailure { uid, message })
|
||||
.collect(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
|
||||
static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
struct ControlHandlerPermit;
|
||||
@@ -246,6 +292,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
failing_error: queued.last_error.clone(),
|
||||
staged_bytes: staging.bytes,
|
||||
staged_oldest_secs: staging.oldest_secs,
|
||||
paused: core.sync_pause().is_some(),
|
||||
paused_until: core.sync_pause().flatten(),
|
||||
username: username.to_string(),
|
||||
mountpoint: mountpoint.display().to_string(),
|
||||
pinned: pins.len(),
|
||||
@@ -282,7 +330,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListPins) => CtlResponse::Pins {
|
||||
pins: core.cache.list_pins(),
|
||||
pins: core.list_pins(),
|
||||
},
|
||||
Ok(CtlRequest::ListDir { path }) => match route_to_mount(core, mountpoint, &path) {
|
||||
Ok((core, rel)) => match core.list_dir(&rel) {
|
||||
@@ -385,9 +433,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: if favorite {
|
||||
"added to favourites".to_string()
|
||||
"added to favorites".to_string()
|
||||
} else {
|
||||
"removed from favourites".to_string()
|
||||
"removed from favorites".to_string()
|
||||
},
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
@@ -419,6 +467,38 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||
},
|
||||
Ok(CtlRequest::CreateAlbum { name }) => match core.create_album(&name) {
|
||||
Ok(uid) => CtlResponse::AlbumCreated { uid },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RenameAlbum { uid, name }) => match parse_uid(&uid) {
|
||||
Some(album) => match core.rename_album(&album, &name) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: format!("renamed album to {name}"),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||
},
|
||||
Ok(CtlRequest::DeleteAlbum { uid, delete_photos }) => match parse_uid(&uid) {
|
||||
Some(album) => match core.delete_album(&album, delete_photos) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: "album deleted".to_string(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||
},
|
||||
Ok(CtlRequest::AddToAlbum { uid, photos }) => {
|
||||
album_change(&uid, &photos, |album, photos| {
|
||||
core.add_to_album(album, photos)
|
||||
})
|
||||
}
|
||||
Ok(CtlRequest::RemoveFromAlbum { uid, photos }) => {
|
||||
album_change(&uid, &photos, |album, photos| {
|
||||
core.remove_from_album(album, photos)
|
||||
})
|
||||
}
|
||||
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
|
||||
Ok(months) => CtlResponse::PhotoMonths {
|
||||
months: months
|
||||
@@ -629,7 +709,11 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::Search { query, limit }) => match core.search(&query, clamp_limit(limit)) {
|
||||
Ok(CtlRequest::Search {
|
||||
query,
|
||||
limit,
|
||||
scope,
|
||||
}) => match core.search(&query, clamp_limit(limit), scope.as_deref()) {
|
||||
Ok(hits) => CtlResponse::SearchResults { hits },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
@@ -810,9 +894,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Ok(CtlRequest::EmptyTrash) => match core.empty_trash() {
|
||||
Ok(n) => {
|
||||
core.log_activity(ActivityKind::EmptyTrash, format!("{n} item(s)"), "", true);
|
||||
let items = if n == 1 {
|
||||
"1 item".to_string()
|
||||
} else {
|
||||
format!("{n} items")
|
||||
};
|
||||
core.log_activity(ActivityKind::EmptyTrash, items.clone(), "", true);
|
||||
CtlResponse::Ok {
|
||||
message: format!("emptied trash ({n} item(s))"),
|
||||
message: format!("emptied trash ({items})"),
|
||||
}
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
@@ -892,6 +981,29 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
))),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::SetBandwidthLimits { upload, download }) => {
|
||||
core.transfers.set_limits(upload, download);
|
||||
// Persisted like the cache budget: best-effort, the live caps are
|
||||
// already applied.
|
||||
match AppDirs::new().map(|dirs| {
|
||||
let mut cfg = dirs.load_config();
|
||||
cfg.upload_limit = Some(upload);
|
||||
cfg.download_limit = Some(download);
|
||||
dirs.save_config(&cfg)
|
||||
}) {
|
||||
Ok(Ok(())) => CtlResponse::Ok {
|
||||
message: format!(
|
||||
"bandwidth limits set to {upload} B/s up, {download} B/s down"
|
||||
),
|
||||
},
|
||||
Ok(Err(e)) => CtlResponse::error(CoreError::internal(format!(
|
||||
"limits applied but config write failed: {e}"
|
||||
))),
|
||||
Err(e) => CtlResponse::error(CoreError::internal(format!(
|
||||
"limits applied but config unavailable: {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::ListDevices) => match core.list_devices() {
|
||||
Ok(items) => CtlResponse::Devices { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
@@ -975,6 +1087,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::SetSyncFolderPaused { id, paused }) => {
|
||||
match core.set_sync_folder_paused(id, paused) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: match paused {
|
||||
true => format!("folder {id} paused"),
|
||||
false => format!("folder {id} resumed"),
|
||||
},
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::SyncNow { id }) => {
|
||||
core.sync_now(id);
|
||||
CtlResponse::Ok {
|
||||
@@ -984,22 +1107,75 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::ListRestorableFolders) => match core.list_restorable_folders() {
|
||||
Ok(CtlRequest::SetSyncPaused { paused, until }) => {
|
||||
match core.set_sync_paused(paused, until) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: match (paused, until) {
|
||||
(false, _) => "sync resumed".to_string(),
|
||||
(true, None) => "sync paused until resumed".to_string(),
|
||||
(true, Some(_)) => "sync paused".to_string(),
|
||||
},
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::ListConflicts) => match core.list_conflicts() {
|
||||
Ok(items) => CtlResponse::Conflicts { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ResolveConflict { path, keep }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.resolve_conflict(&rel, &keep) {
|
||||
Ok(message) => CtlResponse::Ok { message },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListPendingOps) => match core.pending_op_infos() {
|
||||
Ok(items) => CtlResponse::PendingOps { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RetryPendingOp { id }) => match core.retry_pending_ops(id) {
|
||||
Ok(0) if id.is_some() => CtlResponse::Ok {
|
||||
message: "nothing to retry: the operation is already due or waiting for a rename"
|
||||
.to_string(),
|
||||
},
|
||||
Ok(moved) => CtlResponse::Ok {
|
||||
message: format!("retrying {moved} queued operation(s) now"),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListRestorableFolders) => match core.list_restorable_folders(None) {
|
||||
Ok(items) => CtlResponse::RestorableFolders { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RestoreSyncFolders { items }) => {
|
||||
Ok(CtlRequest::ListDeviceRestorableFolders { device }) => {
|
||||
match core.list_restorable_folders(Some(&device)) {
|
||||
Ok(items) => CtlResponse::RestorableFolders { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(
|
||||
request @ (CtlRequest::RestoreSyncFolders { .. }
|
||||
| CtlRequest::RestoreDeviceFolders { .. }),
|
||||
) => {
|
||||
let (device, items) = match request {
|
||||
CtlRequest::RestoreDeviceFolders { device, items } => (Some(device), items),
|
||||
CtlRequest::RestoreSyncFolders { items } => (None, items),
|
||||
_ => unreachable!("matched above"),
|
||||
};
|
||||
// Like AddSyncFolder: each restored folder downloads a whole tree,
|
||||
// which outlasts the socket timeout. Ack, then work.
|
||||
let core = core.clone();
|
||||
std::thread::spawn(move || match core.restore_sync_folders(&items) {
|
||||
Ok(message) => {
|
||||
core.touch_profile();
|
||||
core.log_activity(ActivityKind::Download, &message, "", true);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "restore failed");
|
||||
core.log_activity(ActivityKind::Download, "restore folders", &e, false);
|
||||
std::thread::spawn(move || {
|
||||
match core.restore_sync_folders(&items, device.as_deref()) {
|
||||
Ok(message) => {
|
||||
core.touch_profile();
|
||||
core.log_activity(ActivityKind::Download, &message, "", true);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "restore failed");
|
||||
core.log_activity(ActivityKind::Download, "restore folders", &e, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
CtlResponse::Ok {
|
||||
@@ -1170,6 +1346,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::StopSharing { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => stop_sharing_reply(core, &path, core.stop_sharing(&rel)),
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::StopSharingByUid { uid }) => {
|
||||
stop_sharing_reply(core, &uid, core.stop_sharing_by_uid(&uid))
|
||||
}
|
||||
Ok(CtlRequest::ListRevisions { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.list_revisions(&rel) {
|
||||
Ok(items) => CtlResponse::Revisions { items },
|
||||
|
||||
@@ -128,6 +128,7 @@ impl Core {
|
||||
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
|
||||
if let MountKind::Device { sync_folder_id } = &location.kind {
|
||||
location.progress = progress.get(sync_folder_id).cloned();
|
||||
location.paused = self.sync_folder_paused(*sync_folder_id);
|
||||
}
|
||||
}
|
||||
Ok(locations)
|
||||
@@ -226,6 +227,39 @@ impl Core {
|
||||
Ok(format!("adopted device {name}"))
|
||||
}
|
||||
|
||||
/// The device a restore reads from: this machine's own for `None`, else
|
||||
/// the registered device with that uid.
|
||||
///
|
||||
/// Another device is looked up without touching the cached device row —
|
||||
/// restoring its folders here must not make this machine back up as it.
|
||||
/// Its folders are then attached with that device's share id, so they sync
|
||||
/// against the other computer's tree.
|
||||
pub(crate) fn restore_source(&self, device: Option<&str>) -> CoreResult<StoredDevice> {
|
||||
let Some(uid) = device else {
|
||||
return self.ensure_device();
|
||||
};
|
||||
let remote = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_devices())
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||
let d = remote
|
||||
.into_iter()
|
||||
.find(|d| d.uid.to_string() == uid)
|
||||
.ok_or_else(|| CoreError::not_found(format!("no device {uid} in this account")))?;
|
||||
Ok(StoredDevice {
|
||||
uid: d.uid.to_string(),
|
||||
share_id: d.share_id.to_string(),
|
||||
root_uid: d.root_folder_uid.to_string(),
|
||||
name: d
|
||||
.name
|
||||
.as_deref()
|
||||
.ok()
|
||||
.unwrap_or("(unnamed device)")
|
||||
.to_string(),
|
||||
created: d.creation_time,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- device folder sync (devices.md, Phase 1) -------------------------
|
||||
|
||||
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
||||
@@ -448,7 +482,8 @@ impl Core {
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let live = progress.get(&f.id).cloned();
|
||||
sync_folder_info(f, live)
|
||||
let paused = self.sync_folder_paused(f.id);
|
||||
sync_folder_info(f, live, paused)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -561,6 +596,7 @@ impl Core {
|
||||
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
|
||||
}
|
||||
drop(_guard);
|
||||
self.clear_sync_folder_paused(id);
|
||||
// Stop watching the folder we just dropped.
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||
if delete_remote
|
||||
@@ -625,6 +661,13 @@ impl Core {
|
||||
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
||||
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
|
||||
// A switch waits on the folder's reconcile, which a pause skips; refuse
|
||||
// it up front rather than queue it indefinitely.
|
||||
if current_mode != target_mode && self.sync_folder_paused(id) {
|
||||
return Err(CoreError::invalid(
|
||||
"this folder is paused; resume it before changing its mode",
|
||||
));
|
||||
}
|
||||
if current_mode == target_mode {
|
||||
let mode_str = target_mode.as_str();
|
||||
let lock = self.sync_lock(id);
|
||||
|
||||
@@ -285,8 +285,9 @@ impl Core {
|
||||
}
|
||||
};
|
||||
let online = self.online.load(Ordering::Relaxed);
|
||||
let paused = self.sync_paused();
|
||||
let op = match due {
|
||||
Some(op) if online => op,
|
||||
Some(op) if online && !paused => op,
|
||||
idle => {
|
||||
// Claimed but offline: hand it straight back, or it stays
|
||||
// invisible to every worker until this one happens to be
|
||||
@@ -306,6 +307,14 @@ impl Core {
|
||||
// its writer has to be noticed.
|
||||
self.sweep_parked_creates();
|
||||
}
|
||||
// Paused, nothing due changes that: sleep until a resume
|
||||
// wakes the worker (or the idle poll notices a timed pause
|
||||
// ran out), not until the next op falls due — that is
|
||||
// "now" for everything already queued.
|
||||
if paused {
|
||||
self.wait_for_drain_work();
|
||||
continue;
|
||||
}
|
||||
// A debounced or backed-off op may be waiting: sleep only
|
||||
// until it becomes due rather than the full idle-poll.
|
||||
self.wait_for_drain_work_or_due();
|
||||
|
||||
@@ -42,7 +42,7 @@ use std::os::unix::net::UnixListener;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use fuser::ReplyXattr;
|
||||
@@ -80,6 +80,7 @@ use proton_drive_rs::{
|
||||
|
||||
mod albums;
|
||||
mod background;
|
||||
mod conflicts;
|
||||
mod control;
|
||||
use control::run_control_socket;
|
||||
mod devices;
|
||||
@@ -88,8 +89,10 @@ mod drain;
|
||||
mod filesystem;
|
||||
pub use filesystem::ProtonFs;
|
||||
mod mount;
|
||||
mod pause;
|
||||
mod photos;
|
||||
mod profile;
|
||||
mod queue;
|
||||
mod reads;
|
||||
mod redate;
|
||||
mod revisions;
|
||||
@@ -508,6 +511,10 @@ struct Core {
|
||||
/// `Response::Status` so the UI can say so rather than leaving the user to
|
||||
/// infer it from a wall of EIO.
|
||||
online: Arc<AtomicBool>,
|
||||
/// When the user paused syncing, the Unix second it resumes by itself;
|
||||
/// [`pause::PAUSED_INDEFINITELY`] for "until resumed", `0` when not paused.
|
||||
/// Persisted, so a pause survives a restart (see [`Core::sync_paused`]).
|
||||
sync_paused_until: Arc<AtomicI64>,
|
||||
/// Writes accepted from the kernel but not yet uploaded, keyed by node
|
||||
/// (offline.md Phase 3). The in-memory face of the `pending_op` table, from
|
||||
/// which it is rebuilt on mount.
|
||||
@@ -3850,10 +3857,25 @@ impl Core {
|
||||
/// Full-text search node names against the local SQLite index, mapping each
|
||||
/// DB hit to the wire [`SearchHit`] (resolving live pin state from the cache,
|
||||
/// which the DB doesn't track). Pure local lookup — never hits the network.
|
||||
fn search(&self, query: &str, limit: usize) -> CoreResult<Vec<SearchHit>> {
|
||||
/// Every pin, with `cached` saying whether a pinned file's content is on
|
||||
/// disk, so a front-end listing pins can badge them without a round-trip
|
||||
/// per file.
|
||||
fn list_pins(&self) -> Vec<pdfs_core::cache::Pin> {
|
||||
let mut pins = self.cache.list_pins();
|
||||
for pin in pins.iter_mut().filter(|pin| pin.is_dir == Some(false)) {
|
||||
if let Ok(Some(node)) = self.db.node_by_uid(&pin.uid) {
|
||||
pin.cached =
|
||||
self.cache
|
||||
.is_cached(&node.uid, node.modification_time, node_size(&node));
|
||||
}
|
||||
}
|
||||
pins
|
||||
}
|
||||
|
||||
fn search(&self, query: &str, limit: usize, scope: Option<&str>) -> CoreResult<Vec<SearchHit>> {
|
||||
let hits = self
|
||||
.db
|
||||
.search(query, limit)
|
||||
.search_in(query, limit, scope)
|
||||
.map_err(|e| CoreError::from_api(&e, "search"))?;
|
||||
let roots = self.search_roots();
|
||||
let pinned = self.pinned_set();
|
||||
@@ -3865,6 +3887,12 @@ impl Core {
|
||||
size: node_size(&h.node),
|
||||
modified: h.node.modification_time,
|
||||
pinned: pinned.contains(&h.node.uid.to_string()),
|
||||
cached: !h.node.is_folder()
|
||||
&& self.cache.is_cached(
|
||||
&h.node.uid,
|
||||
h.node.modification_time,
|
||||
node_size(&h.node),
|
||||
),
|
||||
uid: h.node.uid.to_string(),
|
||||
mounted_path: roots.resolve(&h.path),
|
||||
path: h.path,
|
||||
@@ -3932,6 +3960,12 @@ impl Core {
|
||||
size: node_size(&hit.node),
|
||||
modified: hit.node.modification_time,
|
||||
pinned: is_pinned,
|
||||
cached: !hit.node.is_folder()
|
||||
&& self.cache.is_cached(
|
||||
&hit.node.uid,
|
||||
hit.node.modification_time,
|
||||
node_size(&hit.node),
|
||||
),
|
||||
uid,
|
||||
mounted_path: roots.resolve(&hit.path),
|
||||
path: hit.path,
|
||||
@@ -5134,7 +5168,11 @@ fn evict_dir_contents(dir: &Path) -> std::io::Result<()> {
|
||||
|
||||
/// Convert a stored synced folder into its wire form for the front-ends, with
|
||||
/// the live progress of its pass when one is running.
|
||||
fn sync_folder_info(f: StoredSyncFolder, progress: Option<SyncProgress>) -> SyncFolderInfo {
|
||||
fn sync_folder_info(
|
||||
f: StoredSyncFolder,
|
||||
progress: Option<SyncProgress>,
|
||||
paused: bool,
|
||||
) -> SyncFolderInfo {
|
||||
SyncFolderInfo {
|
||||
id: f.id,
|
||||
local_path: f.local_path,
|
||||
@@ -5144,6 +5182,7 @@ fn sync_folder_info(f: StoredSyncFolder, progress: Option<SyncProgress>) -> Sync
|
||||
state: f.state,
|
||||
last_sync: f.last_sync,
|
||||
progress,
|
||||
paused,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -241,6 +241,9 @@ pub struct MountOptions {
|
||||
pub username: String,
|
||||
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
|
||||
pub sweep_mode: SweepMode,
|
||||
/// Upload and download caps in bytes per second (`0` = no cap).
|
||||
pub upload_limit: u64,
|
||||
pub download_limit: u64,
|
||||
}
|
||||
|
||||
/// Spawn one FUSE session rooted at an arbitrary remote node.
|
||||
@@ -340,6 +343,8 @@ pub fn mount(
|
||||
let MountOptions {
|
||||
username,
|
||||
sweep_mode,
|
||||
upload_limit,
|
||||
download_limit,
|
||||
} = options;
|
||||
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
||||
// so the age it reports is the daemon's own.
|
||||
@@ -357,6 +362,7 @@ pub fn mount(
|
||||
// handlers can trigger reconciles.
|
||||
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
||||
|
||||
let paused_until = pause::load_paused_until(&db);
|
||||
let core = Core {
|
||||
client: client.clone(),
|
||||
rt: rt.clone(),
|
||||
@@ -377,6 +383,7 @@ pub fn mount(
|
||||
shared_generation: Arc::new(AtomicU64::new(0)),
|
||||
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
||||
online: Arc::new(AtomicBool::new(online)),
|
||||
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||
@@ -417,6 +424,7 @@ pub fn mount(
|
||||
// Before anything can queue work against it: the drain thread below reaches
|
||||
// every mount's inode space through this registry, not through `core.state`.
|
||||
core.register_state(mountpoint);
|
||||
core.transfers.set_limits(upload_limit, download_limit);
|
||||
|
||||
// Writes queued by a previous run (or left behind by a crash) are still owed
|
||||
// an upload, and reads must be served from their staged blobs until they land.
|
||||
@@ -451,6 +459,10 @@ pub fn mount(
|
||||
// Start the folder-sync engine. It watches every mirror folder, polls the
|
||||
// remotes, and reconciles on its own thread — never in front of a FUSE call.
|
||||
workers.extend(sync::spawn(core.clone(), sync_rx));
|
||||
// A timed pause persisted by an earlier run still has to end on time.
|
||||
if pause::paused_at(paused_until, now_secs()) && paused_until != pause::PAUSED_INDEFINITELY {
|
||||
core.arm_pause_timer(paused_until);
|
||||
}
|
||||
|
||||
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
|
||||
// out identical to the live file, surface the ones that genuinely diverge.
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Pausing sync: the user's "stop uploading for a while".
|
||||
//!
|
||||
//! A pause holds back the two things that push local state to the remote — the
|
||||
//! drain worker's queued uploads and changes, and the mirror-folder reconcile.
|
||||
//! Nothing else stops: reads through the mount keep hydrating on demand (a read
|
||||
//! is not a sync), writes keep being accepted and staged, and the watcher and
|
||||
//! poll keep noticing changes. Resuming lets all of it catch up at once.
|
||||
//!
|
||||
//! The pause is persisted in the database, so a restart does not quietly undo
|
||||
//! it. A timed pause ends by itself; [`Core::set_sync_paused`] arms a timer for
|
||||
//! that, and [`Core::sync_paused`] compares against the clock anyway, so a
|
||||
//! deadline that passed while the daemon was down is already over on mount.
|
||||
//!
|
||||
//! A single mirror folder can be paused on its own as well: its reconcile is
|
||||
//! skipped, and with it any mode switch queued behind that reconcile, until the
|
||||
//! folder is resumed. On-demand folders cannot be paused alone — their writes
|
||||
//! go through the shared queue, which only the global pause holds back.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The `state` key holding the resume time, in Unix seconds.
|
||||
const PAUSED_UNTIL_KEY: &str = "sync_paused_until";
|
||||
|
||||
/// The `state` key prefix marking one synced folder paused; its id follows.
|
||||
const FOLDER_PAUSED_PREFIX: &str = "sync_folder_paused:";
|
||||
|
||||
fn folder_paused_key(id: i64) -> String {
|
||||
format!("{FOLDER_PAUSED_PREFIX}{id}")
|
||||
}
|
||||
|
||||
/// Whether synced folder `id` is paused on its own. A row that cannot be read
|
||||
/// counts as running, like the global pause.
|
||||
fn load_folder_paused(db: &Db, id: i64) -> bool {
|
||||
match db.state_i64(&folder_paused_key(id)) {
|
||||
Ok(value) => value.is_some_and(|v| v != 0),
|
||||
Err(error) => {
|
||||
warn!(id, %error, "reading a folder pause failed; treating it as running");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist synced folder `id`'s own pause, or clear it.
|
||||
fn store_folder_paused(db: &Db, id: i64, paused: bool) -> pdfs_core::Result<()> {
|
||||
match paused {
|
||||
true => db.set_state_i64(&folder_paused_key(id), 1),
|
||||
false => db.clear_state(&folder_paused_key(id)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A pause with no end time: it lasts until the user resumes.
|
||||
pub(crate) const PAUSED_INDEFINITELY: i64 = i64::MAX;
|
||||
|
||||
/// The persisted pause, or `0` when there is none (or it cannot be read — a
|
||||
/// broken state row must not keep sync paused forever).
|
||||
pub(crate) fn load_paused_until(db: &Db) -> i64 {
|
||||
match db.state_i64(PAUSED_UNTIL_KEY) {
|
||||
Ok(Some(until)) => until,
|
||||
Ok(None) => 0,
|
||||
Err(error) => {
|
||||
warn!(%error, "reading the sync pause failed; treating sync as running");
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a pause lasting until `until` is still in force at `now` (both Unix
|
||||
/// seconds). `0` is "not paused".
|
||||
pub(crate) fn paused_at(until: i64, now: i64) -> bool {
|
||||
until != 0 && now < until
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// True while the user has sync paused.
|
||||
pub(crate) fn sync_paused(&self) -> bool {
|
||||
paused_at(self.sync_paused_until.load(Ordering::Relaxed), now_secs())
|
||||
}
|
||||
|
||||
/// The pause's end, for status reporting: `None` when not paused,
|
||||
/// `Some(None)` when paused until resumed, `Some(Some(t))` when it ends at
|
||||
/// Unix second `t`.
|
||||
pub(crate) fn sync_pause(&self) -> Option<Option<i64>> {
|
||||
let until = self.sync_paused_until.load(Ordering::Relaxed);
|
||||
match paused_at(until, now_secs()) {
|
||||
false => None,
|
||||
true if until == PAUSED_INDEFINITELY => Some(None),
|
||||
true => Some(Some(until)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pause sync until Unix second `until` (`None`: until resumed), or resume
|
||||
/// it when `paused` is false.
|
||||
pub(crate) fn set_sync_paused(
|
||||
&self,
|
||||
paused: bool,
|
||||
until: Option<i64>,
|
||||
) -> Result<(), CoreError> {
|
||||
let value = match (paused, until) {
|
||||
(false, _) => 0,
|
||||
(true, None) => PAUSED_INDEFINITELY,
|
||||
(true, Some(until)) if until <= now_secs() => {
|
||||
return Err(CoreError::invalid("the pause would already be over"));
|
||||
}
|
||||
(true, Some(until)) => until,
|
||||
};
|
||||
let persisted = match value {
|
||||
0 => self.db.clear_state(PAUSED_UNTIL_KEY),
|
||||
value => self.db.set_state_i64(PAUSED_UNTIL_KEY, value),
|
||||
};
|
||||
if let Err(error) = persisted {
|
||||
return Err(CoreError::internal(format!(
|
||||
"saving the sync pause: {error}"
|
||||
)));
|
||||
}
|
||||
self.sync_paused_until.store(value, Ordering::Relaxed);
|
||||
match value {
|
||||
0 => {
|
||||
info!("sync resumed");
|
||||
self.resume_sync_work();
|
||||
}
|
||||
PAUSED_INDEFINITELY => info!("sync paused until resumed"),
|
||||
until => {
|
||||
info!(until, "sync paused");
|
||||
self.arm_pause_timer(until);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True while the user has this one synced folder paused. A row that cannot
|
||||
/// be read counts as running, like the global pause.
|
||||
pub(crate) fn sync_folder_paused(&self, id: i64) -> bool {
|
||||
load_folder_paused(&self.db, id)
|
||||
}
|
||||
|
||||
/// Pause or resume one mirror folder's reconcile. Resuming reconciles it
|
||||
/// straight away to catch up on what the watcher saw meanwhile.
|
||||
pub(crate) fn set_sync_folder_paused(&self, id: i64, paused: bool) -> Result<(), CoreError> {
|
||||
let folder = self
|
||||
.db
|
||||
.sync_folder_get(id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||
if paused && folder.mode != "mirror" {
|
||||
return Err(CoreError::invalid(
|
||||
"only a mirrored folder can be paused on its own; an on-demand folder uploads \
|
||||
through the shared queue, so pause all sync instead",
|
||||
));
|
||||
}
|
||||
if let Err(error) = store_folder_paused(&self.db, id, paused) {
|
||||
return Err(CoreError::internal(format!(
|
||||
"saving the folder pause: {error}"
|
||||
)));
|
||||
}
|
||||
if paused {
|
||||
info!(id, path = %folder.local_path, "folder sync paused");
|
||||
} else {
|
||||
info!(id, path = %folder.local_path, "folder sync resumed");
|
||||
self.sync_now(Some(id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget a removed folder's pause, so a later folder reusing its id does
|
||||
/// not start out paused.
|
||||
pub(crate) fn clear_sync_folder_paused(&self, id: i64) {
|
||||
if let Err(error) = store_folder_paused(&self.db, id, false) {
|
||||
warn!(id, %error, "clearing a removed folder's pause failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake everything a pause held back: the drain worker, and a full
|
||||
/// reconcile to pick up whatever the watcher saw meanwhile.
|
||||
fn resume_sync_work(&self) {
|
||||
self.wake_drain();
|
||||
self.sync_now(None);
|
||||
}
|
||||
|
||||
/// End a timed pause when its time comes. A later pause or resume replaces
|
||||
/// the stored deadline, which the timer checks before acting, so a stale
|
||||
/// timer does nothing.
|
||||
pub(crate) fn arm_pause_timer(&self, until: i64) {
|
||||
let core = self.clone();
|
||||
let wait = Duration::from_secs(until.saturating_sub(now_secs()).max(0) as u64);
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pdfs-pause".into())
|
||||
.spawn(move || {
|
||||
if !core.shutdown.sleep(wait) {
|
||||
return;
|
||||
}
|
||||
if core
|
||||
.sync_paused_until
|
||||
.compare_exchange(until, 0, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
if let Err(error) = core.db.clear_state(PAUSED_UNTIL_KEY) {
|
||||
warn!(%error, "clearing an expired sync pause failed");
|
||||
}
|
||||
info!("sync pause ended");
|
||||
core.resume_sync_work();
|
||||
}
|
||||
});
|
||||
if let Err(error) = spawned {
|
||||
// The drain and the reconcile still read the clock, so the pause
|
||||
// still ends — just at the next idle poll instead of on time.
|
||||
warn!(%error, "starting the sync pause timer failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_pause_is_in_force_until_its_deadline() {
|
||||
assert!(!paused_at(0, 100));
|
||||
assert!(paused_at(200, 100));
|
||||
assert!(!paused_at(200, 200));
|
||||
assert!(!paused_at(200, 300));
|
||||
assert!(paused_at(PAUSED_INDEFINITELY, i64::MAX - 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_pause_is_kept_per_folder_until_cleared() {
|
||||
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||
assert!(!load_folder_paused(&db, 3));
|
||||
store_folder_paused(&db, 3, true).unwrap();
|
||||
assert!(load_folder_paused(&db, 3));
|
||||
assert!(!load_folder_paused(&db, 4));
|
||||
assert!(!load_folder_paused(&db, 33));
|
||||
store_folder_paused(&db, 3, false).unwrap();
|
||||
assert!(!load_folder_paused(&db, 3));
|
||||
}
|
||||
}
|
||||
@@ -501,7 +501,7 @@ impl Core {
|
||||
.map_err(|e| CoreError::from_api(&e, "update photo tags"))?;
|
||||
// The photo event feed replays this write back at us as a bare
|
||||
// `NodeUpdated`, which is indistinguishable from the same photo being
|
||||
// favourited on a phone. Claim the echo, or the gallery pays for a
|
||||
// favorited on a phone. Claim the echo, or the gallery pays for a
|
||||
// resolve of a node whose one changed field is already stored below.
|
||||
self.note_self_change(uid);
|
||||
self.db
|
||||
@@ -1469,7 +1469,7 @@ impl Core {
|
||||
NodeKind::File { media_type, .. } => Some(media_type.clone()),
|
||||
NodeKind::Folder => None,
|
||||
};
|
||||
// The favourite state is a tag on the photo node, so it
|
||||
// The favorite state is a tag on the photo node, so it
|
||||
// rides along with the metadata resolve rather than
|
||||
// costing a listing of its own.
|
||||
let favorite = node
|
||||
@@ -1507,7 +1507,7 @@ impl Core {
|
||||
let key = it.uid.to_string();
|
||||
let main_uid = main_of.get(&key).cloned();
|
||||
// An unresolved photo keeps whatever was learned before, for the
|
||||
// favourite flag as much as for the name and media type.
|
||||
// favorite flag as much as for the name and media type.
|
||||
match meta.get(&key).cloned() {
|
||||
Some(m) => db::TimelineRow {
|
||||
name: m.name,
|
||||
|
||||
@@ -248,15 +248,19 @@ impl Core {
|
||||
|
||||
// ---- restore (features.md 5.2) ----------------------------------------
|
||||
|
||||
/// The folders under this machine's device that could be synced here, each
|
||||
/// carrying a proposed local path.
|
||||
/// The folders under a device that could be synced here, each carrying a
|
||||
/// proposed local path. `device` names another registered device by uid;
|
||||
/// `None` is this machine's own.
|
||||
///
|
||||
/// The proposal comes from the profile when one exists and its path is
|
||||
/// usable on this machine, and from `~/<name>` otherwise — a restored
|
||||
/// `/home/other-user/Documents` would be wrong on a machine where that user
|
||||
/// does not exist, and silently creating it would be worse.
|
||||
pub(crate) fn list_restorable_folders(&self) -> CoreResult<Vec<RestorableFolder>> {
|
||||
let device = self.ensure_device()?;
|
||||
pub(crate) fn list_restorable_folders(
|
||||
&self,
|
||||
device: Option<&str>,
|
||||
) -> CoreResult<Vec<RestorableFolder>> {
|
||||
let device = self.restore_source(device)?;
|
||||
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
|
||||
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
||||
})?;
|
||||
@@ -334,11 +338,12 @@ impl Core {
|
||||
pub(crate) fn restore_sync_folders(
|
||||
&self,
|
||||
items: &[pdfs_core::control::RestoreItem],
|
||||
device: Option<&str>,
|
||||
) -> CoreResult<String> {
|
||||
if items.is_empty() {
|
||||
return Err(CoreError::invalid("nothing to restore"));
|
||||
}
|
||||
let device = self.ensure_device()?;
|
||||
let device = self.restore_source(device)?;
|
||||
let existing = self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
@@ -499,6 +504,19 @@ mod tests {
|
||||
assert_before(add, "PROFILE_DIR_NAME", "ensure_device()");
|
||||
}
|
||||
|
||||
/// Restoring another computer's folders reads that device, but must not
|
||||
/// rewrite the cached device row: this machine would then back up as the
|
||||
/// other computer.
|
||||
#[test]
|
||||
fn restoring_from_another_device_keeps_this_machines_identity() {
|
||||
let source = function_source(include_str!("devices.rs"), "fn restore_source(");
|
||||
assert!(source.contains("ensure_device()"), "None is this machine");
|
||||
assert!(
|
||||
!source.contains("device_set"),
|
||||
"the cached row is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
fn function_source<'a>(source: &'a str, signature: &str) -> &'a str {
|
||||
let start = source.find(signature).expect("function signature exists");
|
||||
let body = &source[start..];
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! The user's view of the pending-op queue: what has not reached the remote
|
||||
//! yet, why, and a way to stop waiting out a backoff.
|
||||
//!
|
||||
//! Read-only apart from "retry now", which only moves a row's next-attempt time.
|
||||
//! Nothing here drops a queued op: its staged blob is the only copy of the
|
||||
//! user's write (see `docs/RECOVERY.md`).
|
||||
|
||||
use super::*;
|
||||
use pdfs_core::control::PendingOpInfo;
|
||||
use pdfs_core::db::{FAILING_ATTEMPTS, PARK_UNTIL};
|
||||
|
||||
impl Core {
|
||||
/// Every queued op, oldest first, with a path a person can read.
|
||||
pub(crate) fn pending_op_infos(&self) -> CoreResult<Vec<PendingOpInfo>> {
|
||||
let ops = self
|
||||
.db
|
||||
.pending_ops()
|
||||
.map_err(|e| CoreError::internal(format!("listing the queue: {e}")))?;
|
||||
Ok(ops
|
||||
.into_iter()
|
||||
.map(|op| {
|
||||
let parked = op.next_attempt_at >= PARK_UNTIL;
|
||||
let path = self
|
||||
.db
|
||||
.node_path(&op.uid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|path| !path.is_empty())
|
||||
.or_else(|| op.name.clone())
|
||||
.unwrap_or_else(|| op.uid.clone());
|
||||
PendingOpInfo {
|
||||
id: op.id,
|
||||
kind: op.kind,
|
||||
path,
|
||||
attempts: op.attempts,
|
||||
last_error: op.last_error,
|
||||
queued_at: op.created_at / 1000,
|
||||
next_attempt_at: (!parked).then_some(op.next_attempt_at / 1000),
|
||||
parked,
|
||||
failing: op.attempts >= FAILING_ATTEMPTS,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Retry one backed-off op now, or every failed one when `id` is `None`.
|
||||
/// Returns how many ops were moved up.
|
||||
pub(crate) fn retry_pending_ops(&self, id: Option<i64>) -> CoreResult<usize> {
|
||||
let now = now_millis();
|
||||
let moved = match id {
|
||||
Some(id) => self.db.retry_op_now(id, now).map(usize::from),
|
||||
None => self.db.retry_failed_ops_now(now),
|
||||
}
|
||||
.map_err(|e| CoreError::internal(format!("retrying the queue: {e}")))?;
|
||||
if moved > 0 {
|
||||
self.wake_drain();
|
||||
}
|
||||
Ok(moved)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
//!
|
||||
//! Proton has no API for editing a sealed capture time, so the repair is a
|
||||
//! rewrite: download the photo, upload it again with the capture time its file
|
||||
//! name implies, put the new copy back into the albums and favourites the old
|
||||
//! name implies, put the new copy back into the albums and favorites the old
|
||||
//! one was in, then **trash** (not delete) the original. Trash rather than
|
||||
//! delete so a bad run is recoverable from Proton's own trash for 30 days.
|
||||
//!
|
||||
@@ -213,7 +213,7 @@ impl Core {
|
||||
drop(guard);
|
||||
let new_uid = uploaded.map_err(|e| CoreError::from_api(&e, "re-upload photo"))?;
|
||||
|
||||
// Favourite and albums before the trash: a failure here leaves both
|
||||
// Favorite and albums before the trash: a failure here leaves both
|
||||
// copies on the account, which is recoverable by hand. Losing the
|
||||
// original first would not be.
|
||||
if candidate.favorite {
|
||||
@@ -227,7 +227,7 @@ impl Core {
|
||||
// just set does not come back as a remote change.
|
||||
Ok(_) => self.note_self_change(&new_uid),
|
||||
Err(e) => {
|
||||
warn!(name = candidate.name, error = %e, "cannot re-favourite re-dated photo")
|
||||
warn!(name = candidate.name, error = %e, "cannot re-favorite re-dated photo")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +388,66 @@ impl Core {
|
||||
.map_err(|e| CoreError::from_api(&e, "remove public link"))
|
||||
}
|
||||
|
||||
/// Stop sharing the node at `rel`: remove its public link, then every
|
||||
/// external invitation, Proton invitation and member. The SDK has no call
|
||||
/// that deletes a share outright, so this composes the per-entry ones.
|
||||
pub(crate) fn stop_sharing(&self, rel: &Path) -> CoreResult<StopSharingOutcome> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.stop_sharing_for_uid(&uid)
|
||||
}
|
||||
|
||||
/// [`Core::stop_sharing`] for a node addressed through any daemon location.
|
||||
pub(crate) fn stop_sharing_by_uid(&self, uid: &str) -> CoreResult<StopSharingOutcome> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.stop_sharing_for_uid(&uid)
|
||||
}
|
||||
|
||||
/// Each step runs even when an earlier one failed, so a single stuck
|
||||
/// member does not leave the public link up. The link goes first: it is the
|
||||
/// widest exposure. Only a failure to list what is there fails the whole
|
||||
/// request; a failed removal is counted in the outcome.
|
||||
fn stop_sharing_for_uid(&self, uid: &NodeUid) -> CoreResult<StopSharingOutcome> {
|
||||
let mut outcome = StopSharingOutcome::default();
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "get public link"))?;
|
||||
if let Some(link) = link {
|
||||
let result = self.rt.block_on(self.client.remove_public_link(&link));
|
||||
outcome.record("the public link", result);
|
||||
}
|
||||
let external = self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?;
|
||||
for ext in external {
|
||||
let result = self
|
||||
.rt
|
||||
.block_on(self.client.delete_external_invitation(&ext));
|
||||
outcome.record(&ext.invitee_email, result);
|
||||
}
|
||||
let invitations = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?;
|
||||
for inv in invitations {
|
||||
let result = self.rt.block_on(self.client.delete_invitation(&inv));
|
||||
outcome.record(&inv.invitee_email, result);
|
||||
}
|
||||
let members = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list members"))?;
|
||||
for member in members {
|
||||
let result = self.rt.block_on(self.client.remove_member(&member));
|
||||
outcome.record(&member.email, result);
|
||||
}
|
||||
if !outcome.failed.is_empty() {
|
||||
debug!(%uid, failed = ?outcome.failed, "stop sharing: some entries remain");
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
// ---- shared with me ---------------------------------------------------
|
||||
|
||||
/// List nodes shared with me that I have accepted.
|
||||
@@ -657,6 +717,47 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`Core::stop_sharing`] managed to remove.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct StopSharingOutcome {
|
||||
/// Entries removed (the link, invitations and members alike).
|
||||
pub(crate) removed: usize,
|
||||
/// What could not be removed, by email (or "the public link"), with why.
|
||||
pub(crate) failed: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl StopSharingOutcome {
|
||||
fn record<E: std::fmt::Display>(&mut self, what: &str, result: Result<(), E>) {
|
||||
match result {
|
||||
Ok(()) => self.removed += 1,
|
||||
Err(e) => self.failed.push((what.to_string(), e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The reply for the control socket: a sentence for a full success, an
|
||||
/// error naming what is left otherwise.
|
||||
pub(crate) fn into_result(self) -> Result<String, String> {
|
||||
if self.failed.is_empty() {
|
||||
return Ok(match self.removed {
|
||||
0 => "it was not shared".to_string(),
|
||||
1 => "stopped sharing: removed 1 entry".to_string(),
|
||||
n => format!("stopped sharing: removed {n} entries"),
|
||||
});
|
||||
}
|
||||
let left = self
|
||||
.failed
|
||||
.iter()
|
||||
.map(|(what, why)| format!("{what} ({why})"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Err(format!(
|
||||
"removed {} of {}; still shared: {left}",
|
||||
self.removed,
|
||||
self.removed + self.failed.len()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk one inode space from `uid` up to its root, building the path. `None` if
|
||||
/// that mount has never interned the node, or if the chain breaks partway —
|
||||
/// a broken chain is a missing path, never a partial one.
|
||||
@@ -671,3 +772,37 @@ fn walk_to_root(st: &super::State, uid: &NodeUid) -> Option<String> {
|
||||
parts.reverse();
|
||||
Some(parts.join("/"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stopping_a_share_that_is_gone_says_so() {
|
||||
let outcome = StopSharingOutcome::default();
|
||||
assert_eq!(outcome.into_result().unwrap(), "it was not shared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_removal_names_what_is_still_shared() {
|
||||
let mut outcome = StopSharingOutcome::default();
|
||||
outcome.record::<String>("the public link", Ok(()));
|
||||
outcome.record("bob@example.com", Err("forbidden"));
|
||||
outcome.record::<String>("carol@example.com", Ok(()));
|
||||
assert_eq!(
|
||||
outcome.into_result().unwrap_err(),
|
||||
"removed 2 of 3; still shared: bob@example.com (forbidden)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_removal_counts_the_entries() {
|
||||
let mut outcome = StopSharingOutcome::default();
|
||||
outcome.record::<String>("the public link", Ok(()));
|
||||
outcome.record::<String>("bob@example.com", Ok(()));
|
||||
assert_eq!(
|
||||
outcome.into_result().unwrap(),
|
||||
"stopped sharing: removed 2 entries"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -261,7 +261,7 @@ impl Core {
|
||||
/// is tracked in that fork's `State` — so consulting `self.state` alone
|
||||
/// answered "idle" for every such file and let the sweep delete one with a
|
||||
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
|
||||
fn is_busy(&self, uid: &NodeUid) -> bool {
|
||||
pub(crate) fn is_busy(&self, uid: &NodeUid) -> bool {
|
||||
let mut busy = false;
|
||||
self.for_each_state(|st| {
|
||||
let Some(&ino) = st.by_uid.get(uid) else {
|
||||
|
||||
@@ -82,6 +82,12 @@ impl Core {
|
||||
/// is guaranteed — a switch requested over the control socket cannot wait for a
|
||||
/// pass that may run for minutes.
|
||||
pub(crate) fn reconcile_folder(&self, folder: &StoredSyncFolder) {
|
||||
// A paused folder skips the settle too: a queued switch to on-demand
|
||||
// evicts the local tree, which is only safe after a pass uploaded it.
|
||||
if self.sync_folder_paused(folder.id) {
|
||||
debug!(id = folder.id, "sync: folder paused, skipping reconcile");
|
||||
return;
|
||||
}
|
||||
// `ondemand` folders are live FUSE mounts, not mirrored, so there is nothing
|
||||
// to reconcile — but one may still carry a queued switch back to `mirror`,
|
||||
// so the settle below is not skipped with it.
|
||||
|
||||
@@ -115,6 +115,9 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
reconcile_all(&core);
|
||||
} else {
|
||||
for id in ids {
|
||||
if core.sync_paused() {
|
||||
break;
|
||||
}
|
||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||
core.reconcile_folder(&folder);
|
||||
}
|
||||
@@ -151,6 +154,9 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
||||
reconcile_all(core);
|
||||
} else {
|
||||
for id in ids {
|
||||
if core.sync_paused() {
|
||||
break;
|
||||
}
|
||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||
core.reconcile_folder(&folder);
|
||||
}
|
||||
@@ -230,8 +236,14 @@ pub(super) fn classify(
|
||||
true
|
||||
}
|
||||
|
||||
/// Reconcile every mirror folder in turn.
|
||||
/// Reconcile every mirror folder in turn. Skipped while sync is paused: the
|
||||
/// resume sends a fresh [`SyncMsg::ReconcileAll`], which walks everything the
|
||||
/// watcher reported meanwhile.
|
||||
fn reconcile_all(core: &Core) {
|
||||
if core.sync_paused() {
|
||||
debug!("sync: paused, skipping reconcile");
|
||||
return;
|
||||
}
|
||||
let folders = match core.db.sync_folder_list() {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
|
||||
@@ -357,9 +357,9 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
// Favourites are a separate call: the upload seal takes classification
|
||||
// Favorites are a separate call: the upload seal takes classification
|
||||
// tags, but `Favorite` is not one of them — it goes through the photos
|
||||
// volume's dedicated favourite endpoint.
|
||||
// volume's dedicated favorite endpoint.
|
||||
if !favorites.is_empty() {
|
||||
let updates: Vec<PhotoTagsUpdate> = favorites
|
||||
.into_iter()
|
||||
@@ -370,7 +370,7 @@ impl Core {
|
||||
})
|
||||
.collect();
|
||||
if let Err(e) = self.rt.block_on(photos.update_photos(&updates)) {
|
||||
warn!(error = %e, "marking imported favourites failed");
|
||||
warn!(error = %e, "marking imported favorites failed");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
//! always reflects exactly what is in flight — even if the work fails partway
|
||||
//! and unwinds.
|
||||
//!
|
||||
//! The same per-block tick enforces the user's bandwidth limits: every byte a
|
||||
//! transfer moves is charged to its direction's [`RateLimit`], and the thread
|
||||
//! moving it sleeps off whatever the charge puts it over the cap.
|
||||
//!
|
||||
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
|
||||
|
||||
use parking_lot::Mutex;
|
||||
@@ -20,7 +24,7 @@ use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
|
||||
|
||||
@@ -52,6 +56,59 @@ pub struct TransferRegistry {
|
||||
inner: Mutex<HashMap<u64, Arc<Entry>>>,
|
||||
jobs: Mutex<HashMap<u64, Arc<JobEntry>>>,
|
||||
next: AtomicU64,
|
||||
upload_limit: RateLimit,
|
||||
download_limit: RateLimit,
|
||||
}
|
||||
|
||||
/// How far ahead of its cap a direction may run before it is made to wait, so a
|
||||
/// small file still goes out at full speed.
|
||||
const RATE_BURST: Duration = Duration::from_secs(1);
|
||||
|
||||
/// A bytes-per-second cap shared by every transfer in one direction; `0` is no
|
||||
/// cap.
|
||||
///
|
||||
/// A virtual clock rather than a refilled bucket: each charge pushes
|
||||
/// `next_free` forward by the time its bytes take at the cap, so concurrent
|
||||
/// transfers split the cap between them instead of each getting all of it.
|
||||
#[derive(Default)]
|
||||
struct RateLimit {
|
||||
rate: AtomicU64,
|
||||
next_free: Mutex<Option<Instant>>,
|
||||
}
|
||||
|
||||
impl RateLimit {
|
||||
fn set(&self, rate: u64) {
|
||||
self.rate.store(rate, Ordering::Relaxed);
|
||||
// A new cap starts from now, not from the debt the old one ran up.
|
||||
*self.next_free.lock() = None;
|
||||
}
|
||||
|
||||
/// Charge `n` bytes moved at `now`, returning how long the mover must wait
|
||||
/// to stay under the cap.
|
||||
fn charge(&self, n: u64, now: Instant) -> Duration {
|
||||
let rate = self.rate.load(Ordering::Relaxed);
|
||||
if rate == 0 || n == 0 {
|
||||
return Duration::ZERO;
|
||||
}
|
||||
let mut next_free = self.next_free.lock();
|
||||
let start = next_free.filter(|t| *t > now).unwrap_or(now);
|
||||
let done = start + Duration::from_secs_f64(n as f64 / rate as f64);
|
||||
*next_free = Some(done);
|
||||
done.saturating_duration_since(now + RATE_BURST)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sleep off a rate-limit wait. The bulk uploader reads on tokio workers, so
|
||||
/// the sleep is announced to the runtime there rather than silently holding a
|
||||
/// worker other tasks — FUSE reads among them — may need.
|
||||
fn throttle(wait: Duration) {
|
||||
use tokio::runtime::{Handle, RuntimeFlavor};
|
||||
match Handle::try_current() {
|
||||
Ok(handle) if handle.runtime_flavor() == RuntimeFlavor::MultiThread => {
|
||||
tokio::task::block_in_place(|| std::thread::sleep(wait))
|
||||
}
|
||||
_ => std::thread::sleep(wait),
|
||||
}
|
||||
}
|
||||
|
||||
impl TransferRegistry {
|
||||
@@ -59,6 +116,20 @@ impl TransferRegistry {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
/// Cap uploads and downloads at these bytes per second; `0` lifts a cap.
|
||||
/// Applies to transfers already running from their next block.
|
||||
pub fn set_limits(&self, upload: u64, download: u64) {
|
||||
self.upload_limit.set(upload);
|
||||
self.download_limit.set(download);
|
||||
}
|
||||
|
||||
fn limit(&self, direction: TransferDirection) -> &RateLimit {
|
||||
match direction {
|
||||
TransferDirection::Upload => &self.upload_limit,
|
||||
TransferDirection::Download => &self.download_limit,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new transfer of `total` bytes (`0` = unknown), returning a
|
||||
/// guard that tracks progress and deregisters the transfer when dropped.
|
||||
pub fn begin(
|
||||
@@ -169,9 +240,17 @@ pub struct TransferGuard {
|
||||
}
|
||||
|
||||
impl TransferGuard {
|
||||
/// Record `n` more bytes moved.
|
||||
/// Record `n` more bytes moved, then wait if they put the direction over
|
||||
/// its cap.
|
||||
pub fn add(&self, n: u64) {
|
||||
self.entry.done.fetch_add(n, Ordering::Relaxed);
|
||||
let wait = self
|
||||
.reg
|
||||
.limit(self.entry.direction)
|
||||
.charge(n, Instant::now());
|
||||
if !wait.is_zero() {
|
||||
throttle(wait);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,3 +400,37 @@ impl<R: Read> Read for CountingReader<'_, R> {
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_uncapped_direction_never_waits() {
|
||||
let limit = RateLimit::default();
|
||||
assert_eq!(limit.charge(u64::MAX / 2, Instant::now()), Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_capped_direction_waits_once_past_its_burst() {
|
||||
let limit = RateLimit::default();
|
||||
limit.set(1000);
|
||||
let now = Instant::now();
|
||||
// One second of bytes is the burst: no wait yet.
|
||||
assert_eq!(limit.charge(1000, now), Duration::ZERO);
|
||||
// Two more seconds' worth at the same instant: wait out all but the burst.
|
||||
assert_eq!(limit.charge(2000, now), Duration::from_secs(2));
|
||||
// Lifting the cap drops the debt with it.
|
||||
limit.set(0);
|
||||
assert_eq!(limit.charge(5000, now), Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_transfers_share_one_cap() {
|
||||
let limit = RateLimit::default();
|
||||
limit.set(1000);
|
||||
let now = Instant::now();
|
||||
assert_eq!(limit.charge(1500, now), Duration::from_millis(500));
|
||||
assert_eq!(limit.charge(1500, now), Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ fn main() {
|
||||
&format!("{}/pdfs.gresource", out_dir),
|
||||
"--sourcedir",
|
||||
"resources/icons",
|
||||
"--sourcedir",
|
||||
"resources",
|
||||
"resources/pdfs.gresource.xml",
|
||||
])
|
||||
.status()
|
||||
@@ -22,10 +24,6 @@ fn main() {
|
||||
}
|
||||
|
||||
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
||||
println!(
|
||||
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg"
|
||||
);
|
||||
println!(
|
||||
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg"
|
||||
);
|
||||
println!("cargo:rerun-if-changed=resources/icons");
|
||||
println!("cargo:rerun-if-changed=resources/style.css");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- "Available offline": a filled disc with a down arrow knocked out of it
|
||||
(one path, fill-rule evenodd, so symbolic recolouring keeps working).
|
||||
Replaces the star, which every desktop uses for "favourite". -->
|
||||
<path fill="#222222" fill-rule="evenodd" d="M 8,1 A 7,7 0 1 0 8,15 A 7,7 0 1 0 8,1 Z M 7,4 H 9 V 8.6 L 10.8,6.8 L 12.2,8.2 L 8,12.4 L 3.8,8.2 L 5.2,6.8 L 7,8.6 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 485 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- "Online only": a cloud outline. Lighter than the stock weather icon, so a
|
||||
folder full of cloud-only files does not shout. -->
|
||||
<path fill="#222222" fill-rule="evenodd" d="M 8.5,3 C 6.6,3 5,4.3 4.6,6.1 C 2.6,6.3 1,8 1,10 C 1,12.2 2.8,14 5,14 H 12 C 13.7,14 15,12.7 15,11 C 15,9.5 13.9,8.2 12.4,8 C 12.1,5.2 10.5,3 8.5,3 Z M 8.5,4.5 C 9.9,4.5 11,5.9 11,8 V 9.4 H 12 C 12.8,9.4 13.5,10.1 13.5,11 C 13.5,11.8 12.8,12.5 12,12.5 H 5 C 3.6,12.5 2.5,11.4 2.5,10 C 2.5,8.6 3.6,7.5 5,7.5 H 5.9 L 6,6.7 C 6.2,5.4 7.2,4.5 8.5,4.5 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 629 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- Upload-files action: an up arrow over a tray, the pair to
|
||||
pdfs-folder-upload-symbolic. Stock themes only offer `document-send`,
|
||||
which reads as "send by mail". -->
|
||||
<path fill="#222222" d="M 8,1 L 3.5,5.5 L 4.9,6.9 L 7,4.8 V 11 H 9 V 4.8 L 11.1,6.9 L 12.5,5.5 Z"/>
|
||||
<path fill="#222222" d="M 1,10 V 14 C 1,14.6 1.4,15 2,15 H 14 C 14.6,15 15,14.6 15,14 V 10 H 13 V 13 H 3 V 10 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 494 B |
@@ -6,5 +6,14 @@
|
||||
<gresource prefix="/de/nils/protondrivelinux/icons">
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-upload-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-offline-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file>
|
||||
</gresource>
|
||||
<!-- The app stylesheet, loaded once at startup. Colours come from the
|
||||
libadwaita palette so the app follows the system accent, light/dark and
|
||||
high contrast; the Proton purple accent is a separate, opt-in provider. -->
|
||||
<gresource prefix="/de/nils/protondrivelinux">
|
||||
<file>style.css</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
@@ -0,0 +1,85 @@
|
||||
/* Proton Drive for Linux — app stylesheet.
|
||||
*
|
||||
* Colours come from the libadwaita palette (var(--accent-bg-color),
|
||||
* var(--success-color), ...) so the app follows the system accent, light/dark
|
||||
* and high contrast. The one exception is the photo viewer, which always sits
|
||||
* on a near-black backdrop and so uses fixed light-on-dark colours. The Proton
|
||||
* purple accent is opt-in and lives in its own provider (see main.rs).
|
||||
*/
|
||||
|
||||
/* Brand */
|
||||
.brand-title { font-size: 1.6rem; font-weight: 800; }
|
||||
.brand-icon { color: #6d4aff; }
|
||||
|
||||
/* Sidebar footer */
|
||||
.sidebar-footer { padding: 6px 12px 12px 12px; }
|
||||
.sidebar-status { padding: 6px 8px; border-radius: 8px; }
|
||||
.sidebar-status image.success { color: var(--success-color); }
|
||||
.sidebar-status image.warning { color: var(--warning-color); }
|
||||
.sidebar-status image.error { color: var(--error-color); }
|
||||
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
|
||||
.sidebar-account { padding: 4px 4px 4px 8px; }
|
||||
|
||||
/* File browser */
|
||||
.file-grid { padding: 6px; }
|
||||
.file-tile { padding: 8px; border-radius: 10px; }
|
||||
.file-tile:hover { background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
|
||||
.file-tile:selected, .file-tile:hover:selected { background: color-mix(in srgb, var(--accent-bg-color) 20%, transparent); }
|
||||
.file-thumbnail { border-radius: 7px; }
|
||||
.file-badge { -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }
|
||||
.badge-pinned { color: var(--yellow-3); }
|
||||
.badge-cached { color: var(--success-color); }
|
||||
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
||||
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
|
||||
scale.browser-status-meter trough, progressbar.browser-status-meter trough { min-width: 104px; }
|
||||
scale.browser-status-meter trough { min-height: 6px; }
|
||||
progressbar.browser-status-meter trough, progressbar.browser-status-meter progress { min-height: 6px; }
|
||||
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
|
||||
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
|
||||
.dropzone-active { border-color: var(--accent-bg-color); background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
|
||||
.files-drop-active { outline: 2px dashed var(--accent-bg-color); outline-offset: -4px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 6%, transparent); }
|
||||
|
||||
/* Photo viewer: always light-on-dark, whatever the system style */
|
||||
.photo-viewer-window { background-color: #111014; }
|
||||
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }
|
||||
.viewer-title { font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }
|
||||
.viewer-counter { font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
|
||||
.viewer-action-btn { color: white; background-color: rgba(255, 255, 255, 0.12); border-radius: 50%; min-width: 34px; min-height: 34px; margin-left: 4px; }
|
||||
.viewer-action-btn:hover { background-color: rgba(255, 255, 255, 0.28); color: white; }
|
||||
.viewer-action-btn:checked { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
|
||||
.viewer-close-btn { background-color: rgba(255, 255, 255, 0.2); margin-left: 10px; }
|
||||
.viewer-close-btn:hover { background-color: var(--destructive-bg-color); color: var(--destructive-fg-color); }
|
||||
.viewer-nav-btn { background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }
|
||||
.viewer-nav-btn:hover { background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }
|
||||
.viewer-nav-btn:disabled { opacity: 0; }
|
||||
.photo-scrubber { min-width: 20px; padding: 0 2px; }
|
||||
.photo-scrubber value { font-weight: 600; background-color: var(--popover-bg-color); border-radius: 6px; padding: 2px 8px; }
|
||||
.viewer-spinner { color: white; }
|
||||
.viewer-status { color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }
|
||||
.viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); }
|
||||
|
||||
/* Photos grid */
|
||||
.gallery-day { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 4px 2px 8px 2px; }
|
||||
.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 6px; background: none; box-shadow: none; transition: box-shadow 180ms ease; }
|
||||
.photo-tile:hover { box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }
|
||||
.photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; }
|
||||
.photo-thumb { transition: transform 220ms ease; }
|
||||
.photo-tile:hover .photo-thumb { transform: scale(1.06); }
|
||||
.photo-check { color: white; background: rgba(0, 0, 0, 0.5); border-radius: 999px; padding: 3px; }
|
||||
.photo-check-on { background: var(--accent-bg-color); color: var(--accent-fg-color); }
|
||||
.photo-tile-selected { outline: 3px solid var(--accent-bg-color); outline-offset: -3px; }
|
||||
.photo-placeholder { color: alpha(currentColor, 0.35); background: alpha(currentColor, 0.07); }
|
||||
.photo-caption { font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; }
|
||||
.photo-tile:hover .photo-caption { opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }
|
||||
.photo-video-badge { color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }
|
||||
.photo-tile:hover .photo-video-badge { background: color-mix(in srgb, var(--accent-bg-color) 85%, transparent); }
|
||||
.photo-group-badge { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); }
|
||||
.album-card { padding: 0; border-radius: 16px; transition: background 180ms ease; }
|
||||
.album-card:hover { background: alpha(currentColor, 0.07); }
|
||||
.album-cover { border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }
|
||||
.album-card:hover .album-cover { box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); }
|
||||
.album-card:hover .photo-thumb { transform: scale(1.05); }
|
||||
.view-switch button { padding-left: 18px; padding-right: 18px; }
|
||||
|
||||
/* Shared with Me: what a share lets me do, as a tinted pill beside the name. */
|
||||
.role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); }
|
||||
+590
-196
File diff suppressed because it is too large.
Load diff
@@ -1,48 +1,132 @@
|
||||
use crate::*;
|
||||
|
||||
pub(crate) struct ActivityState {
|
||||
// Activity page: one section, newest-first, rebuilt wholesale on each load.
|
||||
// Activity page: a "Needs attention" section for live conflicts, then the
|
||||
// feed grouped by day, rebuilt wholesale on each change.
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||
pub(crate) attention: adw::PreferencesGroup,
|
||||
pub(crate) attention_rows: RefCell<Vec<adw::ActionRow>>,
|
||||
pub(crate) days: gtk4::Box,
|
||||
pub(crate) 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.
|
||||
/// Fingerprint of what is on screen (see [`activity_key`]). The page polls
|
||||
/// every couple of seconds and usually gets back exactly what it is already
|
||||
/// showing; rebuilding every row for that would throw away the user's
|
||||
/// scroll position several times a minute.
|
||||
pub(crate) key: RefCell<Option<String>>,
|
||||
/// The last feed the daemon sent, so a filter change repaints without a
|
||||
/// round-trip.
|
||||
pub(crate) items: RefCell<Vec<ActivityEntry>>,
|
||||
pub(crate) filter: Cell<ActivityFilter>,
|
||||
/// Conflict copies still waiting for a decision, or `None` before the first
|
||||
/// listing. Lets a logged conflict read as resolved once it is gone.
|
||||
pub(crate) conflicts: RefCell<Option<Vec<ConflictInfo>>>,
|
||||
pub(crate) conflicts_inflight: Cell<bool>,
|
||||
pub(crate) conflicts_at: Cell<Option<Instant>>,
|
||||
}
|
||||
|
||||
/// Widgets the Activity page's load/repaint touch.
|
||||
pub(crate) struct ActivityWidgets {
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) attention: adw::PreferencesGroup,
|
||||
pub(crate) days: gtk4::Box,
|
||||
pub(crate) filters: Vec<(ActivityFilter, gtk4::ToggleButton)>,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
}
|
||||
|
||||
/// The Activity page: a newest-first feed of the mutations and transfers the
|
||||
/// daemon performed this session (uploads, deletes, shares, …).
|
||||
pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Activity")
|
||||
.halign(gtk4::Align::Start)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
let refresh = refresh_button();
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header.set_hexpand(true);
|
||||
header.append(&title);
|
||||
header.append(&refresh);
|
||||
/// Which slice of the feed the filter bar shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ActivityFilter {
|
||||
All,
|
||||
Transfers,
|
||||
Changes,
|
||||
Sharing,
|
||||
Problems,
|
||||
}
|
||||
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let clamp = adw::Clamp::builder().child(&group).build();
|
||||
impl ActivityFilter {
|
||||
const ALL: [ActivityFilter; 5] = [
|
||||
ActivityFilter::All,
|
||||
ActivityFilter::Transfers,
|
||||
ActivityFilter::Changes,
|
||||
ActivityFilter::Sharing,
|
||||
ActivityFilter::Problems,
|
||||
];
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
ActivityFilter::All => "All",
|
||||
ActivityFilter::Transfers => "Transfers",
|
||||
ActivityFilter::Changes => "Changes",
|
||||
ActivityFilter::Sharing => "Sharing",
|
||||
ActivityFilter::Problems => "Problems",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an entry belongs in this slice. A failed action is a problem
|
||||
/// whatever it was, and also stays under its own kind.
|
||||
pub(crate) fn matches(self, entry: &ActivityEntry) -> bool {
|
||||
use ActivityKind::*;
|
||||
match self {
|
||||
ActivityFilter::All => true,
|
||||
ActivityFilter::Transfers => matches!(entry.kind, Upload | Download | Sync),
|
||||
ActivityFilter::Changes => matches!(
|
||||
entry.kind,
|
||||
Rename | Move | CreateFolder | Trash | Restore | DeleteForever | EmptyTrash
|
||||
),
|
||||
ActivityFilter::Sharing => matches!(entry.kind, Share | PublicLink | Unshare),
|
||||
ActivityFilter::Problems => !entry.ok || entry.kind == Conflict,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Activity page: a newest-first feed of the mutations and transfers the
|
||||
/// daemon performed (uploads, deletes, shares, …), grouped by day, with the
|
||||
/// conflicts that still need a decision on top.
|
||||
pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
||||
let refresh = refresh_button();
|
||||
|
||||
// Filter chips: a linked row of radio toggles.
|
||||
let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
bar.add_css_class("linked");
|
||||
bar.set_halign(gtk4::Align::Start);
|
||||
let mut filters: Vec<(ActivityFilter, gtk4::ToggleButton)> = Vec::new();
|
||||
for filter in ActivityFilter::ALL {
|
||||
let button = gtk4::ToggleButton::with_label(filter.label());
|
||||
if let Some((_, first)) = filters.first() {
|
||||
button.set_group(Some(first));
|
||||
} else {
|
||||
button.set_active(true);
|
||||
}
|
||||
bar.append(&button);
|
||||
filters.push((filter, button));
|
||||
}
|
||||
|
||||
let attention = adw::PreferencesGroup::builder()
|
||||
.title("Needs attention")
|
||||
.visible(false)
|
||||
.build();
|
||||
let days = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
|
||||
|
||||
let column = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
|
||||
column.set_margin_top(18);
|
||||
column.set_margin_bottom(18);
|
||||
column.set_margin_start(18);
|
||||
column.set_margin_end(18);
|
||||
column.append(&bar);
|
||||
column.append(&attention);
|
||||
column.append(&days);
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(900)
|
||||
.tightening_threshold(600)
|
||||
.child(&column)
|
||||
.build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
@@ -66,20 +150,17 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
||||
content.add_named(&scroll, Some("list"));
|
||||
content.add_named(&status, Some("status"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(12);
|
||||
inner.set_margin_bottom(12);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&header);
|
||||
inner.append(&content);
|
||||
let (frame, header, _) = page_frame("Activity", &content);
|
||||
header.pack_end(&refresh);
|
||||
|
||||
(
|
||||
inner.upcast(),
|
||||
frame.upcast(),
|
||||
ActivityWidgets {
|
||||
content,
|
||||
status,
|
||||
group,
|
||||
attention,
|
||||
days,
|
||||
filters,
|
||||
retry,
|
||||
refresh,
|
||||
},
|
||||
@@ -87,12 +168,26 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
||||
}
|
||||
|
||||
/// Install the Activity page's retry button.
|
||||
pub(crate) fn wire_activity(ui: &Rc<Ui>, retry: >k4::Button) {
|
||||
pub(crate) fn wire_activity(
|
||||
ui: &Rc<Ui>,
|
||||
retry: >k4::Button,
|
||||
filters: &[(ActivityFilter, gtk4::ToggleButton)],
|
||||
) {
|
||||
let ui_retry = ui.clone();
|
||||
retry.connect_clicked(move |_| {
|
||||
service::restart();
|
||||
load_activity(&ui_retry);
|
||||
});
|
||||
for (filter, button) in filters {
|
||||
let ui = ui.clone();
|
||||
let filter = *filter;
|
||||
button.connect_toggled(move |button| {
|
||||
if button.is_active() {
|
||||
ui.activity.filter.set(filter);
|
||||
paint_activity(&ui);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a status page in place of the Activity list.
|
||||
@@ -118,6 +213,7 @@ pub(crate) fn activity_status(
|
||||
/// 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.
|
||||
pub(crate) fn refresh_activity(ui: &Rc<Ui>) {
|
||||
refresh_activity_conflicts(ui, false);
|
||||
if ui.activity.inflight.get() {
|
||||
return;
|
||||
}
|
||||
@@ -141,6 +237,7 @@ pub(crate) fn refresh_activity(ui: &Rc<Ui>) {
|
||||
}
|
||||
|
||||
pub(crate) fn load_activity(ui: &Rc<Ui>) {
|
||||
refresh_activity_conflicts(ui, true);
|
||||
if ui.activity.inflight.get() {
|
||||
return;
|
||||
}
|
||||
@@ -210,28 +307,143 @@ pub(crate) fn activity_unreachable(ui: &Rc<Ui>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(crate) 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.
|
||||
pub(crate) fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) {
|
||||
let key = activity_key(items);
|
||||
if ui.activity.key.borrow().as_deref() == Some(key.as_str()) {
|
||||
/// Poll the conflicts still waiting for a decision while the Activity page is
|
||||
/// on screen. `force` skips the TTL, for navigation and right after a
|
||||
/// resolution. Listing walks every node the daemon knows, so the tick asks far
|
||||
/// less often than it does for the feed.
|
||||
pub(crate) fn refresh_activity_conflicts(ui: &Rc<Ui>, force: bool) {
|
||||
let state = &ui.activity;
|
||||
if state.conflicts_inflight.get()
|
||||
|| (!force
|
||||
&& state
|
||||
.conflicts_at
|
||||
.get()
|
||||
.is_some_and(|at| at.elapsed() < CONFLICTS_TTL))
|
||||
{
|
||||
return;
|
||||
}
|
||||
*ui.activity.key.borrow_mut() = Some(key);
|
||||
for row in ui.activity.rows.borrow_mut().drain(..) {
|
||||
ui.activity.group.remove(&row);
|
||||
state.conflicts_inflight.set(true);
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListConflicts);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
let state = &ui.activity;
|
||||
state.conflicts_inflight.set(false);
|
||||
state.conflicts_at.set(Some(Instant::now()));
|
||||
// An older daemon, or none, has no list: leave the logged conflicts as
|
||||
// they are rather than calling them resolved.
|
||||
let items = match result {
|
||||
Ok(Ok(Response::Conflicts { items })) => Some(items),
|
||||
_ => None,
|
||||
};
|
||||
*state.conflicts.borrow_mut() = items;
|
||||
if ui.stack.visible_child_name().as_deref() == Some("activity") {
|
||||
paint_activity(&ui);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A cheap fingerprint of what the page shows: the feed's length and newest
|
||||
/// entry (the log is append-only and newest-first, so that pins the feed), the
|
||||
/// filter, and which conflicts are still open.
|
||||
pub(crate) fn activity_key(
|
||||
items: &[ActivityEntry],
|
||||
filter: ActivityFilter,
|
||||
conflicts: Option<&[ConflictInfo]>,
|
||||
) -> String {
|
||||
let newest = match items.first() {
|
||||
Some(a) => format!("{}:{}:{}:{}", items.len(), a.time, a.target, a.detail),
|
||||
None => String::new(),
|
||||
};
|
||||
let open = conflicts.map(|c| {
|
||||
c.iter()
|
||||
.map(|c| c.path.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
});
|
||||
format!("{newest}|{filter:?}|{open:?}")
|
||||
}
|
||||
|
||||
/// Keep a fresh log from the daemon and repaint with it.
|
||||
pub(crate) fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) {
|
||||
*ui.activity.items.borrow_mut() = items.to_vec();
|
||||
paint_activity(ui);
|
||||
}
|
||||
|
||||
/// One feed row: an entry and how many times it was logged in a row (or, for
|
||||
/// a conflict, at all).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct FeedRow<'a> {
|
||||
pub(crate) entry: &'a ActivityEntry,
|
||||
pub(crate) count: usize,
|
||||
/// When the oldest collapsed repeat happened.
|
||||
pub(crate) first: i64,
|
||||
}
|
||||
|
||||
/// Collapse repeats so the feed reads as news. The daemon flags a divergent
|
||||
/// conflict once per run, so the same copy comes back every day it stays
|
||||
/// unresolved: every conflict entry folds into its newest mention. Any other
|
||||
/// entry folds only into an identical one right before it (a retry loop
|
||||
/// failing the same way).
|
||||
pub(crate) fn collapse_feed(items: &[ActivityEntry]) -> Vec<FeedRow<'_>> {
|
||||
let same = |a: &ActivityEntry, b: &ActivityEntry| {
|
||||
a.kind == b.kind && a.ok == b.ok && a.target == b.target && a.detail == b.detail
|
||||
};
|
||||
let mut rows: Vec<FeedRow> = Vec::new();
|
||||
for entry in items {
|
||||
let prior = if entry.kind == ActivityKind::Conflict {
|
||||
rows.iter_mut().find(|row| same(row.entry, entry))
|
||||
} else {
|
||||
rows.last_mut().filter(|row| same(row.entry, entry))
|
||||
};
|
||||
match prior {
|
||||
Some(row) => {
|
||||
row.count += 1;
|
||||
row.first = row.first.min(entry.time);
|
||||
}
|
||||
None => rows.push(FeedRow {
|
||||
entry,
|
||||
count: 1,
|
||||
first: entry.time,
|
||||
}),
|
||||
}
|
||||
}
|
||||
if items.is_empty() {
|
||||
rows
|
||||
}
|
||||
|
||||
/// A section title for the day `at` falls on, seen from `now`: "Today",
|
||||
/// "Yesterday", a weekday within the week, then a date.
|
||||
pub(crate) fn day_label(at: &glib::DateTime, now: &glib::DateTime) -> String {
|
||||
let today = now.ymd();
|
||||
if at.ymd() == today {
|
||||
return "Today".to_string();
|
||||
}
|
||||
if now.add_days(-1).is_ok_and(|y| y.ymd() == at.ymd()) {
|
||||
return "Yesterday".to_string();
|
||||
}
|
||||
let format = if now.difference(at).as_seconds() < 6 * 86_400 {
|
||||
"%A"
|
||||
} else if at.year() == now.year() {
|
||||
"%A, %B %-d"
|
||||
} else {
|
||||
"%B %-d, %Y"
|
||||
};
|
||||
at.format(format).map(|s| s.to_string()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Rebuild the page from the kept feed and conflict list, unless it already
|
||||
/// shows exactly this.
|
||||
pub(crate) fn paint_activity(ui: &Rc<Ui>) {
|
||||
let state = &ui.activity;
|
||||
let items = state.items.borrow();
|
||||
let conflicts = state.conflicts.borrow();
|
||||
let filter = state.filter.get();
|
||||
let key = activity_key(&items, filter, conflicts.as_deref());
|
||||
if state.key.borrow().as_deref() == Some(key.as_str()) {
|
||||
return;
|
||||
}
|
||||
let open: &[ConflictInfo] = conflicts.as_deref().unwrap_or_default();
|
||||
if items.is_empty() && open.is_empty() {
|
||||
activity_status(
|
||||
ui,
|
||||
"document-open-recent-symbolic",
|
||||
@@ -241,28 +453,149 @@ pub(crate) fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
ui.activity.content.set_visible_child_name("list");
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
for a in items {
|
||||
let mut subtitle = activity_time(a.time);
|
||||
if !a.detail.is_empty() {
|
||||
subtitle = format!("{subtitle} · {}", a.detail);
|
||||
}
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(format!("{} {}", activity_verb(a.kind), a.target))
|
||||
.subtitle(subtitle)
|
||||
.build();
|
||||
let icon = if a.ok {
|
||||
activity_icon(a.kind)
|
||||
} else {
|
||||
"dialog-warning-symbolic"
|
||||
};
|
||||
row.add_prefix(>k4::Image::from_icon_name(icon));
|
||||
row.set_activatable(false);
|
||||
ui.activity.group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
*state.key.borrow_mut() = Some(key);
|
||||
state.content.set_visible_child_name("list");
|
||||
|
||||
// Needs attention: the live conflicts, each opening the resolve dialog.
|
||||
for row in state.attention_rows.borrow_mut().drain(..) {
|
||||
state.attention.remove(&row);
|
||||
}
|
||||
*ui.activity.rows.borrow_mut() = rows;
|
||||
state.attention.set_visible(!open.is_empty());
|
||||
state.attention.set_description(Some(&match open.len() {
|
||||
1 => "A file was changed in two places at once. Choose which version to keep.".to_string(),
|
||||
n => {
|
||||
format!("{n} files were changed in two places at once. Choose which versions to keep.")
|
||||
}
|
||||
}));
|
||||
let mut attention_rows = state.attention_rows.borrow_mut();
|
||||
for conflict in open {
|
||||
let row = conflict_row(ui, conflict);
|
||||
state.attention.add(&row);
|
||||
attention_rows.push(row);
|
||||
}
|
||||
drop(attention_rows);
|
||||
|
||||
while let Some(child) = state.days.first_child() {
|
||||
state.days.remove(&child);
|
||||
}
|
||||
let rows: Vec<FeedRow> = collapse_feed(&items)
|
||||
.into_iter()
|
||||
.filter(|row| filter.matches(row.entry))
|
||||
.collect();
|
||||
if rows.is_empty() {
|
||||
let empty = adw::StatusPage::builder()
|
||||
.icon_name("edit-find-symbolic")
|
||||
.title("No matching activity")
|
||||
.description("Nothing in the recent log fits this filter.")
|
||||
.build();
|
||||
empty.add_css_class("compact");
|
||||
state.days.append(&empty);
|
||||
return;
|
||||
}
|
||||
let now = glib::DateTime::now_local().ok();
|
||||
let mut group: Option<(String, adw::PreferencesGroup)> = None;
|
||||
for row in rows {
|
||||
let at = glib::DateTime::from_unix_local(row.entry.time).ok();
|
||||
let label = match (&at, &now) {
|
||||
(Some(at), Some(now)) => day_label(at, now),
|
||||
_ => String::new(),
|
||||
};
|
||||
if group.as_ref().is_none_or(|(day, _)| *day != label) {
|
||||
let section = adw::PreferencesGroup::builder().title(&label).build();
|
||||
state.days.append(§ion);
|
||||
group = Some((label, section));
|
||||
}
|
||||
if let Some((_, section)) = &group {
|
||||
section.add(&feed_row(ui, &row, at.as_ref(), open));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the feed.
|
||||
fn feed_row(
|
||||
ui: &Rc<Ui>,
|
||||
row: &FeedRow,
|
||||
at: Option<&glib::DateTime>,
|
||||
open: &[ConflictInfo],
|
||||
) -> adw::ActionRow {
|
||||
let a = row.entry;
|
||||
let title = match a.kind {
|
||||
// The target of an empty is a count, not a name to lead with a verb.
|
||||
ActivityKind::EmptyTrash => format!("Emptied Trash ({})", a.target),
|
||||
kind => format!("{} {}", activity_verb(kind), a.target),
|
||||
};
|
||||
// A logged conflict names the copy; it is still open while the daemon
|
||||
// still lists a copy by that name.
|
||||
let pending = (a.kind == ActivityKind::Conflict)
|
||||
.then(|| open.iter().find(|c| file_name(&c.path) == a.target))
|
||||
.flatten();
|
||||
let resolved = a.kind == ActivityKind::Conflict
|
||||
&& pending.is_none()
|
||||
&& ui.activity.conflicts.borrow().is_some();
|
||||
|
||||
let mut subtitle: Vec<String> = Vec::new();
|
||||
if !a.detail.is_empty() {
|
||||
subtitle.push(capitalize(&a.detail));
|
||||
}
|
||||
if row.count > 1 {
|
||||
subtitle.push(format!(
|
||||
"{} times since {}",
|
||||
row.count,
|
||||
activity_time(row.first)
|
||||
));
|
||||
}
|
||||
if resolved {
|
||||
subtitle.push("Resolved".to_string());
|
||||
}
|
||||
let widget = adw::ActionRow::builder()
|
||||
.title(title)
|
||||
.subtitle(subtitle.join(" · "))
|
||||
.use_markup(false)
|
||||
.title_lines(1)
|
||||
.subtitle_lines(2)
|
||||
.build();
|
||||
|
||||
// A failure and a conflict are different news: a conflict kept both
|
||||
// copies, a failure lost the action. Each gets its own icon and colour.
|
||||
let icon = gtk4::Image::from_icon_name(if resolved {
|
||||
"object-select-symbolic"
|
||||
} else if a.ok || a.kind == ActivityKind::Conflict {
|
||||
activity_icon(a.kind)
|
||||
} else {
|
||||
"dialog-error-symbolic"
|
||||
});
|
||||
if resolved {
|
||||
icon.add_css_class("success");
|
||||
} else if a.kind == ActivityKind::Conflict {
|
||||
icon.add_css_class("warning");
|
||||
} else if !a.ok {
|
||||
icon.add_css_class("error");
|
||||
}
|
||||
widget.add_prefix(&icon);
|
||||
|
||||
let time = gtk4::Label::new(Some(
|
||||
&at.and_then(|at| at.format("%H:%M").ok())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
time.add_css_class("dim-label");
|
||||
time.add_css_class("numeric");
|
||||
if let Some(at) = at.and_then(|at| at.format("%c").ok()) {
|
||||
time.set_tooltip_text(Some(&at));
|
||||
}
|
||||
widget.add_suffix(&time);
|
||||
|
||||
if let Some(conflict) = pending {
|
||||
let resolve = gtk4::Button::builder()
|
||||
.label("Resolve…")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let ui = ui.clone();
|
||||
let conflict = conflict.clone();
|
||||
resolve.connect_clicked(move |_| prompt_resolve_conflict(&ui, &conflict));
|
||||
widget.add_suffix(&resolve);
|
||||
}
|
||||
widget
|
||||
}
|
||||
|
||||
/// A human verb for an activity kind, used as the row title's lead word.
|
||||
@@ -277,9 +610,9 @@ pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
|
||||
ActivityKind::Trash => "Trashed",
|
||||
ActivityKind::Restore => "Restored",
|
||||
ActivityKind::DeleteForever => "Deleted",
|
||||
ActivityKind::EmptyTrash => "Emptied trash —",
|
||||
ActivityKind::EmptyTrash => "Emptied Trash",
|
||||
ActivityKind::Share => "Shared",
|
||||
ActivityKind::PublicLink => "Linked",
|
||||
ActivityKind::PublicLink => "Created a link to",
|
||||
ActivityKind::Unshare => "Unshared",
|
||||
ActivityKind::Conflict => "Conflict",
|
||||
}
|
||||
@@ -288,15 +621,14 @@ pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
|
||||
/// A themed icon for an activity kind.
|
||||
pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
|
||||
match kind {
|
||||
ActivityKind::Upload => "document-send-symbolic",
|
||||
ActivityKind::Upload => "pdfs-upload-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",
|
||||
ActivityKind::Trash | ActivityKind::DeleteForever | ActivityKind::EmptyTrash => {
|
||||
"user-trash-symbolic"
|
||||
}
|
||||
ActivityKind::Trash => "user-trash-symbolic",
|
||||
ActivityKind::DeleteForever | ActivityKind::EmptyTrash => "edit-delete-symbolic",
|
||||
ActivityKind::Restore => "edit-undo-symbolic",
|
||||
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
|
||||
ActivityKind::Unshare => "action-unavailable-symbolic",
|
||||
@@ -311,3 +643,99 @@ pub(crate) fn activity_time(secs: i64) -> String {
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(time: i64, kind: ActivityKind, target: &str, detail: &str, ok: bool) -> ActivityEntry {
|
||||
ActivityEntry {
|
||||
time,
|
||||
kind,
|
||||
target: target.into(),
|
||||
detail: detail.into(),
|
||||
ok,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conflict_logged_every_day_shows_once_with_a_count() {
|
||||
let items = vec![
|
||||
entry(
|
||||
300,
|
||||
ActivityKind::Conflict,
|
||||
"a (sync-conflict).txt",
|
||||
"differs",
|
||||
false,
|
||||
),
|
||||
entry(250, ActivityKind::Upload, "b.txt", "", true),
|
||||
entry(
|
||||
200,
|
||||
ActivityKind::Conflict,
|
||||
"a (sync-conflict).txt",
|
||||
"differs",
|
||||
false,
|
||||
),
|
||||
entry(
|
||||
100,
|
||||
ActivityKind::Conflict,
|
||||
"a (sync-conflict).txt",
|
||||
"differs",
|
||||
false,
|
||||
),
|
||||
];
|
||||
let rows = collapse_feed(&items);
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].entry.time, 300);
|
||||
assert_eq!(rows[0].count, 3);
|
||||
assert_eq!(rows[0].first, 100);
|
||||
assert_eq!(rows[1].count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_entries_collapse_only_when_back_to_back() {
|
||||
let items = vec![
|
||||
entry(300, ActivityKind::Upload, "b.txt", "timeout", false),
|
||||
entry(200, ActivityKind::Upload, "b.txt", "timeout", false),
|
||||
entry(150, ActivityKind::Trash, "c.txt", "", true),
|
||||
entry(100, ActivityKind::Upload, "b.txt", "timeout", false),
|
||||
];
|
||||
let counts: Vec<usize> = collapse_feed(&items).iter().map(|r| r.count).collect();
|
||||
assert_eq!(counts, vec![2, 1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_problems_filter_holds_failures_and_conflicts() {
|
||||
let failed = entry(1, ActivityKind::Upload, "a", "", false);
|
||||
let conflict = entry(1, ActivityKind::Conflict, "a", "", false);
|
||||
let fine = entry(1, ActivityKind::Upload, "a", "", true);
|
||||
assert!(ActivityFilter::Problems.matches(&failed));
|
||||
assert!(ActivityFilter::Problems.matches(&conflict));
|
||||
assert!(!ActivityFilter::Problems.matches(&fine));
|
||||
assert!(ActivityFilter::Transfers.matches(&failed));
|
||||
assert!(!ActivityFilter::Sharing.matches(&fine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn days_read_today_yesterday_weekday_then_date() {
|
||||
let tz = glib::TimeZone::utc();
|
||||
let at = |y, m, d, h| glib::DateTime::new(&tz, y, m, d, h, 0, 0.0).unwrap();
|
||||
let now = at(2026, 9, 23, 12);
|
||||
assert_eq!(day_label(&at(2026, 9, 23, 1), &now), "Today");
|
||||
assert_eq!(day_label(&at(2026, 9, 22, 23), &now), "Yesterday");
|
||||
// Weekday and month names follow the locale, so compare against it.
|
||||
let named = |dt: glib::DateTime, format: &str| dt.format(format).unwrap().to_string();
|
||||
assert_eq!(
|
||||
day_label(&at(2026, 9, 20, 10), &now),
|
||||
named(at(2026, 9, 20, 10), "%A")
|
||||
);
|
||||
assert_eq!(
|
||||
day_label(&at(2026, 9, 1, 10), &now),
|
||||
named(at(2026, 9, 1, 10), "%A, %B %-d")
|
||||
);
|
||||
assert_eq!(
|
||||
day_label(&at(2025, 12, 31, 10), &now),
|
||||
named(at(2025, 12, 31, 10), "%B %-d, %Y")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,23 +16,34 @@ const COVER_EDGE: i32 = 200;
|
||||
|
||||
/// Show the Albums grid and (re)load it. Called when the Albums toggle goes on.
|
||||
pub(crate) fn show_albums(ui: &Rc<Ui>) {
|
||||
close_album(ui);
|
||||
ui.gallery.content.set_visible_child_name("albums");
|
||||
show_album_grid(ui);
|
||||
load_albums(ui);
|
||||
}
|
||||
|
||||
/// Switch to the album grid as it was last filled, keeping its scroll
|
||||
/// position. The timeline's filters do not apply to albums, so they go.
|
||||
fn show_album_grid(ui: &Rc<Ui>) {
|
||||
close_album(ui);
|
||||
ui.gallery.filters.set_visible(false);
|
||||
ui.gallery.content.set_visible_child_name("albums");
|
||||
}
|
||||
|
||||
/// Ask the daemon for the album listing and rebuild the grid.
|
||||
pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
||||
if ui.gallery.albums_loading.get() {
|
||||
return;
|
||||
}
|
||||
ui.gallery.albums_loading.set(true);
|
||||
albums_status(
|
||||
ui,
|
||||
"view-grid-symbolic",
|
||||
"Loading albums…",
|
||||
"Reading your Proton Drive albums.",
|
||||
);
|
||||
// A grid already on screen stays there while it refreshes, rather than
|
||||
// blinking to a loading page and losing its scroll position.
|
||||
if ui.gallery.albums_stack.visible_child_name().as_deref() != Some("grid") {
|
||||
albums_status(
|
||||
ui,
|
||||
"view-grid-symbolic",
|
||||
"Loading albums…",
|
||||
"Reading your Proton Drive albums.",
|
||||
);
|
||||
}
|
||||
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
||||
let ui = ui.clone();
|
||||
@@ -52,7 +63,7 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
||||
&ui,
|
||||
"view-grid-symbolic",
|
||||
"No albums",
|
||||
"Albums you create in Proton Photos appear here.",
|
||||
"Create one with New Album, or in Proton Photos.",
|
||||
),
|
||||
Ok(Ok(Response::Albums { items, .. })) => {
|
||||
fill_albums(&ui, &items);
|
||||
@@ -80,15 +91,31 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
||||
/// Replace the grid's cards with `albums`, in the order the daemon gave them
|
||||
/// (newest activity first).
|
||||
fn fill_albums(ui: &Rc<Ui>, albums: &[AlbumInfo]) {
|
||||
let scroll = ui
|
||||
.gallery
|
||||
.albums
|
||||
.ancestor(gtk4::ScrolledWindow::static_type())
|
||||
.and_downcast::<gtk4::ScrolledWindow>();
|
||||
let position = scroll.as_ref().map(|s| s.vadjustment().value());
|
||||
while let Some(child) = ui.gallery.albums.first_child() {
|
||||
ui.gallery.albums.remove(&child);
|
||||
}
|
||||
*ui.gallery.album_list.borrow_mut() = albums.to_vec();
|
||||
if ui.gallery.album.borrow().is_none() {
|
||||
ui.gallery
|
||||
.title
|
||||
.set_subtitle(&count_noun(albums.len(), "album", "albums"));
|
||||
}
|
||||
for album in albums {
|
||||
ui.gallery.albums.append(&album_card(ui, album));
|
||||
}
|
||||
// The covers are ordinary photos as far as the daemon is concerned, so the
|
||||
// batch that fills them is the gallery's own.
|
||||
schedule_thumbs(ui);
|
||||
// A refresh rebuilds the cards; put the view back where the user was.
|
||||
if let (Some(scroll), Some(position)) = (scroll, position) {
|
||||
glib::idle_add_local_once(move || scroll.vadjustment().set_value(position));
|
||||
}
|
||||
}
|
||||
|
||||
/// One album as a clickable card: its cover, its name, and how many photos it
|
||||
@@ -150,12 +177,354 @@ fn album_card(ui: &Rc<Ui>, album: &AlbumInfo) -> gtk4::Button {
|
||||
want_cover(ui, uid, &picture);
|
||||
}
|
||||
|
||||
let context = gtk4::GestureClick::builder().button(3).build();
|
||||
let ui_context = ui.clone();
|
||||
let menu_album = album.clone();
|
||||
context.connect_pressed(move |gesture, _, x, y| {
|
||||
gesture.set_state(gtk4::EventSequenceState::Claimed);
|
||||
if let Some(anchor) = gesture.widget() {
|
||||
show_album_menu(&ui_context, &menu_album, &anchor, x, y);
|
||||
}
|
||||
});
|
||||
button.add_controller(context);
|
||||
|
||||
let ui_open = ui.clone();
|
||||
let album = album.clone();
|
||||
button.connect_clicked(move |_| open_album(&ui_open, album.clone()));
|
||||
button
|
||||
}
|
||||
|
||||
/// The album card's menu. Albums shared with us belong to someone else, so
|
||||
/// they only open.
|
||||
fn show_album_menu(ui: &Rc<Ui>, album: &AlbumInfo, anchor: >k4::Widget, x: f64, y: f64) {
|
||||
let mut menu = ActionMenu::new();
|
||||
let (ui_c, album_c) = (ui.clone(), album.clone());
|
||||
menu.item("Open", move || open_album(&ui_c, album_c.clone()));
|
||||
if !album.shared {
|
||||
menu.section();
|
||||
let (ui_c, album_c) = (ui.clone(), album.clone());
|
||||
menu.item("Rename…", move || prompt_rename_album(&ui_c, &album_c));
|
||||
menu.section();
|
||||
let (ui_c, album_c) = (ui.clone(), album.clone());
|
||||
menu.item("Delete Album…", move || {
|
||||
confirm_delete_album(&ui_c, &album_c)
|
||||
});
|
||||
}
|
||||
menu.popup_at(anchor, x, y);
|
||||
}
|
||||
|
||||
/// Ask for an album name. `on_name` gets the trimmed name; Create stays off
|
||||
/// while the entry is blank.
|
||||
fn prompt_album_name(
|
||||
ui: &Rc<Ui>,
|
||||
heading: &str,
|
||||
action: &str,
|
||||
current: &str,
|
||||
on_name: impl Fn(String) + 'static,
|
||||
) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder().heading(heading).build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let row = adw::EntryRow::builder()
|
||||
.title("Album name")
|
||||
.activates_default(true)
|
||||
.build();
|
||||
row.set_text(current);
|
||||
group.add(&row);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("ok", action);
|
||||
dialog.set_response_appearance("ok", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_response_enabled("ok", !current.trim().is_empty());
|
||||
dialog.set_default_response(Some("ok"));
|
||||
dialog.set_close_response("cancel");
|
||||
let dialog_typed = dialog.clone();
|
||||
row.connect_changed(move |row| {
|
||||
dialog_typed.set_response_enabled("ok", !row.text().trim().is_empty());
|
||||
});
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
let name = row.text().trim().to_string();
|
||||
if resp == "ok" && !name.is_empty() {
|
||||
on_name(name);
|
||||
}
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Ask for a name and create an album, then add `photos` to it, if any.
|
||||
pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
||||
let ui_c = ui.clone();
|
||||
prompt_album_name(ui, "New Album", "Create", "", move |name| {
|
||||
let rx = spawn_request(ui_c.dirs.control_socket(), Request::CreateAlbum { name });
|
||||
let ui = ui_c.clone();
|
||||
let photos = photos.clone();
|
||||
ui.busy_begin();
|
||||
glib::spawn_future_local(async move {
|
||||
let reply = rx.recv().await;
|
||||
ui.busy_end();
|
||||
match reply {
|
||||
Ok(Ok(Response::AlbumCreated { uid })) => {
|
||||
if photos.is_empty() {
|
||||
toast(&ui, "Album created");
|
||||
reload_album_grid(&ui);
|
||||
} else {
|
||||
add_to_album(&ui, uid, photos);
|
||||
}
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't create the album", &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
"Couldn't create the album",
|
||||
"The mount service didn't respond.",
|
||||
),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn prompt_rename_album(ui: &Rc<Ui>, album: &AlbumInfo) {
|
||||
let (ui_c, uid) = (ui.clone(), album.uid.clone());
|
||||
prompt_album_name(ui, "Rename Album", "Rename", &album.name, move |name| {
|
||||
run_album_request(
|
||||
&ui_c,
|
||||
Request::RenameAlbum {
|
||||
uid: uid.clone(),
|
||||
name,
|
||||
},
|
||||
"Album renamed",
|
||||
"Couldn't rename the album",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn confirm_delete_album(ui: &Rc<Ui>, album: &AlbumInfo) {
|
||||
let Some(win) = ui_window(ui) else { return };
|
||||
let (ui_c, uid) = (ui.clone(), album.uid.clone());
|
||||
confirm_destructive(
|
||||
&win,
|
||||
"Delete Album?",
|
||||
&format!(
|
||||
"“{}” is deleted. Its photos stay in your timeline.",
|
||||
album.name
|
||||
),
|
||||
"Delete",
|
||||
move || {
|
||||
run_album_request(
|
||||
&ui_c,
|
||||
Request::DeleteAlbum {
|
||||
uid: uid.clone(),
|
||||
delete_photos: false,
|
||||
},
|
||||
"Album deleted",
|
||||
"Couldn't delete the album",
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Send an album rename or delete and refresh the grid once it lands.
|
||||
fn run_album_request(ui: &Rc<Ui>, request: Request, done: &'static str, failed: &'static str) {
|
||||
let rx = spawn_request(ui.dirs.control_socket(), request);
|
||||
let ui = ui.clone();
|
||||
ui.busy_begin();
|
||||
glib::spawn_future_local(async move {
|
||||
let reply = rx.recv().await;
|
||||
ui.busy_end();
|
||||
match reply {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
toast(&ui, done);
|
||||
reload_album_grid(&ui);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
||||
_ => toast_error(&ui, failed, "The mount service didn't respond."),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Refill the album grid if it is on screen; otherwise it reloads when shown.
|
||||
fn reload_album_grid(ui: &Rc<Ui>) {
|
||||
if ui.gallery.content.visible_child_name().as_deref() == Some("albums") {
|
||||
load_albums(ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// Offer our own albums to add `photos` to, plus a new one.
|
||||
pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let albums: Vec<AlbumInfo> = match rx.recv().await {
|
||||
Ok(Ok(Response::Albums { items, .. })) => {
|
||||
items.into_iter().filter(|album| !album.shared).collect()
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't load albums", &message, kind);
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
toast_error(
|
||||
&ui,
|
||||
"Couldn't load albums",
|
||||
"The mount service didn't respond.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if albums.is_empty() {
|
||||
prompt_new_album(&ui, photos);
|
||||
return;
|
||||
}
|
||||
|
||||
let list = gtk4::ListBox::builder()
|
||||
.selection_mode(gtk4::SelectionMode::None)
|
||||
.build();
|
||||
list.add_css_class("boxed-list");
|
||||
for album in &albums {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(glib::markup_escape_text(&album.name))
|
||||
.subtitle(album_subtitle(album))
|
||||
.activatable(true)
|
||||
.build();
|
||||
list.append(&row);
|
||||
}
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.propagate_natural_height(true)
|
||||
.max_content_height(360)
|
||||
.child(&list)
|
||||
.build();
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Add to Album")
|
||||
.body(match photos.len() {
|
||||
1 => "Choose an album for this photo.".to_string(),
|
||||
n => format!("Choose an album for {n} photos."),
|
||||
})
|
||||
.extra_child(&scroll)
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("new", "New Album…");
|
||||
dialog.set_close_response("cancel");
|
||||
|
||||
let (ui_c, dialog_c, photos_c) = (ui.clone(), dialog.clone(), photos.clone());
|
||||
list.connect_row_activated(move |_, row| {
|
||||
if let Some(album) = albums.get(row.index().max(0) as usize) {
|
||||
dialog_c.close();
|
||||
add_to_album(&ui_c, album.uid.clone(), photos_c.clone());
|
||||
}
|
||||
});
|
||||
let ui_c = ui.clone();
|
||||
dialog.connect_response(Some("new"), move |_, _| {
|
||||
prompt_new_album(&ui_c, photos.clone())
|
||||
});
|
||||
dialog.present(ui_window(&ui).as_ref());
|
||||
});
|
||||
}
|
||||
|
||||
fn add_to_album(ui: &Rc<Ui>, album: String, photos: Vec<String>) {
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::AddToAlbum { uid: album, photos },
|
||||
);
|
||||
let ui = ui.clone();
|
||||
ui.busy_begin();
|
||||
glib::spawn_future_local(async move {
|
||||
let reply = rx.recv().await;
|
||||
ui.busy_end();
|
||||
match reply {
|
||||
Ok(Ok(Response::AlbumChanged { changed, failed })) => {
|
||||
if let Some(failure) = failed.first() {
|
||||
toast_error(&ui, "Some photos couldn't be added", &failure.message);
|
||||
} else {
|
||||
toast(
|
||||
&ui,
|
||||
&format!(
|
||||
"Added {} to the album",
|
||||
count_noun(changed.len(), "photo", "photos")
|
||||
),
|
||||
);
|
||||
}
|
||||
reload_album_grid(&ui);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't add to the album", &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
"Couldn't add to the album",
|
||||
"The mount service didn't respond.",
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Take photos out of the open album. They leave the view at once and come
|
||||
/// back if the server refuses; the timeline keeps them either way.
|
||||
pub(crate) fn remove_from_album(ui: &Rc<Ui>, album: &AlbumInfo, photos: Vec<String>) {
|
||||
let removed = remove_photos(ui, &photos);
|
||||
set_selection_mode(ui, false);
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::RemoveFromAlbum {
|
||||
uid: album.uid.clone(),
|
||||
photos,
|
||||
},
|
||||
);
|
||||
let ui = ui.clone();
|
||||
ui.busy_begin();
|
||||
glib::spawn_future_local(async move {
|
||||
let reply = rx.recv().await;
|
||||
ui.busy_end();
|
||||
match reply {
|
||||
Ok(Ok(Response::AlbumChanged { changed, failed })) => {
|
||||
if !failed.is_empty() {
|
||||
let kept: Vec<PhotoItem> = removed
|
||||
.iter()
|
||||
.filter(|photo| failed.iter().any(|f| f.uid == photo.uid))
|
||||
.cloned()
|
||||
.collect();
|
||||
restore_photos(&ui, kept);
|
||||
toast_error(
|
||||
&ui,
|
||||
"Some photos couldn't be removed from the album",
|
||||
&failed[0].message,
|
||||
);
|
||||
}
|
||||
if changed.is_empty() {
|
||||
return;
|
||||
}
|
||||
// The open album's count, as the header shows it.
|
||||
let mut open = ui.gallery.album.borrow_mut();
|
||||
if let Some(album) = open.as_mut() {
|
||||
album.photo_count = album.photo_count.saturating_sub(changed.len());
|
||||
ui.gallery.title.set_subtitle(&album_subtitle(album));
|
||||
}
|
||||
drop(open);
|
||||
toast(
|
||||
&ui,
|
||||
&format!(
|
||||
"Removed {} from the album",
|
||||
count_noun(changed.len(), "photo", "photos")
|
||||
),
|
||||
);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
restore_photos(&ui, removed);
|
||||
toast_failure(&ui, "Couldn't remove from the album", &message, kind)
|
||||
}
|
||||
_ => {
|
||||
restore_photos(&ui, removed);
|
||||
toast_error(
|
||||
&ui,
|
||||
"Couldn't remove from the album",
|
||||
"The mount service didn't respond.",
|
||||
)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// "12 photos", plus where the album came from when it isn't ours.
|
||||
fn album_subtitle(album: &AlbumInfo) -> String {
|
||||
let photos = match album.photo_count {
|
||||
@@ -192,10 +561,10 @@ fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
||||
/// Open one album in the gallery: the timeline view, paged from the album
|
||||
/// instead, with the filters that don't apply to it out of the way.
|
||||
pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) {
|
||||
ui.gallery.title.set_label(&album.name);
|
||||
ui.gallery.subtitle.set_label(&album_subtitle(&album));
|
||||
ui.gallery.subtitle.set_visible(true);
|
||||
ui.gallery.title.set_title(&album.name);
|
||||
ui.gallery.title.set_subtitle(&album_subtitle(&album));
|
||||
*ui.gallery.album.borrow_mut() = Some(album);
|
||||
ui.gallery.timeline_stale.set(true);
|
||||
|
||||
// An album page carries no kind or date filter, and Upload targets the
|
||||
// timeline rather than an album — hide those rather than offer controls that
|
||||
@@ -219,8 +588,7 @@ pub(crate) fn close_album(ui: &Rc<Ui>) {
|
||||
if ui.gallery.album.borrow_mut().take().is_none() {
|
||||
return;
|
||||
}
|
||||
ui.gallery.title.set_label("Gallery");
|
||||
ui.gallery.filters.set_visible(true);
|
||||
ui.gallery.title.set_title("Photos");
|
||||
ui.gallery.view_switch.set_visible(true);
|
||||
ui.gallery.upload.set_visible(true);
|
||||
ui.gallery.back.set_visible(false);
|
||||
@@ -251,15 +619,28 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
||||
if !btn.is_active() {
|
||||
return;
|
||||
}
|
||||
// The timeline is reloaded rather than restored, because an open album
|
||||
// left the model holding its own photos.
|
||||
close_album(&ui_photos);
|
||||
ui_photos.gallery.filters.set_visible(true);
|
||||
ui_photos.gallery.content.set_visible_child_name("timeline");
|
||||
load_gallery(&ui_photos, false);
|
||||
// The timeline comes back as it was left unless an album took over the
|
||||
// model since; then it has to be reloaded.
|
||||
if ui_photos.gallery.timeline_stale.replace(false) || ui_photos.gallery.model.n_items() == 0
|
||||
{
|
||||
load_gallery(&ui_photos, false);
|
||||
} else {
|
||||
update_gallery_subtitle(&ui_photos);
|
||||
}
|
||||
});
|
||||
|
||||
// Back returns to the grid the album was opened from, scrolled where it
|
||||
// was, instead of reloading it.
|
||||
let ui_back = ui.clone();
|
||||
ui.gallery.back.clone().connect_clicked(move |_| {
|
||||
show_albums(&ui_back);
|
||||
show_album_grid(&ui_back);
|
||||
let count = ui_back.gallery.album_list.borrow().len();
|
||||
ui_back
|
||||
.gallery
|
||||
.title
|
||||
.set_subtitle(&count_noun(count, "album", "albums"));
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -36,46 +36,24 @@ pub(crate) struct DevicesWidgets {
|
||||
/// machine's own device is deliberately not among them; see
|
||||
/// [`repaint_devices`].
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
/// "Restore Folders" — re-attach this device's remote folders to local
|
||||
/// directories after adopting it on a new machine.
|
||||
pub(crate) restore: gtk4::Button,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// The Computers page: a "This computer" section naming the device this machine
|
||||
/// backs up to (renamable from its header), plus an "Other computers" section
|
||||
/// listing the account's other registered devices. The synced folders
|
||||
/// themselves live on the Sync page.
|
||||
pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Computers")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
titles.set_hexpand(true);
|
||||
titles.append(&title);
|
||||
|
||||
let restore = gtk4::Button::builder()
|
||||
.label("Restore Folders")
|
||||
.tooltip_text("Sync this computer's Drive folders back to local directories")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
restore.add_css_class("flat");
|
||||
let refresh = refresh_button();
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header.append(&titles);
|
||||
header.append(&refresh);
|
||||
header.append(&restore);
|
||||
|
||||
// This page is about device *identity* — which computer this is, which other
|
||||
// computers back up to the account, and how to adopt one. The folders
|
||||
// themselves are local paths, so they live on the Locations page; keeping a
|
||||
// themselves are local paths, so they live on the Sync page; keeping a
|
||||
// second copy of that list here would be two places to change the same mode.
|
||||
let sync_group = adw::PreferencesGroup::builder()
|
||||
.title("This computer")
|
||||
.description("The device this machine backs up to.")
|
||||
.description("The computer this machine backs up as.")
|
||||
.build();
|
||||
// Rename control for *this* machine's device, in the section header. The
|
||||
// current device is filtered out of "Other computers", so this is the only
|
||||
@@ -90,7 +68,7 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||
sync_group.set_header_suffix(Some(&rename_this));
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Other computers")
|
||||
.description("Other devices backing up to this account.")
|
||||
.description("Other computers backing up to this account.")
|
||||
.build();
|
||||
|
||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
@@ -123,37 +101,35 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||
content.add_named(&status, Some("status"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(12);
|
||||
inner.set_margin_bottom(12);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&header);
|
||||
inner.set_margin_top(18);
|
||||
inner.set_margin_bottom(18);
|
||||
inner.set_margin_start(18);
|
||||
inner.set_margin_end(18);
|
||||
inner.append(&content);
|
||||
let (frame, header, _) = page_frame("Computers", &inner);
|
||||
header.pack_end(&refresh);
|
||||
|
||||
(
|
||||
inner.upcast(),
|
||||
frame.upcast(),
|
||||
DevicesWidgets {
|
||||
content,
|
||||
status,
|
||||
sync_group,
|
||||
rename_this,
|
||||
group,
|
||||
restore,
|
||||
retry,
|
||||
refresh,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Install the Devices page's retry button and the "Restore Folders" action.
|
||||
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: >k4::Button, restore: >k4::Button) {
|
||||
/// Install the Devices page's retry button and the Rename action.
|
||||
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: >k4::Button) {
|
||||
let ui_retry = ui.clone();
|
||||
retry.connect_clicked(move |_| {
|
||||
service::restart();
|
||||
load_devices(&ui_retry);
|
||||
});
|
||||
let ui_restore = ui.clone();
|
||||
restore.connect_clicked(move |_| prompt_restore_folders(&ui_restore));
|
||||
let ui_ren = ui.clone();
|
||||
ui.devices.rename_this.connect_clicked(move |_| {
|
||||
if let Some((uid, name)) = ui_ren.devices.this_device.borrow().clone() {
|
||||
@@ -183,7 +159,7 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
||||
ui,
|
||||
"computer-symbolic",
|
||||
"Loading…",
|
||||
"Reading your devices.",
|
||||
"Reading your computers.",
|
||||
false,
|
||||
);
|
||||
ui.busy_begin();
|
||||
@@ -271,6 +247,9 @@ pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
|
||||
match devices.iter().find(|d| d.this_device) {
|
||||
Some(me) => {
|
||||
*ui.devices.this_device.borrow_mut() = Some((me.uid.clone(), me.name.clone()));
|
||||
ui.devices
|
||||
.sync_group
|
||||
.set_description(Some(&format!("Backing up as “{}”.", me.name)));
|
||||
ui.devices.rename_this.set_sensitive(true);
|
||||
ui.devices
|
||||
.rename_this
|
||||
@@ -299,43 +278,34 @@ pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
|
||||
.subtitle(device_subtitle(dev))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Remove this computer and everything it backed up")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let rename = gtk4::Button::builder()
|
||||
.icon_name("document-edit-symbolic")
|
||||
.tooltip_text("Rename computer")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
rename.add_css_class("flat");
|
||||
// Adoption is how a reinstalled or renamed machine re-attaches to the
|
||||
// device it used to be, instead of registering a duplicate. It only
|
||||
// makes sense on *another* device's row, which is the only place this
|
||||
// makes sense on *another* computer's row, which is the only place this
|
||||
// loop paints.
|
||||
let adopt = gtk4::Button::builder()
|
||||
.icon_name("insert-object-symbolic")
|
||||
.tooltip_text("Use this computer's identity for this machine")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
adopt.add_css_class("flat");
|
||||
let ui_ad = ui.clone();
|
||||
let uid_ad = dev.uid.clone();
|
||||
let name_ad = dev.name.clone();
|
||||
adopt.connect_clicked(move |_| prompt_adopt_device(&ui_ad, &uid_ad, &name_ad));
|
||||
row.add_suffix(&adopt);
|
||||
let ui_ren = ui.clone();
|
||||
let uid_ren = dev.uid.clone();
|
||||
let name_ren = dev.name.clone();
|
||||
rename.connect_clicked(move |_| prompt_rename_device(&ui_ren, &uid_ren, &name_ren));
|
||||
let ui_rm = ui.clone();
|
||||
let uid_rm = dev.uid.clone();
|
||||
let name_rm = dev.name.clone();
|
||||
remove.connect_clicked(move |_| prompt_remove_device(&ui_rm, &uid_rm, &name_rm));
|
||||
row.add_suffix(&rename);
|
||||
row.add_suffix(&remove);
|
||||
let (ui_ren, uid_ren, name_ren) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
||||
let (ui_ad, uid_ad, name_ad) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
||||
let (ui_rm, uid_rm, name_rm) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
||||
let (ui_rs, uid_rs, name_rs) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
||||
row.add_suffix(&more_menu_button(vec![
|
||||
(
|
||||
"Restore to This Computer…",
|
||||
Box::new(move || {
|
||||
prompt_restore_folders(&ui_rs, Some((uid_rs.clone(), name_rs.clone())))
|
||||
}),
|
||||
),
|
||||
(
|
||||
"Rename…",
|
||||
Box::new(move || prompt_rename_device(&ui_ren, &uid_ren, &name_ren)),
|
||||
),
|
||||
(
|
||||
"Use This Computer's Identity…",
|
||||
Box::new(move || prompt_adopt_device(&ui_ad, &uid_ad, &name_ad)),
|
||||
),
|
||||
(
|
||||
"Remove Computer…",
|
||||
Box::new(move || prompt_remove_device(&ui_rm, &uid_rm, &name_rm)),
|
||||
),
|
||||
]));
|
||||
ui.devices.group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
}
|
||||
@@ -370,7 +340,7 @@ pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||
let manage = gtk4::Button::builder()
|
||||
.label("Locations")
|
||||
.label("Open Sync")
|
||||
.tooltip_text("Manage this computer's folders and mountpoint")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
@@ -378,6 +348,11 @@ pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
||||
let ui_go = ui.clone();
|
||||
manage.connect_clicked(move |_| ui_go.stack.set_visible_child_name("locations"));
|
||||
row.add_suffix(&manage);
|
||||
let ui_rs = ui.clone();
|
||||
row.add_suffix(&more_menu_button(vec![(
|
||||
"Restore Folders…",
|
||||
Box::new(move || prompt_restore_folders(&ui_rs, None)),
|
||||
)]));
|
||||
ui.devices.sync_group.add(&row);
|
||||
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()];
|
||||
}
|
||||
@@ -386,7 +361,7 @@ pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
||||
/// attention — the one fact worth surfacing away from the folder list itself.
|
||||
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
||||
if folders.is_empty() {
|
||||
return "No folders backed up yet — add one under Locations.".to_string();
|
||||
return "No folders backed up yet — add one on the Sync page.".to_string();
|
||||
}
|
||||
let count = match folders.len() {
|
||||
1 => "1 folder backed up".to_string(),
|
||||
@@ -397,7 +372,7 @@ pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
||||
.filter(|f| f.state == "error" || f.state == "conflict")
|
||||
.count();
|
||||
match attention {
|
||||
0 => format!("{count} · manage them under Locations"),
|
||||
0 => format!("{count} · manage them on the Sync page"),
|
||||
1 => format!("{count} · 1 needs attention"),
|
||||
n => format!("{count} · {n} need attention"),
|
||||
}
|
||||
@@ -481,21 +456,30 @@ pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Ask the daemon what this machine's device holds, then show a picker mapping
|
||||
/// each remote folder onto a local directory.
|
||||
/// Ask the daemon what a device holds, then show a picker mapping each remote
|
||||
/// folder onto a local directory. `device` is another computer as `(uid, name)`;
|
||||
/// `None` is this machine's own device.
|
||||
///
|
||||
/// The daemon's paths are proposals — from the device's `profile.json` when it
|
||||
/// makes sense here, else `~/<name>` — so every one of them is editable and
|
||||
/// nothing is restored without being ticked.
|
||||
pub(crate) fn prompt_restore_folders(ui: &Rc<Ui>) {
|
||||
pub(crate) fn prompt_restore_folders(ui: &Rc<Ui>, device: Option<(String, String)>) {
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListRestorableFolders);
|
||||
let request = match &device {
|
||||
Some((uid, _)) => Request::ListDeviceRestorableFolders {
|
||||
device: uid.clone(),
|
||||
},
|
||||
None => Request::ListRestorableFolders,
|
||||
};
|
||||
let rx = spawn_request(ui.dirs.control_socket(), request);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
match result {
|
||||
Ok(Ok(Response::RestorableFolders { items })) => show_restore_picker(&ui, items),
|
||||
Ok(Ok(Response::RestorableFolders { items })) => {
|
||||
show_restore_picker(&ui, items, device)
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't list folders to restore", &message, kind)
|
||||
}
|
||||
@@ -509,14 +493,18 @@ pub(crate) fn prompt_restore_folders(ui: &Rc<Ui>) {
|
||||
}
|
||||
|
||||
/// The restore picker itself: one editable row per restorable folder.
|
||||
fn show_restore_picker(ui: &Rc<Ui>, items: Vec<RestorableFolder>) {
|
||||
fn show_restore_picker(
|
||||
ui: &Rc<Ui>,
|
||||
items: Vec<RestorableFolder>,
|
||||
device: Option<(String, String)>,
|
||||
) {
|
||||
let win = ui_window(ui);
|
||||
let candidates: Vec<RestorableFolder> =
|
||||
items.into_iter().filter(|f| !f.already_synced).collect();
|
||||
if candidates.is_empty() {
|
||||
toast(
|
||||
ui,
|
||||
"Nothing to restore — this computer's folders are all synced here.",
|
||||
¬hing_to_restore(device.as_ref().map(|(_, n)| n.as_str())),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -547,7 +535,9 @@ fn show_restore_picker(ui: &Rc<Ui>, items: Vec<RestorableFolder>) {
|
||||
.build();
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Restore folders")
|
||||
.body("These folders are backed up under this computer in Proton Drive.")
|
||||
.body(restore_picker_body(
|
||||
device.as_ref().map(|(_, n)| n.as_str()),
|
||||
))
|
||||
.extra_child(&scroll)
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
@@ -577,7 +567,13 @@ fn show_restore_picker(ui: &Rc<Ui>, items: Vec<RestorableFolder>) {
|
||||
// rows appear as the periodic refresh picks them up.
|
||||
run_devices_mutation(
|
||||
&ui,
|
||||
Request::RestoreSyncFolders { items },
|
||||
match &device {
|
||||
Some((uid, _)) => Request::RestoreDeviceFolders {
|
||||
device: uid.clone(),
|
||||
items,
|
||||
},
|
||||
None => Request::RestoreSyncFolders { items },
|
||||
},
|
||||
"Restoring folders…",
|
||||
"Couldn't restore folders",
|
||||
);
|
||||
@@ -585,10 +581,29 @@ fn show_restore_picker(ui: &Rc<Ui>, items: Vec<RestorableFolder>) {
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// The restore picker's body: whose backup the folders come from.
|
||||
pub(crate) fn restore_picker_body(device: Option<&str>) -> String {
|
||||
match device {
|
||||
Some(name) => format!(
|
||||
"These folders are backed up from “{name}”. Restored folders keep syncing with \
|
||||
that computer's copy."
|
||||
),
|
||||
None => "These folders are backed up under this computer in Proton Drive.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The toast when every folder of a device is already synced here.
|
||||
pub(crate) fn nothing_to_restore(device: Option<&str>) -> String {
|
||||
match device {
|
||||
Some(name) => format!("Nothing to restore — “{name}”'s folders are all synced here."),
|
||||
None => "Nothing to restore — this computer's folders are all synced here.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &str) {
|
||||
pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &'static str) {
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::SetSyncFolderMode {
|
||||
@@ -599,7 +614,17 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &str) {
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { message })) => toast(&ui, &message),
|
||||
// The daemon's own text is written for the log; the toast says what
|
||||
// the person will see happen. A switch may be queued behind a running
|
||||
// pass, hence "will".
|
||||
Ok(Ok(Response::Ok { .. })) => toast(
|
||||
&ui,
|
||||
if mode == "ondemand" {
|
||||
"Folder will switch to on-demand"
|
||||
} else {
|
||||
"Folder will download and stay synced"
|
||||
},
|
||||
),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't change mode", &message, kind)
|
||||
}
|
||||
@@ -609,12 +634,22 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &str) {
|
||||
"The mount service didn't respond.",
|
||||
),
|
||||
}
|
||||
if ui.stack.visible_child_name().as_deref() == Some("devices") {
|
||||
load_devices(&ui);
|
||||
}
|
||||
reload_sync_pages(&ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// Repaint whichever of the two pages that show sync folders is on screen. Sync
|
||||
/// folder changes are raised from Locations as well as Computers, and both must
|
||||
/// show the daemon's answer at once rather than on the next tick — a rejected
|
||||
/// mode switch would otherwise stay visibly flipped.
|
||||
pub(crate) fn reload_sync_pages(ui: &Rc<Ui>) {
|
||||
match ui.stack.visible_child_name().as_deref() {
|
||||
Some("devices") => load_devices(ui),
|
||||
Some("locations") => refresh_locations(ui),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm, then stop syncing a folder. Offers to also delete the cloud copy.
|
||||
pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondemand: bool) {
|
||||
let win = ui_window(ui);
|
||||
@@ -679,7 +714,7 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
|
||||
pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Rename device")
|
||||
.heading("Rename Computer")
|
||||
.body(format!("Rename “{current}”."))
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
@@ -703,7 +738,7 @@ pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
|
||||
}
|
||||
let name = row.text().trim().to_string();
|
||||
if name.is_empty() {
|
||||
toast_error(&ui, "Couldn't rename device", "A name is required.");
|
||||
toast_error(&ui, "Couldn't rename the computer", "A name is required.");
|
||||
return;
|
||||
}
|
||||
run_devices_mutation(
|
||||
@@ -712,29 +747,42 @@ pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
|
||||
uid: uid.clone(),
|
||||
name,
|
||||
},
|
||||
"Device renamed",
|
||||
"Couldn't rename the device",
|
||||
"Computer renamed",
|
||||
"Couldn't rename the computer",
|
||||
);
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Confirm, then remove (deregister) a device.
|
||||
/// Confirm, then remove (deregister) a computer. Removing deletes everything the
|
||||
/// computer backed up, so the confirm button stays off until the name is typed.
|
||||
pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
let win = ui_window(ui);
|
||||
let confirm = adw::EntryRow::builder()
|
||||
.title(format!("Type “{name}” to confirm"))
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
group.add(&confirm);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Remove computer")
|
||||
.heading("Remove Computer")
|
||||
.body(format!(
|
||||
"Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is \
|
||||
deleted along with it. The files on that computer itself are not touched — but \
|
||||
this cannot be undone from here."
|
||||
))
|
||||
.extra_child(&group)
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("remove", "Remove");
|
||||
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_response_enabled("remove", false);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
let dialog_typed = dialog.clone();
|
||||
let expected = name.to_string();
|
||||
confirm.connect_changed(move |row| {
|
||||
dialog_typed.set_response_enabled("remove", row.text().trim() == expected);
|
||||
});
|
||||
let ui = ui.clone();
|
||||
let uid = uid.to_string();
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
@@ -742,8 +790,8 @@ pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
run_devices_mutation(
|
||||
&ui,
|
||||
Request::DeleteDevice { uid: uid.clone() },
|
||||
"Device removed",
|
||||
"Couldn't remove the device",
|
||||
"Computer removed",
|
||||
"Couldn't remove the computer",
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -790,7 +838,7 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Run a mutation raised from the Devices page and reload the page on success.
|
||||
/// Run a mutation raised from Computers or Locations and reload the page on success.
|
||||
pub(crate) fn run_devices_mutation(
|
||||
ui: &Rc<Ui>,
|
||||
req: Request,
|
||||
@@ -806,7 +854,7 @@ pub(crate) fn run_devices_mutation(
|
||||
ui.busy_end();
|
||||
match result {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
load_devices(&ui);
|
||||
reload_sync_pages(&ui);
|
||||
toast(&ui, &done);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
||||
@@ -829,6 +877,7 @@ mod tests {
|
||||
last_sync: 0,
|
||||
pending_mode: None,
|
||||
progress: None,
|
||||
paused: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,7 +885,7 @@ mod tests {
|
||||
fn a_machine_with_no_folders_is_told_where_to_add_one() {
|
||||
assert_eq!(
|
||||
this_computer_subtitle(&[]),
|
||||
"No folders backed up yet — add one under Locations."
|
||||
"No folders backed up yet — add one on the Sync page."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -846,7 +895,7 @@ mod tests {
|
||||
// the Computers page can surface that something is wrong.
|
||||
assert_eq!(
|
||||
this_computer_subtitle(&[folder("idle"), folder("idle")]),
|
||||
"2 folders backed up · manage them under Locations"
|
||||
"2 folders backed up · manage them on the Sync page"
|
||||
);
|
||||
assert_eq!(
|
||||
this_computer_subtitle(&[folder("idle"), folder("error")]),
|
||||
@@ -857,4 +906,11 @@ mod tests {
|
||||
"2 folders backed up · 2 need attention"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_restore_picker_names_the_computer_it_restores_from() {
|
||||
assert!(restore_picker_body(Some("laptop")).contains("“laptop”"));
|
||||
assert!(restore_picker_body(None).contains("this computer"));
|
||||
assert!(nothing_to_restore(Some("laptop")).contains("“laptop”"));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! The Locations page: every local place Proton Drive occupies on this machine.
|
||||
//! The Sync page (formerly Locations): what is moving right now, and every local
|
||||
//! place Proton Drive occupies on this machine.
|
||||
//!
|
||||
//! One row per [`MountSpec`] from [`Request::ListLocations`] — the primary
|
||||
//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is
|
||||
@@ -21,6 +22,46 @@ pub(crate) struct LocationsState {
|
||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
||||
pub(crate) card: SyncCard,
|
||||
pub(crate) queue: QueueState,
|
||||
pub(crate) conflicts: ConflictsState,
|
||||
}
|
||||
|
||||
/// The status card heading the Sync page: what sync is doing, and Pause/Resume.
|
||||
pub(crate) struct SyncCard {
|
||||
pub(crate) icon: gtk4::Image,
|
||||
pub(crate) row: adw::ActionRow,
|
||||
pub(crate) pause: adw::SplitButton,
|
||||
/// Whether the last status said paused, so the button knows which way to go.
|
||||
pub(crate) paused: Cell<bool>,
|
||||
}
|
||||
|
||||
/// What a queue row shows that can change: id, attempts, next attempt, parked.
|
||||
pub(crate) type QueueKey = (i64, i64, Option<i64>, bool);
|
||||
|
||||
/// The "Waiting to Upload" list: the daemon's pending-op queue.
|
||||
pub(crate) struct QueueState {
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) retry_all: gtk4::Button,
|
||||
pub(crate) rows: RefCell<Vec<adw::ActionRow>>,
|
||||
/// What the rows were built from, so an unchanged queue is not rebuilt on
|
||||
/// every tick.
|
||||
pub(crate) painted: RefCell<Vec<QueueKey>>,
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
}
|
||||
|
||||
/// What a conflict row shows that can change: path, both sizes, both times.
|
||||
pub(crate) type ConflictKey = (String, u64, i64, Option<u64>, Option<i64>);
|
||||
|
||||
/// The "Conflicts" list: `(sync-conflict …)` copies waiting for a decision.
|
||||
pub(crate) struct ConflictsState {
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) rows: RefCell<Vec<adw::ActionRow>>,
|
||||
pub(crate) painted: RefCell<Vec<ConflictKey>>,
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
/// When the list was last fetched. Listing walks every node the daemon
|
||||
/// knows, so the tick asks far less often than it does for the queue.
|
||||
pub(crate) fetched_at: Cell<Option<Instant>>,
|
||||
}
|
||||
|
||||
/// Widgets the Locations page's load/repaint touch.
|
||||
@@ -31,33 +72,38 @@ pub(crate) struct LocationsWidgets {
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
pub(crate) add_folder: gtk4::Button,
|
||||
/// Live transfers, above the folder list; painted by the refresh loop.
|
||||
pub(crate) transfers_group: adw::PreferencesGroup,
|
||||
pub(crate) card_icon: gtk4::Image,
|
||||
pub(crate) card_row: adw::ActionRow,
|
||||
pub(crate) pause: adw::SplitButton,
|
||||
pub(crate) queue_group: adw::PreferencesGroup,
|
||||
pub(crate) retry_all: gtk4::Button,
|
||||
pub(crate) conflicts_group: adw::PreferencesGroup,
|
||||
}
|
||||
|
||||
pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Locations")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
|
||||
let add_folder = gtk4::Button::builder()
|
||||
.label("Add Folder")
|
||||
.child(
|
||||
&adw::ButtonContent::builder()
|
||||
.label("Add Folder")
|
||||
.icon_name("list-add-symbolic")
|
||||
.build(),
|
||||
)
|
||||
.tooltip_text("Back a local folder up to this computer's Proton Drive device")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
add_folder.add_css_class("flat");
|
||||
let refresh = refresh_button();
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
titles.set_hexpand(true);
|
||||
titles.append(&title);
|
||||
header.append(&titles);
|
||||
header.append(&refresh);
|
||||
header.append(&add_folder);
|
||||
// Clamped to the same width as the rows below, or the page title floats off
|
||||
// to the left of the list it names.
|
||||
let header_clamp = adw::Clamp::builder().child(&header).build();
|
||||
let transfers_group = build_transfers_group();
|
||||
let (card, card_icon, card_row, pause) = build_sync_card();
|
||||
let (queue_group, retry_all) = build_queue_group();
|
||||
let conflicts_group = adw::PreferencesGroup::builder()
|
||||
.title("Conflicts")
|
||||
.description(
|
||||
"Files changed in two places at once. Both versions were kept; choose which \
|
||||
one stays.",
|
||||
)
|
||||
.visible(false)
|
||||
.build();
|
||||
|
||||
// Same warning the Computers page carried, for the same reason: the
|
||||
// on-demand switch removes the local copy, which is not a thing to discover
|
||||
@@ -72,6 +118,10 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
|
||||
.build();
|
||||
|
||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
groups.append(&card);
|
||||
groups.append(&conflicts_group);
|
||||
groups.append(&queue_group);
|
||||
groups.append(&transfers_group);
|
||||
groups.append(&group);
|
||||
let clamp = adw::Clamp::builder().child(&groups).build();
|
||||
// Never scroll sideways: a location's title is a full path, and letting the
|
||||
@@ -114,8 +164,10 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
|
||||
page.set_margin_bottom(18);
|
||||
page.set_margin_start(18);
|
||||
page.set_margin_end(18);
|
||||
page.append(&header_clamp);
|
||||
page.append(&content);
|
||||
let (frame, header, _) = page_frame("Sync", &page);
|
||||
header.pack_start(&add_folder);
|
||||
header.pack_end(&refresh);
|
||||
|
||||
let widgets = LocationsWidgets {
|
||||
content: content.clone(),
|
||||
@@ -124,8 +176,15 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
|
||||
retry: retry.clone(),
|
||||
refresh,
|
||||
add_folder,
|
||||
transfers_group,
|
||||
card_icon,
|
||||
card_row,
|
||||
pause,
|
||||
queue_group,
|
||||
retry_all,
|
||||
conflicts_group,
|
||||
};
|
||||
(page.upcast(), widgets)
|
||||
(frame.upcast(), widgets)
|
||||
}
|
||||
|
||||
pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: >k4::Button, add_folder: >k4::Button) {
|
||||
@@ -136,6 +195,522 @@ pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: >k4::Button, add_folder: >k
|
||||
});
|
||||
let ui_add = ui.clone();
|
||||
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
|
||||
wire_pause(ui);
|
||||
let ui_retry = ui.clone();
|
||||
ui.locations
|
||||
.queue
|
||||
.retry_all
|
||||
.connect_clicked(move |_| retry_queued(&ui_retry, None));
|
||||
}
|
||||
|
||||
/// The status card: a large state icon, the state in words, and Pause/Resume
|
||||
/// with timed pauses in its menu.
|
||||
fn build_sync_card() -> (
|
||||
adw::PreferencesGroup,
|
||||
gtk4::Image,
|
||||
adw::ActionRow,
|
||||
adw::SplitButton,
|
||||
) {
|
||||
let icon = gtk4::Image::builder()
|
||||
.icon_name("emblem-synchronizing-symbolic")
|
||||
.pixel_size(32)
|
||||
.margin_top(6)
|
||||
.margin_bottom(6)
|
||||
.build();
|
||||
icon.add_css_class("sidebar-status");
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some("Pause for 1 Hour"), Some("sync.pause-for(int64 3600)"));
|
||||
menu.append(
|
||||
Some("Pause for 8 Hours"),
|
||||
Some("sync.pause-for(int64 28800)"),
|
||||
);
|
||||
menu.append(
|
||||
Some("Pause for 24 Hours"),
|
||||
Some("sync.pause-for(int64 86400)"),
|
||||
);
|
||||
let pause = adw::SplitButton::builder()
|
||||
.label("Pause")
|
||||
.menu_model(&menu)
|
||||
.valign(gtk4::Align::Center)
|
||||
.tooltip_text("Stop uploading until you resume. Files still open as usual.")
|
||||
.dropdown_tooltip("Pause for a while")
|
||||
.build();
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Checking…")
|
||||
.title_lines(1)
|
||||
.subtitle_lines(2)
|
||||
.build();
|
||||
row.add_css_class("property");
|
||||
row.add_prefix(&icon);
|
||||
row.add_suffix(&pause);
|
||||
let group = adw::PreferencesGroup::new();
|
||||
group.add(&row);
|
||||
(group, icon, row, pause)
|
||||
}
|
||||
|
||||
/// Paint the Sync page's status card from what the sidebar strip shows.
|
||||
pub(crate) fn paint_sync_card(
|
||||
ui: &Rc<Ui>,
|
||||
icon: &str,
|
||||
class: Option<&str>,
|
||||
title: &str,
|
||||
detail: Option<&str>,
|
||||
paused: bool,
|
||||
connected: bool,
|
||||
) {
|
||||
let card = &ui.locations.card;
|
||||
card.icon.set_icon_name(Some(icon));
|
||||
for c in ["success", "warning", "error"] {
|
||||
card.icon.remove_css_class(c);
|
||||
}
|
||||
if let Some(class) = class {
|
||||
card.icon.add_css_class(class);
|
||||
}
|
||||
card.row.set_title(title);
|
||||
card.row.set_subtitle(detail.unwrap_or_default());
|
||||
card.paused.set(paused);
|
||||
card.pause
|
||||
.set_label(if paused { "Resume" } else { "Pause" });
|
||||
card.pause.set_tooltip_text(Some(if paused {
|
||||
"Upload everything that waited while sync was paused"
|
||||
} else {
|
||||
"Stop uploading until you resume. Files still open as usual."
|
||||
}));
|
||||
if paused {
|
||||
card.pause.add_css_class("suggested-action");
|
||||
} else {
|
||||
card.pause.remove_css_class("suggested-action");
|
||||
}
|
||||
card.pause.set_sensitive(connected);
|
||||
}
|
||||
|
||||
/// Pause/Resume on the card: the button flips, the menu pauses for a while.
|
||||
fn wire_pause(ui: &Rc<Ui>) {
|
||||
let ui_click = ui.clone();
|
||||
ui.locations.card.pause.connect_clicked(move |_| {
|
||||
let resume = ui_click.locations.card.paused.get();
|
||||
set_sync_paused(&ui_click, !resume, None);
|
||||
});
|
||||
let actions = gio::SimpleActionGroup::new();
|
||||
let pause_for = gio::SimpleAction::new("pause-for", Some(glib::VariantTy::INT64));
|
||||
let ui_for = ui.clone();
|
||||
pause_for.connect_activate(move |_, param| {
|
||||
let Some(secs) = param.and_then(|p| p.get::<i64>()) else {
|
||||
return;
|
||||
};
|
||||
let until = glib::DateTime::now_utc()
|
||||
.map(|now| now.to_unix())
|
||||
.unwrap_or_default()
|
||||
+ secs;
|
||||
set_sync_paused(&ui_for, true, Some(until));
|
||||
});
|
||||
actions.add_action(&pause_for);
|
||||
ui.locations
|
||||
.card
|
||||
.pause
|
||||
.insert_action_group("sync", Some(&actions));
|
||||
}
|
||||
|
||||
fn set_sync_paused(ui: &Rc<Ui>, paused: bool, until: Option<i64>) {
|
||||
ui.locations.card.pause.set_sensitive(false);
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::SetSyncPaused { paused, until },
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let what = if paused {
|
||||
"Couldn't pause sync"
|
||||
} else {
|
||||
"Couldn't resume sync"
|
||||
};
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => toast(
|
||||
&ui,
|
||||
match (paused, until) {
|
||||
(false, _) => "Sync resumed",
|
||||
(true, None) => "Sync paused until you resume",
|
||||
(true, Some(_)) => "Sync paused",
|
||||
},
|
||||
),
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, what, &message, kind),
|
||||
_ => toast_error(&ui, what, "The mount service didn't respond."),
|
||||
}
|
||||
ui.locations.card.pause.set_sensitive(true);
|
||||
refresh_status(&ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// The queue list, hidden while nothing waits.
|
||||
fn build_queue_group() -> (adw::PreferencesGroup, gtk4::Button) {
|
||||
let retry_all = gtk4::Button::builder()
|
||||
.label("Retry All")
|
||||
.valign(gtk4::Align::Center)
|
||||
.tooltip_text("Try every failed change again now")
|
||||
.visible(false)
|
||||
.build();
|
||||
retry_all.add_css_class("flat");
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Waiting to Upload")
|
||||
.description("Changes made on this computer that are not on Proton Drive yet.")
|
||||
.header_suffix(&retry_all)
|
||||
.visible(false)
|
||||
.build();
|
||||
(group, retry_all)
|
||||
}
|
||||
|
||||
/// Most queue rows shown at once. The queue can hold thousands after a big copy;
|
||||
/// the first screenful says what is going on, and the count says the rest.
|
||||
const QUEUE_ROWS_SHOWN: usize = 50;
|
||||
|
||||
/// Poll the pending-op queue while the Sync page is on screen.
|
||||
pub(crate) fn refresh_queue(ui: &Rc<Ui>) {
|
||||
if ui.locations.queue.inflight.get() {
|
||||
return;
|
||||
}
|
||||
ui.locations.queue.inflight.set(true);
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListPendingOps);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.locations.queue.inflight.set(false);
|
||||
match result {
|
||||
Ok(Ok(Response::PendingOps { items })) => repaint_queue(&ui, &items),
|
||||
// An older daemon without the request, or none at all: say nothing
|
||||
// rather than show a list that cannot be kept current.
|
||||
_ => repaint_queue(&ui, &[]),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn repaint_queue(ui: &Rc<Ui>, items: &[PendingOpInfo]) {
|
||||
let queue = &ui.locations.queue;
|
||||
// Stuck first — they are why the user is here — then oldest first.
|
||||
let mut items: Vec<&PendingOpInfo> = items.iter().collect();
|
||||
items.sort_by_key(|op| (!op.failing, op.attempts == 0, op.id));
|
||||
let key: Vec<QueueKey> = items
|
||||
.iter()
|
||||
.map(|op| (op.id, op.attempts, op.next_attempt_at, op.parked))
|
||||
.collect();
|
||||
if *queue.painted.borrow() == key {
|
||||
return;
|
||||
}
|
||||
*queue.painted.borrow_mut() = key;
|
||||
|
||||
for row in queue.rows.borrow_mut().drain(..) {
|
||||
queue.group.remove(&row);
|
||||
}
|
||||
queue.group.set_visible(!items.is_empty());
|
||||
queue
|
||||
.retry_all
|
||||
.set_visible(items.iter().any(|op| op.attempts > 0 && !op.parked));
|
||||
let hidden = items.len().saturating_sub(QUEUE_ROWS_SHOWN);
|
||||
queue.group.set_description(Some(&if hidden > 0 {
|
||||
format!(
|
||||
"Changes made on this computer that are not on Proton Drive yet. \
|
||||
Showing the first {QUEUE_ROWS_SHOWN} of {}.",
|
||||
items.len()
|
||||
)
|
||||
} else {
|
||||
"Changes made on this computer that are not on Proton Drive yet.".to_string()
|
||||
}));
|
||||
let now = glib::DateTime::now_utc()
|
||||
.map(|now| now.to_unix())
|
||||
.unwrap_or_default();
|
||||
let mut rows = queue.rows.borrow_mut();
|
||||
for op in items.into_iter().take(QUEUE_ROWS_SHOWN) {
|
||||
let row = queue_row(ui, op, now);
|
||||
queue.group.add(&row);
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_row(ui: &Rc<Ui>, op: &PendingOpInfo, now: i64) -> adw::ActionRow {
|
||||
let (icon, action) = match op.kind.as_str() {
|
||||
"revision" => ("pdfs-upload-symbolic", "Upload changes"),
|
||||
"create" => ("pdfs-upload-symbolic", "Upload new file"),
|
||||
"mkdir" => ("folder-new-symbolic", "Create folder"),
|
||||
"rename" => ("document-edit-symbolic", "Rename or move"),
|
||||
"trash" => ("user-trash-symbolic", "Move to trash"),
|
||||
_ => ("emblem-synchronizing-symbolic", "Change"),
|
||||
};
|
||||
let state = match (op.parked, op.next_attempt_at) {
|
||||
(true, _) => "waiting for the app writing it to finish".to_string(),
|
||||
(false, Some(at)) if at > now && op.attempts > 0 => {
|
||||
format!("retrying {}", clock_time(at))
|
||||
}
|
||||
(false, _) if ui.locations.card.paused.get() => "waiting for sync to resume".to_string(),
|
||||
(false, _) => "up next".to_string(),
|
||||
};
|
||||
let mut subtitle = format!("{action} · {state}");
|
||||
if let Some(error) = &op.last_error {
|
||||
subtitle.push_str(&format!(
|
||||
"\nFailed {}: {error}",
|
||||
count_noun(op.attempts.max(0) as usize, "time", "times")
|
||||
));
|
||||
}
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(glib::markup_escape_text(&op.path).as_str())
|
||||
.subtitle(glib::markup_escape_text(&subtitle).as_str())
|
||||
.title_lines(1)
|
||||
.subtitle_lines(3)
|
||||
.tooltip_text(&op.path)
|
||||
.build();
|
||||
let image = gtk4::Image::from_icon_name(if op.failing {
|
||||
"dialog-warning-symbolic"
|
||||
} else {
|
||||
icon
|
||||
});
|
||||
if op.failing {
|
||||
image.add_css_class("error");
|
||||
}
|
||||
row.add_prefix(&image);
|
||||
let waiting = !op.parked && op.attempts > 0 && op.next_attempt_at.is_some_and(|at| at > now);
|
||||
if waiting {
|
||||
let retry = gtk4::Button::builder()
|
||||
.icon_name("view-refresh-symbolic")
|
||||
.tooltip_text("Retry now")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry.add_css_class("flat");
|
||||
let ui = ui.clone();
|
||||
let id = op.id;
|
||||
retry.connect_clicked(move |_| retry_queued(&ui, Some(id)));
|
||||
row.add_suffix(&retry);
|
||||
}
|
||||
row
|
||||
}
|
||||
|
||||
/// Ask the daemon to stop waiting out one op's backoff, or every failed op's.
|
||||
fn retry_queued(ui: &Rc<Ui>, id: Option<i64>) {
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::RetryPendingOp { id });
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
toast(&ui, "Retrying now…");
|
||||
ui.locations.queue.painted.borrow_mut().clear();
|
||||
refresh_queue(&ui);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't retry", &message, kind)
|
||||
}
|
||||
_ => toast_error(&ui, "Couldn't retry", "The mount service didn't respond."),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// How long a conflict listing stays fresh on the refresh tick.
|
||||
pub(crate) const CONFLICTS_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Poll the conflict list while the Sync page is on screen. `force` skips the
|
||||
/// TTL, for navigation and right after a resolution.
|
||||
pub(crate) fn refresh_conflicts(ui: &Rc<Ui>, force: bool) {
|
||||
let conflicts = &ui.locations.conflicts;
|
||||
if conflicts.inflight.get()
|
||||
|| (!force
|
||||
&& conflicts
|
||||
.fetched_at
|
||||
.get()
|
||||
.is_some_and(|at| at.elapsed() < CONFLICTS_TTL))
|
||||
{
|
||||
return;
|
||||
}
|
||||
conflicts.inflight.set(true);
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListConflicts);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
let conflicts = &ui.locations.conflicts;
|
||||
conflicts.inflight.set(false);
|
||||
conflicts.fetched_at.set(Some(Instant::now()));
|
||||
match result {
|
||||
Ok(Ok(Response::Conflicts { items })) => repaint_conflicts(&ui, &items),
|
||||
// Same as the queue: an older daemon, or none, shows nothing.
|
||||
_ => repaint_conflicts(&ui, &[]),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn repaint_conflicts(ui: &Rc<Ui>, items: &[ConflictInfo]) {
|
||||
let conflicts = &ui.locations.conflicts;
|
||||
let key: Vec<ConflictKey> = items
|
||||
.iter()
|
||||
.map(|c| {
|
||||
(
|
||||
c.path.clone(),
|
||||
c.size,
|
||||
c.modified,
|
||||
c.original_size,
|
||||
c.original_modified,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if *conflicts.painted.borrow() == key {
|
||||
return;
|
||||
}
|
||||
*conflicts.painted.borrow_mut() = key;
|
||||
|
||||
for row in conflicts.rows.borrow_mut().drain(..) {
|
||||
conflicts.group.remove(&row);
|
||||
}
|
||||
conflicts.group.set_visible(!items.is_empty());
|
||||
let mut rows = conflicts.rows.borrow_mut();
|
||||
for conflict in items {
|
||||
let row = conflict_row(ui, conflict);
|
||||
conflicts.group.add(&row);
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
/// "12.3 MB, changed at 14:30": one side of a conflict.
|
||||
fn conflict_side(size: u64, modified: i64) -> String {
|
||||
format!(
|
||||
"{}, changed {}",
|
||||
glib::format_size(size),
|
||||
clock_time(modified)
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn conflict_row(ui: &Rc<Ui>, conflict: &ConflictInfo) -> adw::ActionRow {
|
||||
let verdict = if !conflict.original_exists {
|
||||
"The original is gone; only this copy is left".to_string()
|
||||
} else if conflict.identical {
|
||||
"Same content as the original".to_string()
|
||||
} else {
|
||||
format!("Differs from {}", file_name(&conflict.original_path))
|
||||
};
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(glib::markup_escape_text(&conflict.path).as_str())
|
||||
.subtitle(glib::markup_escape_text(&verdict).as_str())
|
||||
.title_lines(1)
|
||||
.tooltip_text(&conflict.path)
|
||||
.activatable(true)
|
||||
.build();
|
||||
let image = gtk4::Image::from_icon_name("dialog-warning-symbolic");
|
||||
image.add_css_class("warning");
|
||||
row.add_prefix(&image);
|
||||
row.add_suffix(>k4::Image::from_icon_name("go-next-symbolic"));
|
||||
let ui = ui.clone();
|
||||
let conflict = conflict.clone();
|
||||
row.connect_activated(move |_| prompt_resolve_conflict(&ui, &conflict));
|
||||
row
|
||||
}
|
||||
|
||||
/// The last component of a mount-relative path.
|
||||
pub(crate) fn file_name(path: &str) -> &str {
|
||||
path.rsplit('/').next().unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Ask which version of a conflicted file to keep.
|
||||
pub(crate) fn prompt_resolve_conflict(ui: &Rc<Ui>, conflict: &ConflictInfo) {
|
||||
let win = ui_window(ui);
|
||||
let copy_name = file_name(&conflict.path).to_string();
|
||||
let original_name = file_name(&conflict.original_path).to_string();
|
||||
let body = match (conflict.original_size, conflict.original_modified) {
|
||||
(Some(size), Some(modified)) if conflict.original_exists => format!(
|
||||
"“{original_name}” was changed here and elsewhere at the same time.\n\n\
|
||||
Original: {}\nCopy: {}\n\n\
|
||||
Whatever is not kept goes to Trash, where it can be restored.",
|
||||
conflict_side(size, modified),
|
||||
conflict_side(conflict.size, conflict.modified),
|
||||
),
|
||||
_ => format!(
|
||||
"The original “{original_name}” no longer exists; only the copy is left \
|
||||
({}).\n\nKeep the copy to give it the original name back.",
|
||||
conflict_side(conflict.size, conflict.modified),
|
||||
),
|
||||
};
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Resolve conflict")
|
||||
.body(body)
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let name_row = adw::EntryRow::builder()
|
||||
.title("Name for the copy, if keeping both")
|
||||
.build();
|
||||
name_row.set_text(©_name);
|
||||
group.add(&name_row);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
if conflict.original_exists {
|
||||
dialog.add_response("original", "Keep Original");
|
||||
dialog.add_response("both", "Keep Both");
|
||||
}
|
||||
dialog.add_response("copy", "Keep Copy");
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
// Keeping both is a rename, so it needs a name that is neither side's.
|
||||
let update_both = {
|
||||
let dialog = dialog.clone();
|
||||
let copy_name = copy_name.clone();
|
||||
move |row: &adw::EntryRow| {
|
||||
let name = row.text();
|
||||
let name = name.trim();
|
||||
dialog.set_response_enabled(
|
||||
"both",
|
||||
!name.is_empty()
|
||||
&& name != copy_name
|
||||
&& name != original_name
|
||||
&& !name.contains('/'),
|
||||
);
|
||||
}
|
||||
};
|
||||
if conflict.original_exists {
|
||||
update_both(&name_row);
|
||||
name_row.connect_changed(update_both);
|
||||
}
|
||||
let ui = ui.clone();
|
||||
let path = conflict.path.clone();
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
let keep = match resp {
|
||||
"original" => ConflictKeep::Original,
|
||||
"copy" => ConflictKeep::Copy,
|
||||
"both" => ConflictKeep::Both {
|
||||
name: name_row.text().trim().to_string(),
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
resolve_conflict(&ui, path.clone(), keep);
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
fn resolve_conflict(ui: &Rc<Ui>, path: String, keep: ConflictKeep) {
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::ResolveConflict { path, keep },
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
match result {
|
||||
Ok(Ok(Response::Ok { message })) => {
|
||||
toast(&ui, &capitalize(&message));
|
||||
refresh_conflicts(&ui, true);
|
||||
refresh_activity_conflicts(&ui, true);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't resolve the conflict", &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
"Couldn't resolve the conflict",
|
||||
"The mount service didn't respond.",
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// First letter upper-cased, for a daemon message used as a sentence.
|
||||
pub(crate) fn capitalize(message: &str) -> String {
|
||||
let mut chars = message.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().chain(chars).collect(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a status page in place of the locations list.
|
||||
@@ -327,8 +902,8 @@ pub(crate) fn repaint_locations(ui: &Rc<Ui>, items: &[MountSpec]) {
|
||||
}
|
||||
|
||||
/// The primary mount's only control: where it lives. Changing it rewrites config
|
||||
/// and offers a service restart, which is why it stays a single prompt shared
|
||||
/// with the (now removed) Settings row.
|
||||
/// and offers a service restart, which is why it goes through the shared
|
||||
/// [`prompt_mountpoint`] dialog.
|
||||
fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) {
|
||||
let change = gtk4::Button::builder()
|
||||
.label("Change")
|
||||
@@ -341,10 +916,30 @@ fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) {
|
||||
row.add_suffix(&change);
|
||||
}
|
||||
|
||||
/// Sync now (mirror only), the on-demand switch, and Remove — the same three
|
||||
/// controls, and the same handlers, the Computers page used to carry.
|
||||
/// Pause or resume and Sync now (mirror only), the on-demand switch, and Stop
|
||||
/// syncing for one synced folder.
|
||||
fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id: i64) {
|
||||
if spec.mode != MountMode::OnDemand {
|
||||
let (icon, tooltip) = match spec.paused {
|
||||
true => (
|
||||
"media-playback-start-symbolic",
|
||||
"Resume syncing this folder",
|
||||
),
|
||||
false => ("media-playback-pause-symbolic", "Pause syncing this folder"),
|
||||
};
|
||||
let pause = gtk4::Button::builder()
|
||||
.icon_name(icon)
|
||||
.tooltip_text(tooltip)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
pause.add_css_class("flat");
|
||||
let ui_pause = ui.clone();
|
||||
let paused = spec.paused;
|
||||
pause.connect_clicked(move |_| set_folder_paused(&ui_pause, id, !paused));
|
||||
row.add_suffix(&pause);
|
||||
}
|
||||
// A paused folder skips its passes, so Sync now would do nothing.
|
||||
if spec.mode != MountMode::OnDemand && !spec.paused {
|
||||
let sync_now = gtk4::Button::builder()
|
||||
.icon_name("view-refresh-symbolic")
|
||||
.tooltip_text("Sync this folder now")
|
||||
@@ -369,16 +964,27 @@ fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id:
|
||||
)
|
||||
.valign(gtk4::Align::Center)
|
||||
.active(target == MountMode::OnDemand)
|
||||
// The daemon refuses a mode switch on a paused folder.
|
||||
.sensitive(!spec.paused)
|
||||
.build();
|
||||
let ui_mode = ui.clone();
|
||||
let mode_path = spec.local_path.clone();
|
||||
ondemand.connect_state_set(move |_, on| {
|
||||
set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" });
|
||||
glib::Propagation::Proceed
|
||||
if on {
|
||||
// Going on-demand deletes the local copies, so it is asked for rather
|
||||
// than done on a flick of the switch. Stop leaves the switch's state
|
||||
// alone; the next repaint paints whatever the daemon then reports.
|
||||
confirm_ondemand(&ui_mode, id, &mode_path);
|
||||
glib::Propagation::Stop
|
||||
} else {
|
||||
set_sync_folder_mode(&ui_mode, id, "mirror");
|
||||
glib::Propagation::Proceed
|
||||
}
|
||||
});
|
||||
row.add_suffix(&ondemand);
|
||||
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.icon_name("media-playback-stop-symbolic")
|
||||
.tooltip_text("Stop syncing this folder")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
@@ -392,6 +998,60 @@ fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id:
|
||||
row.add_suffix(&remove);
|
||||
}
|
||||
|
||||
/// Ask before switching a synced folder to on-demand: the switch frees disk
|
||||
/// space by removing the local copies, which is not something to do by accident.
|
||||
fn confirm_ondemand(ui: &Rc<Ui>, id: i64, path: &str) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Make Folder On-Demand?")
|
||||
.body(format!(
|
||||
"Files in {path} will be removed from this computer and kept in Proton \
|
||||
Drive only. Each file downloads again when you open it."
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("ondemand", "Make On-Demand");
|
||||
dialog.set_response_appearance("ondemand", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
let win = ui_window(ui);
|
||||
let ui = ui.clone();
|
||||
dialog.connect_response(None, move |_, response| {
|
||||
if response == "ondemand" {
|
||||
set_sync_folder_mode(&ui, id, "ondemand");
|
||||
} else {
|
||||
refresh_locations(&ui);
|
||||
}
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Pause or resume one synced folder, then repaint so its row shows the result.
|
||||
fn set_folder_paused(ui: &Rc<Ui>, id: i64, paused: bool) {
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::SetSyncFolderPaused { id, paused },
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let failed = match paused {
|
||||
true => "Couldn't pause the folder",
|
||||
false => "Couldn't resume the folder",
|
||||
};
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => toast(
|
||||
&ui,
|
||||
match paused {
|
||||
true => "Folder paused",
|
||||
false => "Folder resumed",
|
||||
},
|
||||
),
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
||||
_ => toast_error(&ui, failed, "The mount service didn't respond."),
|
||||
}
|
||||
refresh_locations(&ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// Ask the daemon for an immediate pass over one folder.
|
||||
fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::SyncNow { id: Some(id) });
|
||||
@@ -439,6 +1099,7 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
|
||||
if matches!(spec.kind, MountKind::Device { .. }) {
|
||||
parts.push(match &spec.progress {
|
||||
Some(p) => sync_progress_label(p),
|
||||
None if spec.paused => "paused".to_string(),
|
||||
None => sync_state_label(&spec.state).to_string(),
|
||||
});
|
||||
}
|
||||
@@ -471,6 +1132,7 @@ mod tests {
|
||||
pending_mode: None,
|
||||
mounted,
|
||||
progress: None,
|
||||
paused: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,6 +1148,17 @@ mod tests {
|
||||
assert_eq!(location_subtitle(&spec), "Synced · up to date");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paused_folder_says_paused_instead_of_its_state() {
|
||||
let mut spec = spec(
|
||||
MountKind::Device { sync_folder_id: 7 },
|
||||
MountMode::Mirror,
|
||||
false,
|
||||
);
|
||||
spec.paused = true;
|
||||
assert_eq!(location_subtitle(&spec), "Synced · paused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_on_demand_folder_without_a_session_says_so() {
|
||||
let spec = spec(
|
||||
|
||||
@@ -6,22 +6,20 @@ pub(crate) struct LoginState {
|
||||
pub(crate) email: adw::EntryRow,
|
||||
pub(crate) password: adw::PasswordEntryRow,
|
||||
pub(crate) login_button: gtk4::Button,
|
||||
/// Spins inside the Sign in button while a sign-in is running.
|
||||
pub(crate) login_spinner: gtk4::Spinner,
|
||||
pub(crate) login_status: gtk4::Label,
|
||||
}
|
||||
|
||||
/// The login page: an email row, password row, a primary "Sign in" button and a
|
||||
/// status label, centred in a clamp. The 2FA code is prompted lazily in a
|
||||
/// dialog (see [`prompt_2fa`]) only when the account actually requires it.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(crate) fn build_login_page() -> (
|
||||
gtk4::Widget,
|
||||
(
|
||||
adw::EntryRow,
|
||||
adw::PasswordEntryRow,
|
||||
gtk4::Button,
|
||||
gtk4::Label,
|
||||
),
|
||||
) {
|
||||
/// Where the login page's account links go.
|
||||
const SIGNUP_URL: &str = "https://account.proton.me/drive/signup";
|
||||
const RESET_PASSWORD_URL: &str = "https://account.proton.me/reset-password";
|
||||
|
||||
/// The login page: an email row, password row, a primary "Sign in" button, a
|
||||
/// status label and links to create an account or reset the password, centred
|
||||
/// in a clamp. The 2FA code is prompted lazily in a dialog (see [`prompt_2fa`])
|
||||
/// only when the account actually requires it.
|
||||
pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Sign in to Proton")
|
||||
.description("Use your Proton account to connect Drive.")
|
||||
@@ -32,8 +30,12 @@ pub(crate) fn build_login_page() -> (
|
||||
group.add(&email);
|
||||
group.add(&password);
|
||||
|
||||
let login_spinner = gtk4::Spinner::builder().visible(false).build();
|
||||
let button_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
button_content.append(&login_spinner);
|
||||
button_content.append(>k4::Label::new(Some("Sign in")));
|
||||
let login_button = gtk4::Button::builder()
|
||||
.label("Sign in")
|
||||
.child(&button_content)
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
login_button.add_css_class("suggested-action");
|
||||
@@ -64,6 +66,14 @@ pub(crate) fn build_login_page() -> (
|
||||
inner.append(&group);
|
||||
inner.append(&login_button);
|
||||
inner.append(&login_status);
|
||||
let links = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
links.set_halign(gtk4::Align::Center);
|
||||
links.append(>k4::LinkButton::with_label(SIGNUP_URL, "Create account"));
|
||||
links.append(>k4::LinkButton::with_label(
|
||||
RESET_PASSWORD_URL,
|
||||
"Forgot password?",
|
||||
));
|
||||
inner.append(&links);
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(420)
|
||||
@@ -73,7 +83,13 @@ pub(crate) fn build_login_page() -> (
|
||||
|
||||
(
|
||||
scroll.upcast(),
|
||||
(email, password, login_button, login_status),
|
||||
LoginState {
|
||||
email,
|
||||
password,
|
||||
login_button,
|
||||
login_spinner,
|
||||
login_status,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,7 +119,7 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
return;
|
||||
}
|
||||
|
||||
ui.login.login_button.set_sensitive(false);
|
||||
set_signing_in(&ui, true);
|
||||
ui.login.login_status.set_text("Signing in…");
|
||||
let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password);
|
||||
|
||||
@@ -140,7 +156,7 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
.recv()
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("login cancelled".into()));
|
||||
ui.login.login_button.set_sensitive(true);
|
||||
set_signing_in(&ui, false);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
ui.login.login_status.set_text("");
|
||||
@@ -151,15 +167,53 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
service::enable_start();
|
||||
refresh(&ui);
|
||||
}
|
||||
Err(e) => ui
|
||||
.login
|
||||
.login_status
|
||||
.set_text(&format!("Sign-in failed: {e}")),
|
||||
Err(e) => ui.login.login_status.set_text(&e),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Lock the form and spin the button while a sign-in runs.
|
||||
fn set_signing_in(ui: &Rc<Ui>, running: bool) {
|
||||
ui.login.login_button.set_sensitive(!running);
|
||||
ui.login.email.set_sensitive(!running);
|
||||
ui.login.password.set_sensitive(!running);
|
||||
ui.login.login_spinner.set_visible(running);
|
||||
ui.login.login_spinner.set_spinning(running);
|
||||
}
|
||||
|
||||
/// What to tell the person when sign-in fails. Proton's own API messages are
|
||||
/// written for people ("Incorrect login credentials…"), so they pass through;
|
||||
/// everything else is mapped from its kind rather than shown as a debug string.
|
||||
pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
|
||||
use pdfs_core::proton_sdk::ProtonError;
|
||||
match error {
|
||||
pdfs_core::Error::Proton(ProtonError::Api(api)) if api.http_status == 429 => {
|
||||
"Too many sign-in attempts. Wait a few minutes and try again.".to_string()
|
||||
}
|
||||
pdfs_core::Error::Proton(ProtonError::Api(api)) if !api.message.is_empty() => {
|
||||
api.message.clone()
|
||||
}
|
||||
pdfs_core::Error::Proton(ProtonError::Transport(_)) => {
|
||||
"Couldn't reach Proton. Check your internet connection and try again.".to_string()
|
||||
}
|
||||
pdfs_core::Error::Keyring(_) => {
|
||||
"Signed in, but the session couldn't be saved to the system keyring. Make sure a \
|
||||
keyring (GNOME Keyring or KWallet) is running and unlocked."
|
||||
.to_string()
|
||||
}
|
||||
pdfs_core::Error::Other(message) if message.contains("two-factor") => {
|
||||
"Sign-in cancelled: no two-factor code was entered.".to_string()
|
||||
}
|
||||
pdfs_core::Error::Other(message) if message.contains("verification") => {
|
||||
"Sign-in cancelled: the verification wasn't completed. Sign in again to get a new \
|
||||
one."
|
||||
.to_string()
|
||||
}
|
||||
other => format!("Sign-in failed: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio
|
||||
/// runtime. Returns two channels: the first yields the final login result once;
|
||||
/// the second fires *only if* the account needs a 2FA code, carrying a
|
||||
@@ -223,7 +277,7 @@ pub(crate) fn spawn_login(
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.map_err(|e| login_error_message(&e))
|
||||
});
|
||||
let _ = tx.send_blocking(result);
|
||||
});
|
||||
@@ -236,7 +290,7 @@ pub(crate) fn spawn_login(
|
||||
pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Two-factor authentication")
|
||||
.body("Enter the code from your authenticator app.")
|
||||
.body("Enter the code from your authenticator app, or one of your recovery codes.")
|
||||
.build();
|
||||
|
||||
let group = adw::PreferencesGroup::new();
|
||||
@@ -268,17 +322,44 @@ pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>)
|
||||
dialog.present(parent.as_ref());
|
||||
}
|
||||
|
||||
/// Connect the sign-out button: disable+stop the mount service (so the daemon
|
||||
/// isn't left running without credentials), forget the stored session, and drop
|
||||
/// back to the login page.
|
||||
pub(crate) fn wire_logout(ui: &Rc<Ui>, button: >k4::Button) {
|
||||
/// Sign out, after a confirmation: disable+stop the mount service (so the
|
||||
/// daemon isn't left running without credentials), forget the stored session,
|
||||
/// and drop back to the login page.
|
||||
pub(crate) fn sign_out(ui: &Rc<Ui>) {
|
||||
let Some(window) = ui_window(ui) else { return };
|
||||
let ui = ui.clone();
|
||||
button.connect_clicked(move |_| {
|
||||
service::disable_stop();
|
||||
if let Err(e) = auth::logout() {
|
||||
tracing::error!("logout failed: {e}");
|
||||
}
|
||||
*ui.session.borrow_mut() = None;
|
||||
refresh(&ui);
|
||||
});
|
||||
confirm_destructive(
|
||||
&window,
|
||||
"Sign Out?",
|
||||
"Proton Drive will disconnect and stop syncing until you sign in again. \
|
||||
Files already on this computer stay where they are.",
|
||||
"Sign Out",
|
||||
move || {
|
||||
service::disable_stop();
|
||||
if let Err(e) = auth::logout() {
|
||||
tracing::error!("logout failed: {e}");
|
||||
}
|
||||
*ui.session.borrow_mut() = None;
|
||||
refresh(&ui);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::login_error_message;
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_prompt_says_which_step_was_skipped() {
|
||||
let totp = pdfs_core::Error::Other("two-factor entry cancelled".into());
|
||||
assert!(login_error_message(&totp).contains("two-factor code"));
|
||||
let hv = pdfs_core::Error::Other("verification cancelled".into());
|
||||
assert!(login_error_message(&hv).contains("verification wasn't completed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unmapped_error_keeps_its_text() {
|
||||
let error = pdfs_core::Error::Other("something odd".into());
|
||||
assert_eq!(login_error_message(&error), "Sign-in failed: something odd");
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,37 @@
|
||||
use crate::*;
|
||||
|
||||
/// How long the pointer has to rest before the lightbox's controls fade out.
|
||||
const CHROME_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Factor one zoom step (a `+`/`-` key, a wheel notch) multiplies by.
|
||||
const ZOOM_STEP: f64 = 1.25;
|
||||
|
||||
/// The furthest the lightbox zooms in, as a multiple of the image's own pixels.
|
||||
const MAX_ZOOM: f64 = 8.0;
|
||||
|
||||
/// How close to the end of the loaded photos stepping forward starts paging the
|
||||
/// next batch in, so reaching the end rarely means waiting for it.
|
||||
const PAGE_AHEAD: u32 = 10;
|
||||
|
||||
/// The lightbox's mutable parts, shared by [`load_photo`], [`navigate_photo`] and
|
||||
/// the button/key handlers so each one takes a single handle instead of a dozen
|
||||
/// widget arguments.
|
||||
pub(crate) struct Viewer {
|
||||
/// The lightbox itself, so a page that lands after it closed can tell.
|
||||
pub(crate) window: gtk4::Window,
|
||||
/// Swaps the still (`"photo"`, inside [`Self::scroller`]) for the player
|
||||
/// (`"video"`).
|
||||
pub(crate) media: gtk4::Stack,
|
||||
pub(crate) scroller: gtk4::ScrolledWindow,
|
||||
pub(crate) picture: gtk4::Picture,
|
||||
pub(crate) video: gtk4::Video,
|
||||
pub(crate) spinner: gtk4::Spinner,
|
||||
pub(crate) status: gtk4::Label,
|
||||
pub(crate) title: gtk4::Label,
|
||||
pub(crate) counter: gtk4::Label,
|
||||
pub(crate) prev: gtk4::Button,
|
||||
pub(crate) next: gtk4::Button,
|
||||
/// Details drawer: the toggle that reveals it, the rows it fills in, and the
|
||||
/// Details panel: the toggle that reveals it, the rows it fills in, and the
|
||||
/// "Show on map" button (hidden when the photo carries no GPS tags).
|
||||
pub(crate) info_toggle: gtk4::ToggleButton,
|
||||
pub(crate) info_revealer: gtk4::Revealer,
|
||||
@@ -19,7 +39,7 @@ pub(crate) struct Viewer {
|
||||
pub(crate) info_map: gtk4::Button,
|
||||
/// Coordinates behind `info_map`, once a photo with GPS tags is shown.
|
||||
pub(crate) coords: RefCell<Option<(f64, f64)>>,
|
||||
/// The favourite toggle in the top bar, and whether it is being set from
|
||||
/// The favorite toggle in the top bar, and whether it is being set from
|
||||
/// the model rather than by the user — the same suppression the details pane
|
||||
/// uses for its pin switch, so painting a photo doesn't fire a round-trip.
|
||||
pub(crate) favorite: gtk4::ToggleButton,
|
||||
@@ -31,6 +51,8 @@ pub(crate) struct Viewer {
|
||||
pub(crate) group: RefCell<Vec<PhotoItem>>,
|
||||
/// uid of the photo currently on screen.
|
||||
pub(crate) uid: RefCell<String>,
|
||||
/// Drive name of the photo on screen, for Save a Copy and Open With.
|
||||
pub(crate) name: RefCell<Option<String>>,
|
||||
/// On-disk path of the full-size photo, once it has been downloaded.
|
||||
pub(crate) path: RefCell<Option<String>>,
|
||||
/// True while the full-size photo is still downloading and what's on screen is
|
||||
@@ -39,10 +61,25 @@ pub(crate) struct Viewer {
|
||||
/// late reply for a photo the user has already navigated away from must not
|
||||
/// overwrite the new one. Guarded by comparing against `uid`.
|
||||
pub(crate) loading: Cell<bool>,
|
||||
/// Zoom as a multiple of the image's own pixels, or `None` while the photo
|
||||
/// is fitted to the window.
|
||||
pub(crate) zoom: Cell<Option<f64>>,
|
||||
/// The zoom a pinch started from, and the scroll offsets a pan started from.
|
||||
pub(crate) pinch_start: Cell<f64>,
|
||||
pub(crate) drag_start: Cell<(f64, f64)>,
|
||||
/// Last pointer position over the image, which Ctrl+scroll zooms around.
|
||||
pub(crate) pointer: Cell<(f64, f64)>,
|
||||
/// The top bar and the prev/next buttons, which fade out once the pointer
|
||||
/// rests; whether the pointer is over one of them (which keeps them up);
|
||||
/// and the pending fade-out.
|
||||
pub(crate) chrome: Vec<gtk4::Revealer>,
|
||||
pub(crate) chrome_hover: Cell<bool>,
|
||||
pub(crate) chrome_source: RefCell<Option<glib::SourceId>>,
|
||||
}
|
||||
|
||||
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
|
||||
/// label/value pairs for the details drawer.
|
||||
/// label/value pairs for the details panel.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ExifInfo {
|
||||
/// `("Camera", "Apple iPhone 15")` and friends; empty when the file has no
|
||||
/// EXIF at all, which is normal for screenshots and re-encoded images.
|
||||
@@ -51,7 +88,7 @@ pub(crate) struct ExifInfo {
|
||||
pub(crate) coords: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
/// Paint the favourite toggle without firing its handler.
|
||||
/// Paint the favorite toggle without firing its handler.
|
||||
pub(crate) fn show_favorite(viewer: &Rc<Viewer>, favorite: bool) {
|
||||
viewer.favorite_suppress.set(true);
|
||||
viewer.favorite.set_active(favorite);
|
||||
@@ -59,7 +96,7 @@ pub(crate) fn show_favorite(viewer: &Rc<Viewer>, favorite: bool) {
|
||||
viewer.favorite_suppress.set(false);
|
||||
}
|
||||
|
||||
/// Filled star for a favourite, outline for the rest.
|
||||
/// Filled star for a favorite, outline for the rest.
|
||||
fn set_favorite_icon(button: >k4::ToggleButton, favorite: bool) {
|
||||
button.set_icon_name(if favorite {
|
||||
"starred-symbolic"
|
||||
@@ -68,9 +105,9 @@ fn set_favorite_icon(button: >k4::ToggleButton, favorite: bool) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Write a favourite change back into the loaded gallery page, so returning to
|
||||
/// Write a favorite change back into the loaded gallery page, so returning to
|
||||
/// the grid (or reopening the photo) shows what was just set without a reload.
|
||||
fn set_gallery_favorite(ui: &Rc<Ui>, uid: &str, favorite: bool) {
|
||||
pub(crate) fn set_gallery_favorite(ui: &Rc<Ui>, uid: &str, favorite: bool) {
|
||||
let Some(idx) = find_photo_index(&ui.gallery.model, uid) else {
|
||||
return;
|
||||
};
|
||||
@@ -83,9 +120,6 @@ fn set_gallery_favorite(ui: &Rc<Ui>, uid: &str, favorite: bool) {
|
||||
ui.gallery.model.insert(idx, &BoxedAnyObject::new(item));
|
||||
}
|
||||
|
||||
/// Show the photo behind `uid`: paint its (already cached) thumbnail immediately
|
||||
/// so the lightbox never opens on a blank screen, ask the daemon for the
|
||||
/// full-size file, and swap it in — plus its EXIF — when it lands.
|
||||
/// Ask the daemon which files this shot was stored as, and offer a switch when
|
||||
/// there is more than one. The grid shows such a shot as a single tile, so the
|
||||
/// lightbox is the only place the RAW is reachable.
|
||||
@@ -139,6 +173,27 @@ pub(crate) fn member_label(item: &PhotoItem) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The gallery's entry for `uid`, or the shot member it names when the group
|
||||
/// switch has left the timeline's own photo (a RAW is not in the timeline).
|
||||
fn viewer_item(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) -> Option<PhotoItem> {
|
||||
find_photo_index(&ui.gallery.model, uid)
|
||||
.and_then(|idx| ui.gallery.model.item(idx))
|
||||
.and_downcast::<BoxedAnyObject>()
|
||||
.map(|boxed| boxed.borrow::<PhotoItem>().clone())
|
||||
.or_else(|| {
|
||||
viewer
|
||||
.group
|
||||
.borrow()
|
||||
.iter()
|
||||
.find(|item| item.uid == uid)
|
||||
.cloned()
|
||||
})
|
||||
}
|
||||
|
||||
/// Show the photo behind `uid`: paint its (already cached) thumbnail immediately
|
||||
/// so the lightbox never opens on a blank screen, ask the daemon for the
|
||||
/// full-size file, and swap it in — plus its EXIF — when it lands. A video
|
||||
/// plays in place once its file is down.
|
||||
pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
viewer.spinner.set_visible(true);
|
||||
viewer.spinner.start();
|
||||
@@ -146,14 +201,19 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
viewer.loading.set(true);
|
||||
*viewer.path.borrow_mut() = None;
|
||||
clear_info(viewer);
|
||||
stop_video(viewer);
|
||||
viewer.media.set_visible_child_name("photo");
|
||||
reset_zoom(viewer);
|
||||
|
||||
// The favourite state comes from the timeline page the gallery already has,
|
||||
// Looked up before the group is refetched, which empties it.
|
||||
let item = viewer_item(ui, viewer, &uid);
|
||||
let is_video = item
|
||||
.as_ref()
|
||||
.is_some_and(|item| item.kind == PhotoKind::Video);
|
||||
// The favorite state comes from the timeline page the gallery already has,
|
||||
// so the button is right on the first frame instead of after a round-trip.
|
||||
let favorite = find_photo_index(&ui.gallery.model, &uid)
|
||||
.and_then(|idx| ui.gallery.model.item(idx))
|
||||
.and_downcast::<BoxedAnyObject>()
|
||||
.is_some_and(|boxed| boxed.borrow::<PhotoItem>().favorite);
|
||||
show_favorite(viewer, favorite);
|
||||
show_favorite(viewer, item.as_ref().is_some_and(|item| item.favorite));
|
||||
*viewer.name.borrow_mut() = item.and_then(|item| item.name);
|
||||
load_photo_group(ui, viewer, &uid);
|
||||
|
||||
// The thumbnail the gallery already decoded stands in for the full photo
|
||||
@@ -184,9 +244,26 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
viewer.status.set_visible(true);
|
||||
};
|
||||
match result {
|
||||
Ok(Ok(Response::FilePath { path })) if is_video => {
|
||||
viewer.video.set_filename(Some(&path));
|
||||
viewer.media.set_visible_child_name("video");
|
||||
*viewer.path.borrow_mut() = Some(path.clone());
|
||||
show_info(&viewer, &path, ExifInfo::default());
|
||||
}
|
||||
Ok(Ok(Response::FilePath { path })) => match gtk4::gdk::Texture::from_filename(&path) {
|
||||
Ok(texture) => {
|
||||
// Zoomed in on the thumbnail: keep the photo the size it is
|
||||
// on screen, now drawn from the full-size pixels.
|
||||
let rescale = viewer
|
||||
.zoom
|
||||
.get()
|
||||
.zip(intrinsic_size(&viewer.picture))
|
||||
.map(|(zoom, (width, _))| zoom * width / f64::from(texture.width()));
|
||||
viewer.picture.set_paintable(Some(&texture));
|
||||
if let Some(zoom) = rescale {
|
||||
viewer.zoom.set(Some(zoom));
|
||||
zoom_to(&viewer, Some(zoom), None);
|
||||
}
|
||||
*viewer.path.borrow_mut() = Some(path.clone());
|
||||
show_info(&viewer, &path, read_exif(&path));
|
||||
}
|
||||
@@ -202,6 +279,12 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop whatever the player was playing, so stepping off a video (or closing
|
||||
/// the lightbox) doesn't leave its sound running.
|
||||
fn stop_video(viewer: &Rc<Viewer>) {
|
||||
viewer.video.set_media_stream(gtk4::MediaStream::NONE);
|
||||
}
|
||||
|
||||
/// Warm the cache with the photo `delta` steps away, so stepping there is
|
||||
/// instant. Fire-and-forget: the daemon serves control connections concurrently,
|
||||
/// so this rides alongside whatever the user does next, and a failure here is
|
||||
@@ -385,93 +468,308 @@ pub(crate) fn gps_degrees(reader: &exif::Exif, tag: exif::Tag, ref_tag: exif::Ta
|
||||
Some(if negative { -degrees } else { degrees })
|
||||
}
|
||||
|
||||
pub(crate) fn save_photo_to_disk(window: >k4::Window, source_path: &str, original_name: &str) {
|
||||
/// The image's own size in pixels, once something is painted.
|
||||
fn intrinsic_size(picture: >k4::Picture) -> Option<(f64, f64)> {
|
||||
let paintable = picture.paintable()?;
|
||||
let (width, height) = (
|
||||
f64::from(paintable.intrinsic_width()),
|
||||
f64::from(paintable.intrinsic_height()),
|
||||
);
|
||||
(width > 0.0 && height > 0.0).then_some((width, height))
|
||||
}
|
||||
|
||||
/// The scale the photo is drawn at: its zoom, or the one that fits it to the
|
||||
/// window.
|
||||
fn current_scale(viewer: &Rc<Viewer>) -> Option<f64> {
|
||||
if let Some(zoom) = viewer.zoom.get() {
|
||||
return Some(zoom);
|
||||
}
|
||||
let (width, height) = intrinsic_size(&viewer.picture)?;
|
||||
let (view_w, view_h) = (
|
||||
f64::from(viewer.scroller.width()),
|
||||
f64::from(viewer.scroller.height()),
|
||||
);
|
||||
(view_w > 0.0 && view_h > 0.0).then(|| (view_w / width).min(view_h / height))
|
||||
}
|
||||
|
||||
/// Fit the photo to the window again.
|
||||
fn reset_zoom(viewer: &Rc<Viewer>) {
|
||||
viewer.zoom.set(None);
|
||||
viewer.picture.set_size_request(-1, -1);
|
||||
viewer.picture.set_cursor(None);
|
||||
viewer
|
||||
.scroller
|
||||
.set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Never);
|
||||
}
|
||||
|
||||
/// Zoom to `scale` × the image's own pixels, keeping the point under `anchor`
|
||||
/// (in the image area's coordinates; its centre when `None`) where it is.
|
||||
/// `None`, or anything at or below the fitted size, fits the photo again.
|
||||
fn zoom_to(viewer: &Rc<Viewer>, scale: Option<f64>, anchor: Option<(f64, f64)>) {
|
||||
let Some((width, height)) = intrinsic_size(&viewer.picture) else {
|
||||
return;
|
||||
};
|
||||
let (view_w, view_h) = (
|
||||
f64::from(viewer.scroller.width()),
|
||||
f64::from(viewer.scroller.height()),
|
||||
);
|
||||
if view_w <= 0.0 || view_h <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let fit = (view_w / width).min(view_h / height);
|
||||
let Some(target) = scale
|
||||
.filter(|scale| *scale > fit * 1.01)
|
||||
.map(|scale| scale.min(MAX_ZOOM.max(fit)))
|
||||
else {
|
||||
reset_zoom(viewer);
|
||||
return;
|
||||
};
|
||||
|
||||
let (anchor_x, anchor_y) = anchor.unwrap_or((view_w / 2.0, view_h / 2.0));
|
||||
let current = viewer.zoom.get().unwrap_or(fit);
|
||||
let (h, v) = (viewer.scroller.hadjustment(), viewer.scroller.vadjustment());
|
||||
// Where the anchor sits on the image, as a fraction of it — the same spot
|
||||
// has to be under the pointer after the resize. An image smaller than the
|
||||
// window on an axis is centred on it, hence the offset.
|
||||
let fraction = |value: f64, anchor: f64, drawn: f64, view: f64| {
|
||||
let offset = ((view - drawn) / 2.0).max(0.0);
|
||||
((value + anchor - offset) / drawn).clamp(0.0, 1.0)
|
||||
};
|
||||
let fx = fraction(h.value(), anchor_x, width * current, view_w);
|
||||
let fy = fraction(v.value(), anchor_y, height * current, view_h);
|
||||
|
||||
let (drawn_w, drawn_h) = (width * target, height * target);
|
||||
viewer.zoom.set(Some(target));
|
||||
viewer
|
||||
.scroller
|
||||
.set_policy(gtk4::PolicyType::External, gtk4::PolicyType::External);
|
||||
viewer
|
||||
.picture
|
||||
.set_size_request(drawn_w.round() as i32, drawn_h.round() as i32);
|
||||
viewer.picture.set_cursor_from_name(Some("grab"));
|
||||
|
||||
// The scrolled window only learns the new size at the next layout, so the
|
||||
// adjustments are sized here to let the offset land now.
|
||||
let place = |adj: >k4::Adjustment, fraction: f64, anchor: f64, drawn: f64, view: f64| {
|
||||
let offset = ((view - drawn) / 2.0).max(0.0);
|
||||
let upper = drawn.max(view);
|
||||
let value = (fraction * drawn + offset - anchor).clamp(0.0, upper - view);
|
||||
adj.configure(value, 0.0, upper, view * 0.1, view * 0.9, view);
|
||||
};
|
||||
place(&h, fx, anchor_x, drawn_w, view_w);
|
||||
place(&v, fy, anchor_y, drawn_h, view_h);
|
||||
}
|
||||
|
||||
/// Zoom in or out by `factor` around the centre.
|
||||
fn zoom_by(viewer: &Rc<Viewer>, factor: f64) {
|
||||
if let Some(scale) = current_scale(viewer) {
|
||||
zoom_to(viewer, Some(scale * factor), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the top bar and the prev/next buttons back, and fade them out again
|
||||
/// once the pointer has rested for [`CHROME_TIMEOUT`].
|
||||
fn show_chrome(viewer: &Rc<Viewer>) {
|
||||
for revealer in &viewer.chrome {
|
||||
revealer.set_reveal_child(true);
|
||||
revealer.set_can_target(true);
|
||||
}
|
||||
viewer.media.set_cursor(None);
|
||||
if let Some(source) = viewer.chrome_source.borrow_mut().take() {
|
||||
source.remove();
|
||||
}
|
||||
let weak = Rc::downgrade(viewer);
|
||||
let source = glib::timeout_add_local_once(CHROME_TIMEOUT, move || {
|
||||
let Some(viewer) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
// This timer has fired, so its id must not be removed again.
|
||||
viewer.chrome_source.borrow_mut().take();
|
||||
if !viewer.chrome_hover.get() {
|
||||
hide_chrome(&viewer);
|
||||
}
|
||||
});
|
||||
*viewer.chrome_source.borrow_mut() = Some(source);
|
||||
}
|
||||
|
||||
/// Fade the controls out. A hidden revealer still covers its corner of the
|
||||
/// photo, so it stops taking clicks too.
|
||||
fn hide_chrome(viewer: &Rc<Viewer>) {
|
||||
for revealer in &viewer.chrome {
|
||||
revealer.set_reveal_child(false);
|
||||
revealer.set_can_target(false);
|
||||
}
|
||||
viewer.media.set_cursor_from_name(Some("none"));
|
||||
}
|
||||
|
||||
/// Copy the photo on screen to where the user picks, under its Drive name.
|
||||
pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: >k4::Window, source: &str, name: &str) {
|
||||
let dialog = gtk4::FileDialog::builder()
|
||||
.title("Save Photo")
|
||||
.initial_name(original_name)
|
||||
.title("Save a Copy")
|
||||
.initial_name(name)
|
||||
.build();
|
||||
let source_path_str = source_path.to_string();
|
||||
let source = source.to_string();
|
||||
let ui = ui.clone();
|
||||
dialog.save(Some(window), gio::Cancellable::NONE, move |res| {
|
||||
if let Ok(file) = res
|
||||
&& let Some(dest_path) = file.path()
|
||||
{
|
||||
if let Err(e) = std::fs::copy(&source_path_str, &dest_path) {
|
||||
tracing::error!("Failed to copy file to {:?}: {}", dest_path, e);
|
||||
} else {
|
||||
tracing::info!("Saved photo to {:?}", dest_path);
|
||||
// An error here is the dialog being dismissed.
|
||||
let Some(dest) = res.ok().and_then(|file| file.path()) else {
|
||||
return;
|
||||
};
|
||||
// A video can run to gigabytes; copy it off the GTK thread.
|
||||
glib::spawn_future_local(async move {
|
||||
let copied =
|
||||
gio::spawn_blocking(move || std::fs::copy(&source, &dest).map(|_| dest)).await;
|
||||
match copied {
|
||||
Ok(Ok(dest)) => {
|
||||
let saved = dest.file_name().map_or_else(
|
||||
|| dest.display().to_string(),
|
||||
|name| name.to_string_lossy().into_owned(),
|
||||
);
|
||||
toast(&ui, &format!("Saved “{saved}”"));
|
||||
}
|
||||
Ok(Err(e)) => toast_error(&ui, "Couldn't save the copy", &e.to_string()),
|
||||
Err(_) => toast_error(&ui, "Couldn't save the copy", "The copy was interrupted."),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A symlink to the cached file `path` under its Drive `name`, in a directory
|
||||
/// of its own below `base`. The cache names files by content hash, with no
|
||||
/// extension; the app chooser and the app it launches both need the real name
|
||||
/// to know what they are looking at.
|
||||
pub(crate) fn named_link(base: &Path, path: &str, name: &str) -> Option<PathBuf> {
|
||||
let name = Path::new(name).file_name()?;
|
||||
let dir = base.join(Path::new(path).file_name()?);
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let link = dir.join(name);
|
||||
let _ = std::fs::remove_file(&link);
|
||||
std::os::unix::fs::symlink(path, &link).ok()?;
|
||||
Some(link)
|
||||
}
|
||||
|
||||
/// Offer every app that can open the photo on screen, rather than launching
|
||||
/// the default one.
|
||||
fn open_with(window: >k4::Window, path: &str, name: Option<&str>) {
|
||||
let base = glib::user_runtime_dir().join("pdfs-open");
|
||||
let target = name
|
||||
.and_then(|name| named_link(&base, path, name))
|
||||
.unwrap_or_else(|| PathBuf::from(path));
|
||||
let launcher = gtk4::FileLauncher::new(Some(&gio::File::for_path(target)));
|
||||
launcher.set_always_ask(true);
|
||||
launcher.launch(Some(window), gio::Cancellable::NONE, |res| {
|
||||
if let Err(e) = res {
|
||||
tracing::warn!("open with failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Step `delta` photos through the flat timeline model and load what lands there.
|
||||
/// Step `delta` photos through the flat timeline model and load what lands
|
||||
/// there. Stepping past the last loaded photo pages the next batch in first.
|
||||
pub(crate) fn navigate_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, delta: i32) {
|
||||
let model = &ui.gallery.model;
|
||||
let n = model.n_items();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let uid_val = viewer.uid.borrow().clone();
|
||||
let current_idx = find_photo_index(model, &uid_val).unwrap_or(0);
|
||||
|
||||
// Where the raw step lands (prev/next are ±1; Home/End are huge deltas that
|
||||
// clamp to the ends), and which way it was travelling.
|
||||
let start = (current_idx as i32 + delta).clamp(0, n as i32 - 1);
|
||||
let dir = if delta < 0 { -1 } else { 1 };
|
||||
|
||||
// The lightbox renders stills only — a video plays in an external player, so
|
||||
// it has no frame here. Land on the nearest renderable item, preferring the
|
||||
// travel direction and falling back to scanning inward from a boundary (so
|
||||
// Home/End still reach the first/last still even when videos sit at the end).
|
||||
let renderable = |idx: u32| -> Option<PhotoItem> {
|
||||
model
|
||||
.item(idx)
|
||||
.and_then(|obj| {
|
||||
obj.downcast_ref::<BoxedAnyObject>()
|
||||
.map(|b| b.borrow::<PhotoItem>().clone())
|
||||
})
|
||||
.filter(|photo| photo.kind != PhotoKind::Video)
|
||||
};
|
||||
let scan = |from: i32, step: i32| -> Option<(u32, PhotoItem)> {
|
||||
let mut probe = from;
|
||||
while (0..n as i32).contains(&probe) {
|
||||
if let Some(photo) = renderable(probe as u32) {
|
||||
return Some((probe as u32, photo));
|
||||
}
|
||||
probe += step;
|
||||
let current = find_photo_index(model, &viewer.uid.borrow()).unwrap_or(0);
|
||||
if delta == 1 && current + 1 >= n {
|
||||
if ui.gallery.has_more.get() {
|
||||
wait_for_page(ui, viewer, n);
|
||||
}
|
||||
None
|
||||
};
|
||||
// Scan the travel direction first. Only fall back to scanning inward when the
|
||||
// step landed on a boundary — that is the Home/End case (jump to first/last,
|
||||
// then step inward past any videos there); a mid-list prev/next with only
|
||||
// videos ahead should simply stay put rather than reverse.
|
||||
let at_boundary = start == 0 || start == n as i32 - 1;
|
||||
let landing = scan(start, dir).or_else(|| at_boundary.then(|| scan(start, -dir)).flatten());
|
||||
let Some((next_idx, photo)) = landing else {
|
||||
return;
|
||||
}
|
||||
// Prev/next are ±1; Home/End are huge deltas that clamp to the ends.
|
||||
let index = (i64::from(current) + i64::from(delta)).clamp(0, i64::from(n) - 1) as u32;
|
||||
if index == current {
|
||||
return;
|
||||
}
|
||||
let Some(photo) = model
|
||||
.item(index)
|
||||
.and_downcast::<BoxedAnyObject>()
|
||||
.map(|boxed| boxed.borrow::<PhotoItem>().clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
*viewer.uid.borrow_mut() = photo.uid.clone();
|
||||
show_photo_position(viewer, next_idx, n, photo.capture_time);
|
||||
load_photo(ui, viewer, photo.uid.clone());
|
||||
show_photo_position(ui, viewer, index, photo.capture_time);
|
||||
load_photo(ui, viewer, photo.uid);
|
||||
// Keep walking in the same direction: the next one is likely where they're
|
||||
// headed, so have it in the cache before they ask.
|
||||
prefetch_photo(ui, viewer, delta.signum());
|
||||
if delta > 0 && index + PAGE_AHEAD >= n && ui.gallery.has_more.get() {
|
||||
load_gallery(ui, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the next timeline page and step onto it once it lands — unless the
|
||||
/// lightbox closed or moved on in the meantime.
|
||||
fn wait_for_page(ui: &Rc<Ui>, viewer: &Rc<Viewer>, loaded: u32) {
|
||||
viewer.next.set_sensitive(false);
|
||||
let (ui_page, viewer_page) = (ui.clone(), viewer.clone());
|
||||
ui.gallery.page_waiters.borrow_mut().push(Box::new(move || {
|
||||
if !viewer_page.window.is_visible() {
|
||||
return;
|
||||
}
|
||||
let index = find_photo_index(&ui_page.gallery.model, &viewer_page.uid.borrow());
|
||||
if index == Some(loaded - 1) && ui_page.gallery.model.n_items() > loaded {
|
||||
navigate_photo(&ui_page, &viewer_page, 1);
|
||||
} else if let Some(index) = index {
|
||||
let capture_time = viewer_item(&ui_page, &viewer_page, &viewer_page.uid.borrow())
|
||||
.map_or(0, |item| item.capture_time);
|
||||
show_photo_position(&ui_page, &viewer_page, index, capture_time);
|
||||
}
|
||||
}));
|
||||
load_gallery(ui, true);
|
||||
}
|
||||
|
||||
/// How many photos the lightbox is stepping through: an album's own count, or
|
||||
/// the library's for the active kind. `None` when a date or favorites filter
|
||||
/// narrows the timeline to a count the daemon does not report.
|
||||
fn gallery_total(ui: &Rc<Ui>) -> Option<usize> {
|
||||
if let Some(album) = ui.gallery.album.borrow().as_ref() {
|
||||
return Some(album.photo_count);
|
||||
}
|
||||
if ui.gallery.range.get().is_some() || ui.gallery.favorites.get() {
|
||||
return None;
|
||||
}
|
||||
let (photos, videos, raw) = ui.gallery.counts.get()?;
|
||||
Some(match ui.gallery.kind.get() {
|
||||
Some(PhotoKind::Photo) => photos,
|
||||
Some(PhotoKind::Video) => videos,
|
||||
Some(PhotoKind::Raw) => raw,
|
||||
None => photos + videos + raw,
|
||||
})
|
||||
}
|
||||
|
||||
/// "12 of 340" for the photo at `index`. While more pages are left the total
|
||||
/// is the library's (`total`), or the loaded count with a "+" when that is
|
||||
/// unknown; once everything is loaded the loaded count is exact.
|
||||
pub(crate) fn position_label(index: u32, loaded: u32, more: bool, total: Option<usize>) -> String {
|
||||
let (at, loaded) = (index as usize + 1, loaded as usize);
|
||||
match total {
|
||||
_ if !more => format!("{} of {}", thousands(at), thousands(loaded)),
|
||||
Some(total) if total > loaded => format!("{} of {}", thousands(at), thousands(total)),
|
||||
_ => format!("{} of {}+", thousands(at), thousands(loaded)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the lightbox's title (the photo's date) and its "12 of 340" counter.
|
||||
pub(crate) fn show_photo_position(viewer: &Rc<Viewer>, index: u32, total: u32, capture_time: i64) {
|
||||
fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, index: u32, capture_time: i64) {
|
||||
let loaded = ui.gallery.model.n_items();
|
||||
let more = ui.gallery.has_more.get();
|
||||
viewer.prev.set_sensitive(index > 0);
|
||||
viewer.next.set_sensitive(index + 1 < total);
|
||||
viewer.next.set_sensitive(index + 1 < loaded || more);
|
||||
viewer.title.set_label(&format_capture_time(capture_time));
|
||||
viewer
|
||||
.counter
|
||||
.set_label(&format!("{} of {total}", index + 1));
|
||||
.set_label(&position_label(index, loaded, more, gallery_total(ui)));
|
||||
}
|
||||
|
||||
/// The in-app lightbox: the photo, edge-to-edge on a dark backdrop, with a
|
||||
/// floating top bar, prev/next affordances and a details drawer.
|
||||
/// The in-app lightbox: the photo or video, edge-to-edge on a dark backdrop,
|
||||
/// with a top bar and prev/next buttons that fade out while the pointer rests,
|
||||
/// and a details panel docked beside the image.
|
||||
///
|
||||
/// Closing it is deliberately hard to get wrong — Escape, `q`, Ctrl+W, the close
|
||||
/// button, or a click on the backdrop beside the photo all dismiss it.
|
||||
@@ -488,35 +786,65 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
window.add_css_class("photo-viewer-window");
|
||||
|
||||
let overlay = gtk4::Overlay::new();
|
||||
overlay.set_hexpand(true);
|
||||
|
||||
let picture = gtk4::Picture::builder()
|
||||
.content_fit(gtk4::ContentFit::Contain)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
overlay.set_child(Some(&picture));
|
||||
// Fitted, the photo is exactly the window; zoomed, it outgrows it and the
|
||||
// scrolled window pans over it (with its scrollbars kept out of sight).
|
||||
let scroller = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.vscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.child(&picture)
|
||||
.build();
|
||||
let video = gtk4::Video::builder()
|
||||
.autoplay(true)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
let media = gtk4::Stack::new();
|
||||
media.add_named(&scroller, Some("photo"));
|
||||
media.add_named(&video, Some("video"));
|
||||
overlay.set_child(Some(&media));
|
||||
|
||||
let fade = |child: >k4::Widget, halign: gtk4::Align, valign: gtk4::Align| {
|
||||
gtk4::Revealer::builder()
|
||||
.transition_type(gtk4::RevealerTransitionType::Crossfade)
|
||||
.reveal_child(true)
|
||||
.halign(halign)
|
||||
.valign(valign)
|
||||
.child(child)
|
||||
.build()
|
||||
};
|
||||
|
||||
let prev_btn = gtk4::Button::builder()
|
||||
.icon_name("go-previous-symbolic")
|
||||
.tooltip_text("Previous (←)")
|
||||
.halign(gtk4::Align::Start)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
prev_btn.add_css_class("circular");
|
||||
prev_btn.add_css_class("flat");
|
||||
prev_btn.add_css_class("viewer-nav-btn");
|
||||
overlay.add_overlay(&prev_btn);
|
||||
let prev_reveal = fade(
|
||||
prev_btn.upcast_ref(),
|
||||
gtk4::Align::Start,
|
||||
gtk4::Align::Center,
|
||||
);
|
||||
overlay.add_overlay(&prev_reveal);
|
||||
|
||||
let next_btn = gtk4::Button::builder()
|
||||
.icon_name("go-next-symbolic")
|
||||
.tooltip_text("Next (→)")
|
||||
.halign(gtk4::Align::End)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
next_btn.add_css_class("circular");
|
||||
next_btn.add_css_class("flat");
|
||||
next_btn.add_css_class("viewer-nav-btn");
|
||||
overlay.add_overlay(&next_btn);
|
||||
let next_reveal = fade(next_btn.upcast_ref(), gtk4::Align::End, gtk4::Align::Center);
|
||||
overlay.add_overlay(&next_reveal);
|
||||
|
||||
// Top bar: the photo's date and position on the left, actions on the right,
|
||||
// over a gradient so white controls stay legible on a bright photo.
|
||||
@@ -556,7 +884,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
|
||||
let favorite_btn = gtk4::ToggleButton::builder()
|
||||
.icon_name("non-starred-symbolic")
|
||||
.tooltip_text("Favourite")
|
||||
.tooltip_text("Favorite")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
favorite_btn.add_css_class("flat");
|
||||
@@ -567,16 +895,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
let group_btn = action("view-paged-symbolic", "Other files of this photo");
|
||||
group_btn.set_visible(false);
|
||||
|
||||
let download_btn = action("document-save-symbolic", "Save a copy…");
|
||||
let download_btn = action("document-save-symbolic", "Save a Copy…");
|
||||
let delete_btn = action("user-trash-symbolic", "Move to Trash (Delete)");
|
||||
let open_ext_btn = action("document-open-symbolic", "Open with another app");
|
||||
let open_ext_btn = action("document-open-symbolic", "Open With…");
|
||||
let close_btn = action("window-close-symbolic", "Close (Esc)");
|
||||
close_btn.add_css_class("viewer-close-btn");
|
||||
|
||||
let top_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
||||
top_bar.add_css_class("viewer-top-bar");
|
||||
top_bar.set_valign(gtk4::Align::Start);
|
||||
top_bar.set_hexpand(true);
|
||||
top_bar.append(&titles);
|
||||
top_bar.append(&group_btn);
|
||||
top_bar.append(&favorite_btn);
|
||||
@@ -585,15 +911,20 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
top_bar.append(&delete_btn);
|
||||
top_bar.append(&open_ext_btn);
|
||||
top_bar.append(&close_btn);
|
||||
overlay.add_overlay(&top_bar);
|
||||
let top_reveal = fade(top_bar.upcast_ref(), gtk4::Align::Fill, gtk4::Align::Start);
|
||||
overlay.add_overlay(&top_reveal);
|
||||
|
||||
// Details drawer: slides in from the right as a real surface (not a wash of
|
||||
// black over the photo), with its own header and its own way out.
|
||||
// Details panel: docked beside the image as a real surface, so opening it
|
||||
// narrows the photo instead of covering part of it.
|
||||
let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
|
||||
let info_map = gtk4::Button::builder()
|
||||
.label("Show on map")
|
||||
.icon_name("map-symbolic")
|
||||
.child(
|
||||
&adw::ButtonContent::builder()
|
||||
.label("Show on map")
|
||||
.icon_name("map-symbolic")
|
||||
.build(),
|
||||
)
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
info_map.add_css_class("pill");
|
||||
@@ -630,7 +961,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
// outgrow a short window.
|
||||
let info_scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.propagate_natural_height(true)
|
||||
.vexpand(true)
|
||||
.child(&info_body)
|
||||
.build();
|
||||
|
||||
@@ -641,11 +972,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
|
||||
let info_revealer = gtk4::Revealer::builder()
|
||||
.transition_type(gtk4::RevealerTransitionType::SlideLeft)
|
||||
.halign(gtk4::Align::End)
|
||||
.valign(gtk4::Align::Fill)
|
||||
.child(&info_panel)
|
||||
.build();
|
||||
overlay.add_overlay(&info_revealer);
|
||||
|
||||
let spinner = gtk4::Spinner::builder()
|
||||
.halign(gtk4::Align::Center)
|
||||
@@ -665,10 +993,17 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
status_label.add_css_class("viewer-status");
|
||||
overlay.add_overlay(&status_label);
|
||||
|
||||
window.set_child(Some(&overlay));
|
||||
let body = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
body.append(&overlay);
|
||||
body.append(&info_revealer);
|
||||
window.set_child(Some(&body));
|
||||
|
||||
let viewer = Rc::new(Viewer {
|
||||
window: window.clone(),
|
||||
media: media.clone(),
|
||||
scroller: scroller.clone(),
|
||||
picture: picture.clone(),
|
||||
video,
|
||||
spinner,
|
||||
status: status_label,
|
||||
title: title_label,
|
||||
@@ -685,28 +1020,135 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
group: RefCell::new(Vec::new()),
|
||||
coords: RefCell::new(None),
|
||||
uid: RefCell::new(initial_uid.clone()),
|
||||
name: RefCell::new(None),
|
||||
path: RefCell::new(None),
|
||||
loading: Cell::new(false),
|
||||
zoom: Cell::new(None),
|
||||
pinch_start: Cell::new(1.0),
|
||||
drag_start: Cell::new((0.0, 0.0)),
|
||||
pointer: Cell::new((0.0, 0.0)),
|
||||
chrome: vec![top_reveal.clone(), prev_reveal.clone(), next_reveal.clone()],
|
||||
chrome_hover: Cell::new(false),
|
||||
chrome_source: RefCell::new(None),
|
||||
});
|
||||
|
||||
let n = ui.gallery.model.n_items();
|
||||
let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
|
||||
let capture_time = ui
|
||||
.gallery
|
||||
.model
|
||||
.item(initial_idx)
|
||||
.and_downcast::<BoxedAnyObject>()
|
||||
.map_or(0, |boxed| boxed.borrow::<PhotoItem>().capture_time);
|
||||
show_photo_position(&viewer, initial_idx, n, capture_time);
|
||||
let capture_time = viewer_item(ui, &viewer, &initial_uid).map_or(0, |item| item.capture_time);
|
||||
show_photo_position(ui, &viewer, initial_idx, capture_time);
|
||||
|
||||
load_photo(ui, &viewer, initial_uid);
|
||||
prefetch_photo(ui, &viewer, 1);
|
||||
show_chrome(&viewer);
|
||||
|
||||
// Any real pointer movement brings the controls back; resting over one of
|
||||
// them keeps them up.
|
||||
let motion = gtk4::EventControllerMotion::new();
|
||||
let viewer_motion = viewer.clone();
|
||||
motion.connect_motion(move |_, x, y| {
|
||||
let (last_x, last_y) = viewer_motion.pointer.get();
|
||||
viewer_motion.pointer.set((x, y));
|
||||
if (x - last_x).abs() > 1.0 || (y - last_y).abs() > 1.0 {
|
||||
show_chrome(&viewer_motion);
|
||||
}
|
||||
});
|
||||
body.add_controller(motion);
|
||||
for revealer in [&top_reveal, &prev_reveal, &next_reveal] {
|
||||
let hover = gtk4::EventControllerMotion::new();
|
||||
let viewer_enter = viewer.clone();
|
||||
hover.connect_enter(move |_, _, _| viewer_enter.chrome_hover.set(true));
|
||||
let viewer_leave = viewer.clone();
|
||||
hover.connect_leave(move |_| {
|
||||
viewer_leave.chrome_hover.set(false);
|
||||
show_chrome(&viewer_leave);
|
||||
});
|
||||
revealer.add_controller(hover);
|
||||
}
|
||||
|
||||
// Ctrl+scroll zooms around the pointer; a plain scroll pans a zoomed photo.
|
||||
let wheel = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL);
|
||||
wheel.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
||||
let viewer_wheel = viewer.clone();
|
||||
wheel.connect_scroll(move |controller, _, dy| {
|
||||
if !controller
|
||||
.current_event_state()
|
||||
.contains(gtk4::gdk::ModifierType::CONTROL_MASK)
|
||||
{
|
||||
return glib::Propagation::Proceed;
|
||||
}
|
||||
if let Some(scale) = current_scale(&viewer_wheel) {
|
||||
let anchor = viewer_wheel.pointer.get();
|
||||
zoom_to(
|
||||
&viewer_wheel,
|
||||
Some(scale * ZOOM_STEP.powf(-dy)),
|
||||
Some(anchor),
|
||||
);
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
});
|
||||
scroller.add_controller(wheel);
|
||||
|
||||
let pinch = gtk4::GestureZoom::new();
|
||||
let viewer_pinch = viewer.clone();
|
||||
pinch.connect_begin(move |_, _| {
|
||||
viewer_pinch
|
||||
.pinch_start
|
||||
.set(current_scale(&viewer_pinch).unwrap_or(1.0));
|
||||
});
|
||||
let viewer_pinch = viewer.clone();
|
||||
pinch.connect_scale_changed(move |gesture, delta| {
|
||||
let start = viewer_pinch.pinch_start.get();
|
||||
zoom_to(
|
||||
&viewer_pinch,
|
||||
Some(start * delta),
|
||||
gesture.bounding_box_center(),
|
||||
);
|
||||
});
|
||||
scroller.add_controller(pinch);
|
||||
|
||||
// Dragging a zoomed photo pans it.
|
||||
let drag = gtk4::GestureDrag::new();
|
||||
let viewer_drag = viewer.clone();
|
||||
drag.connect_drag_begin(move |_, _, _| {
|
||||
let scroller = &viewer_drag.scroller;
|
||||
viewer_drag.drag_start.set((
|
||||
scroller.hadjustment().value(),
|
||||
scroller.vadjustment().value(),
|
||||
));
|
||||
if viewer_drag.zoom.get().is_some() {
|
||||
viewer_drag.picture.set_cursor_from_name(Some("grabbing"));
|
||||
}
|
||||
});
|
||||
let viewer_drag = viewer.clone();
|
||||
drag.connect_drag_update(move |_, dx, dy| {
|
||||
if viewer_drag.zoom.get().is_none() {
|
||||
return;
|
||||
}
|
||||
let (x, y) = viewer_drag.drag_start.get();
|
||||
viewer_drag.scroller.hadjustment().set_value(x - dx);
|
||||
viewer_drag.scroller.vadjustment().set_value(y - dy);
|
||||
});
|
||||
let viewer_drag = viewer.clone();
|
||||
drag.connect_drag_end(move |_, _, _| {
|
||||
if viewer_drag.zoom.get().is_some() {
|
||||
viewer_drag.picture.set_cursor_from_name(Some("grab"));
|
||||
}
|
||||
});
|
||||
scroller.add_controller(drag);
|
||||
|
||||
let w_close = window.clone();
|
||||
close_btn.connect_clicked(move |_| {
|
||||
w_close.close();
|
||||
});
|
||||
|
||||
let viewer_closing = viewer.clone();
|
||||
window.connect_close_request(move |_| {
|
||||
stop_video(&viewer_closing);
|
||||
if let Some(source) = viewer_closing.chrome_source.borrow_mut().take() {
|
||||
source.remove();
|
||||
}
|
||||
glib::Propagation::Proceed
|
||||
});
|
||||
|
||||
let viewer_info = viewer.clone();
|
||||
info_toggle.connect_toggled(move |toggle| {
|
||||
viewer_info
|
||||
@@ -754,7 +1196,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
_ => Some("The mount service didn't respond.".to_string()),
|
||||
};
|
||||
if let Some(detail) = failed {
|
||||
toast_error(&ui_result, "Couldn't change the favourite", &detail);
|
||||
toast_error(&ui_result, "Couldn't change the favorite", &detail);
|
||||
// The server refused, so the button must go back to describing
|
||||
// what is actually stored — without firing this handler again.
|
||||
if *viewer_result.uid.borrow() == uid {
|
||||
@@ -788,19 +1230,26 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
}
|
||||
});
|
||||
|
||||
let ui_download = ui.clone();
|
||||
let w_download = window.clone();
|
||||
let viewer_download = viewer.clone();
|
||||
download_btn.connect_clicked(move |_| {
|
||||
if let Some(path) = viewer_download.path.borrow().as_deref() {
|
||||
let name = format!("{}.jpg", viewer_download.uid.borrow());
|
||||
save_photo_to_disk(&w_download, path, &name);
|
||||
let name = viewer_download.name.borrow();
|
||||
save_photo_to_disk(
|
||||
&ui_download,
|
||||
&w_download,
|
||||
path,
|
||||
name.as_deref().unwrap_or("photo"),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let w_ext = window.clone();
|
||||
let viewer_ext = viewer.clone();
|
||||
open_ext_btn.connect_clicked(move |_| {
|
||||
if let Some(path) = viewer_ext.path.borrow().as_deref() {
|
||||
open_path(path);
|
||||
open_with(&w_ext, path, viewer_ext.name.borrow().as_deref());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -818,12 +1267,34 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
|
||||
// Click the backdrop — the dark area beside the photo — to dismiss, the way
|
||||
// every other lightbox behaves. Clicks on the photo itself are left alone, so
|
||||
// reaching for the image doesn't fling the window shut.
|
||||
// reaching for the image doesn't fling the window shut; a zoomed photo has
|
||||
// no backdrop to speak of. Double-click toggles between fitted and 100 %.
|
||||
let backdrop = gtk4::GestureClick::new();
|
||||
let viewer_click = viewer.clone();
|
||||
backdrop.connect_pressed(move |_, n_press, x, y| {
|
||||
if n_press != 2 {
|
||||
return;
|
||||
}
|
||||
if viewer_click.zoom.get().is_some() {
|
||||
reset_zoom(&viewer_click);
|
||||
} else if let Some(fit) = current_scale(&viewer_click) {
|
||||
let scroller = &viewer_click.scroller;
|
||||
let anchor = (
|
||||
x - scroller.hadjustment().value(),
|
||||
y - scroller.vadjustment().value(),
|
||||
);
|
||||
// A photo smaller than the window is already past 100 % fitted, so
|
||||
// double-clicking it doubles it instead.
|
||||
zoom_to(&viewer_click, Some((fit * 2.0).max(1.0)), Some(anchor));
|
||||
}
|
||||
});
|
||||
let viewer_click = viewer.clone();
|
||||
let w_click = window.clone();
|
||||
backdrop.connect_released(move |_, _, x, y| {
|
||||
if !over_photo(&viewer_click.picture, x, y) {
|
||||
backdrop.connect_released(move |_, n_press, x, y| {
|
||||
if n_press == 1
|
||||
&& viewer_click.zoom.get().is_none()
|
||||
&& !over_photo(&viewer_click.picture, x, y)
|
||||
{
|
||||
w_click.close();
|
||||
}
|
||||
});
|
||||
@@ -835,11 +1306,21 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
let w_key = window.clone();
|
||||
key_controller.connect_key_pressed(move |_, key, _keycode, state| {
|
||||
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||
let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video");
|
||||
match key.name().as_deref() {
|
||||
Some("space") if playing_video => {
|
||||
if let Some(stream) = viewer_key.video.media_stream() {
|
||||
stream.set_playing(!stream.is_playing());
|
||||
}
|
||||
}
|
||||
Some("Left" | "Up" | "BackSpace") => navigate_photo(&ui_key, &viewer_key, -1),
|
||||
Some("Right" | "Down" | "space") => navigate_photo(&ui_key, &viewer_key, 1),
|
||||
Some("Home") => navigate_photo(&ui_key, &viewer_key, i32::MIN / 2),
|
||||
Some("End") => navigate_photo(&ui_key, &viewer_key, i32::MAX / 2),
|
||||
Some("plus" | "equal" | "KP_Add") => zoom_by(&viewer_key, ZOOM_STEP),
|
||||
Some("minus" | "KP_Subtract") => zoom_by(&viewer_key, 1.0 / ZOOM_STEP),
|
||||
Some("0" | "KP_0") => reset_zoom(&viewer_key),
|
||||
Some("1" | "KP_1") => zoom_to(&viewer_key, Some(1.0), None),
|
||||
Some("i") => viewer_key
|
||||
.info_toggle
|
||||
.set_active(!viewer_key.info_toggle.is_active()),
|
||||
@@ -888,3 +1369,31 @@ pub(crate) fn over_photo(picture: >k4::Picture, x: f64, y: f64) -> bool {
|
||||
let (left, top) = ((width - drawn_w) / 2.0, (height - drawn_h) / 2.0);
|
||||
x >= left && x <= left + drawn_w && y >= top && y <= top + drawn_h
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{named_link, position_label};
|
||||
|
||||
#[test]
|
||||
fn the_counter_counts_the_library_while_pages_are_left() {
|
||||
assert_eq!(position_label(11, 200, true, Some(1422)), "12 of 1,422");
|
||||
assert_eq!(position_label(11, 200, true, None), "12 of 200+");
|
||||
assert_eq!(position_label(0, 37, false, Some(1422)), "1 of 37");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_with_sees_the_drive_name() {
|
||||
let base = std::env::temp_dir().join(format!("pdfs-named-link-{}", std::process::id()));
|
||||
let cached = base.join("0a1b2c");
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
std::fs::write(&cached, b"jpeg").unwrap();
|
||||
|
||||
let links = base.join("links");
|
||||
let link = named_link(&links, cached.to_str().unwrap(), "IMG_0001.JPG").unwrap();
|
||||
assert_eq!(link.file_name().unwrap(), "IMG_0001.JPG");
|
||||
assert_eq!(std::fs::read(&link).unwrap(), b"jpeg");
|
||||
// Opening the same photo again replaces the link rather than failing.
|
||||
assert!(named_link(&links, cached.to_str().unwrap(), "IMG_0001.JPG").is_some());
|
||||
std::fs::remove_dir_all(&base).unwrap();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,6 +10,11 @@ pub(crate) struct SharedState {
|
||||
pub(crate) with_me_group: adw::PreferencesGroup,
|
||||
pub(crate) invitations_group: adw::PreferencesGroup,
|
||||
pub(crate) bookmarks_group: adw::PreferencesGroup,
|
||||
/// The header title; its subtitle carries the path inside a shared folder.
|
||||
pub(crate) title: adw::WindowTitle,
|
||||
/// Header button that leaves the current shared folder.
|
||||
pub(crate) back: gtk4::Button,
|
||||
pub(crate) add_bookmark: gtk4::Button,
|
||||
/// Where in a shared folder the page currently is, as `(uid, name)` from the
|
||||
/// top level down. Empty = the top level. Shared subtrees have no path in the
|
||||
/// mount, so descending is uid-addressed and the stack *is* the breadcrumb.
|
||||
@@ -32,6 +37,8 @@ pub(crate) struct SharedWidgets {
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) add_bookmark: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
pub(crate) title: adw::WindowTitle,
|
||||
pub(crate) back: gtk4::Button,
|
||||
}
|
||||
|
||||
/// The Shared page: three stacked sections — items shared *with* me (each with a
|
||||
@@ -39,38 +46,30 @@ pub(crate) struct SharedWidgets {
|
||||
/// links I've bookmarked (open / remove). All three live outside the mount tree,
|
||||
/// so the page addresses them by uid/id/token and always re-lists from the daemon.
|
||||
pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Shared with me")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
titles.set_hexpand(true);
|
||||
titles.append(&title);
|
||||
|
||||
let add_bookmark = gtk4::Button::builder()
|
||||
.label("Add Bookmark")
|
||||
.valign(gtk4::Align::Center)
|
||||
.icon_name("bookmark-new-symbolic")
|
||||
.tooltip_text("Add Bookmark — save a public link to open it from here")
|
||||
.build();
|
||||
add_bookmark.add_css_class("flat");
|
||||
let refresh = refresh_button();
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header.append(&titles);
|
||||
header.append(&refresh);
|
||||
header.append(&add_bookmark);
|
||||
|
||||
let shared_with_me = adw::PreferencesGroup::builder()
|
||||
.title("Shared with me")
|
||||
.build();
|
||||
// Only sections with something in them are shown, so an account with one
|
||||
// share reads as one list rather than three boxes, two of them saying
|
||||
// "nothing here". Invitations come first: they are the one thing on the
|
||||
// page waiting for a decision.
|
||||
let invitations = adw::PreferencesGroup::builder()
|
||||
.title("Invitations")
|
||||
.description("Accept to add the item to your shared files.")
|
||||
.visible(false)
|
||||
.build();
|
||||
let shared_with_me = adw::PreferencesGroup::new();
|
||||
let bookmarks = adw::PreferencesGroup::builder()
|
||||
.title("Bookmarks")
|
||||
.visible(false)
|
||||
.build();
|
||||
let bookmarks = adw::PreferencesGroup::builder().title("Bookmarks").build();
|
||||
|
||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
groups.append(&shared_with_me);
|
||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
|
||||
groups.append(&invitations);
|
||||
groups.append(&shared_with_me);
|
||||
groups.append(&bookmarks);
|
||||
let clamp = adw::Clamp::builder().child(&groups).build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
@@ -99,15 +98,24 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
|
||||
content.add_named(&status, Some("status"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(12);
|
||||
inner.set_margin_bottom(12);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&header);
|
||||
inner.set_margin_top(18);
|
||||
inner.set_margin_bottom(18);
|
||||
inner.set_margin_start(18);
|
||||
inner.set_margin_end(18);
|
||||
inner.append(&content);
|
||||
|
||||
let back = gtk4::Button::builder()
|
||||
.icon_name("go-previous-symbolic")
|
||||
.tooltip_text("Back")
|
||||
.visible(false)
|
||||
.build();
|
||||
let (frame, header, title) = page_frame("Shared with Me", &inner);
|
||||
header.pack_start(&back);
|
||||
header.pack_end(&refresh);
|
||||
header.pack_end(&add_bookmark);
|
||||
|
||||
(
|
||||
inner.upcast(),
|
||||
frame.upcast(),
|
||||
SharedWidgets {
|
||||
content,
|
||||
status,
|
||||
@@ -117,12 +125,19 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
|
||||
retry,
|
||||
add_bookmark,
|
||||
refresh,
|
||||
title,
|
||||
back,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Install the Shared page's retry and Add-Bookmark buttons.
|
||||
/// Install the Shared page's retry, back and Add-Bookmark buttons.
|
||||
pub(crate) fn wire_shared(ui: &Rc<Ui>, retry: >k4::Button, add_bookmark: >k4::Button) {
|
||||
let ui_back = ui.clone();
|
||||
ui.shared.back.connect_clicked(move |_| {
|
||||
ui_back.shared.nav.borrow_mut().pop();
|
||||
load_shared(&ui_back);
|
||||
});
|
||||
let ui_retry = ui.clone();
|
||||
retry.connect_clicked(move |_| {
|
||||
service::restart();
|
||||
@@ -245,8 +260,26 @@ fn load_shared_folder(ui: &Rc<Ui>, uid: String) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Paint the contents of the shared folder at the top of the nav stack: a row to
|
||||
/// go back up, then the children. Invitations and bookmarks are hidden here —
|
||||
/// The header subtitle inside a shared folder: the folders from the share root
|
||||
/// down, the way a breadcrumb reads.
|
||||
pub(crate) fn shared_path(nav: &[(String, String)]) -> String {
|
||||
nav.iter()
|
||||
.map(|(_, name)| name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ")
|
||||
}
|
||||
|
||||
/// The Invitations heading, with the count once there is more than one.
|
||||
pub(crate) fn invitations_title(count: usize) -> String {
|
||||
if count > 1 {
|
||||
format!("Invitations ({count})")
|
||||
} else {
|
||||
"Invitations".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint the contents of the shared folder at the top of the nav stack; the
|
||||
/// header's back button leads up again. Invitations and bookmarks are hidden here —
|
||||
/// they are top-level things, not contents of this folder.
|
||||
fn repaint_shared_folder(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
for (group, row) in ui.shared.rows.borrow_mut().drain(..) {
|
||||
@@ -255,28 +288,15 @@ fn repaint_shared_folder(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
ui.shared.content.set_visible_child_name("list");
|
||||
ui.shared.invitations_group.set_visible(false);
|
||||
ui.shared.bookmarks_group.set_visible(false);
|
||||
ui.shared.back.set_visible(true);
|
||||
ui.shared.with_me_group.set_visible(true);
|
||||
ui.shared.add_bookmark.set_visible(false);
|
||||
|
||||
let nav = ui.shared.nav.borrow().clone();
|
||||
let title = nav
|
||||
.iter()
|
||||
.map(|(_, name)| name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
ui.shared.with_me_group.set_title(&title);
|
||||
ui.shared.title.set_subtitle(&shared_path(&nav));
|
||||
ui.shared.with_me_group.set_title("");
|
||||
|
||||
let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new();
|
||||
let up = adw::ActionRow::builder()
|
||||
.title("Back")
|
||||
.activatable(true)
|
||||
.build();
|
||||
up.add_prefix(>k4::Image::from_icon_name("go-up-symbolic"));
|
||||
let ui_up = ui.clone();
|
||||
up.connect_activated(move |_| {
|
||||
ui_up.shared.nav.borrow_mut().pop();
|
||||
load_shared(&ui_up);
|
||||
});
|
||||
ui.shared.with_me_group.add(&up);
|
||||
rows.push((ui.shared.with_me_group.clone(), up.upcast()));
|
||||
|
||||
if entries.is_empty() {
|
||||
let row = dim_row("This folder is empty.");
|
||||
@@ -284,7 +304,7 @@ fn repaint_shared_folder(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
|
||||
} else {
|
||||
for entry in entries {
|
||||
let row = shared_entry_row(ui, entry);
|
||||
let row = shared_entry_row(ui, entry, false);
|
||||
ui.shared.with_me_group.add(&row);
|
||||
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
|
||||
}
|
||||
@@ -294,7 +314,10 @@ fn repaint_shared_folder(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
|
||||
/// A row for one node shared with me: folders descend into, files download and
|
||||
/// open with the user's default application.
|
||||
fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
||||
///
|
||||
/// `leavable` rows are share roots: only those carry a Leave action, since
|
||||
/// leaving a folder's child on its own is not a thing the API offers.
|
||||
fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry, leavable: bool) -> adw::ActionRow {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&entry.name)
|
||||
.activatable(true)
|
||||
@@ -306,8 +329,8 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
||||
// What I am allowed to do with someone else's file is the thing this page
|
||||
// cannot leave implicit: a viewer share is browsable and unwritable, and
|
||||
// that is only visible if it is said.
|
||||
if let Some(badge) = role_badge(&entry.role) {
|
||||
row.add_suffix(&badge);
|
||||
if let Some(pill) = role_pill(&entry.role) {
|
||||
row.add_suffix(&pill);
|
||||
}
|
||||
// The "shared by" in the subtitle is what the invitation claims. When its
|
||||
// signature does not check out against that person's keys, the claim must
|
||||
@@ -321,6 +344,10 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
||||
row.add_suffix(&warning);
|
||||
}
|
||||
row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true));
|
||||
row.add_suffix(&shared_entry_menu(ui, entry, leavable));
|
||||
if entry.is_dir {
|
||||
row.add_suffix(>k4::Image::from_icon_name("go-next-symbolic"));
|
||||
}
|
||||
let ui_act = ui.clone();
|
||||
let uid = entry.uid.clone();
|
||||
let name = entry.name.clone();
|
||||
@@ -340,35 +367,69 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
||||
row
|
||||
}
|
||||
|
||||
/// A shared row's ⋮ menu: open it, find it in My Files when the mount has
|
||||
/// interned it, and leave it (share roots only).
|
||||
fn shared_entry_menu(ui: &Rc<Ui>, entry: &DirEntry, leavable: bool) -> gtk4::MenuButton {
|
||||
let mut items: Vec<(&str, MenuAction)> = Vec::new();
|
||||
let (ui_open, uid, name, is_dir) = (
|
||||
ui.clone(),
|
||||
entry.uid.clone(),
|
||||
entry.name.clone(),
|
||||
entry.is_dir,
|
||||
);
|
||||
items.push((
|
||||
"Open",
|
||||
Box::new(move || {
|
||||
if is_dir {
|
||||
ui_open
|
||||
.shared
|
||||
.nav
|
||||
.borrow_mut()
|
||||
.push((uid.clone(), name.clone()));
|
||||
load_shared(&ui_open);
|
||||
} else {
|
||||
open_shared_file(&ui_open, &uid, &name);
|
||||
}
|
||||
}),
|
||||
));
|
||||
if !entry.path.is_empty() {
|
||||
let (ui_show, entry_show) = (ui.clone(), entry.clone());
|
||||
items.push((
|
||||
"Show in My Files",
|
||||
Box::new(move || show_in_my_files(&ui_show, &entry_show)),
|
||||
));
|
||||
}
|
||||
if leavable {
|
||||
let (ui_leave, uid, name) = (ui.clone(), entry.uid.clone(), entry.name.clone());
|
||||
items.push((
|
||||
"Leave…",
|
||||
Box::new(move || prompt_leave_shared(&ui_leave, &uid, &name)),
|
||||
));
|
||||
}
|
||||
let button = more_menu_button(items);
|
||||
button.set_valign(gtk4::Align::Center);
|
||||
button
|
||||
}
|
||||
|
||||
/// Tooltip on the warning shown next to a share whose invitation did not verify.
|
||||
const UNVERIFIED_INVITER_TOOLTIP: &str = "The invitation's signature doesn't match the sender's keys. \
|
||||
It may not really be from them.";
|
||||
|
||||
/// A shared row's subtitle: who shared it and when (share roots only — that is
|
||||
/// where the invitation lives), its size (files only), and where it can be
|
||||
/// reached in the local mount once the synthetic `Shared with me/` directory has
|
||||
/// interned it. An unlisted share has no local path yet, and says nothing rather
|
||||
/// than pointing at a directory that is not there.
|
||||
/// where the invitation lives) and its size (files only). Where the share sits
|
||||
/// in the mount is one menu item away ("Show in My Files"), not spelled out on
|
||||
/// every row, where the long path wrapped the subtitle onto a second line.
|
||||
pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if !entry.shared_by.is_empty() {
|
||||
let date = if entry.shared_at > 0 {
|
||||
format_modified(entry.shared_at)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
parts.push(if date.is_empty() {
|
||||
format!("Shared by {}", entry.shared_by)
|
||||
} else {
|
||||
format!("Shared by {} on {date}", entry.shared_by)
|
||||
});
|
||||
parts.push(entry.shared_by.clone());
|
||||
if entry.shared_at > 0 {
|
||||
parts.push(format_modified(entry.shared_at));
|
||||
}
|
||||
}
|
||||
if !entry.is_dir {
|
||||
parts.push(human_bytes(entry.size));
|
||||
}
|
||||
if !entry.path.is_empty() {
|
||||
parts.push(format!("in {}", entry.path));
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
@@ -385,13 +446,25 @@ pub(crate) fn role_label(role: &str) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A dim role pill for a share I did not create.
|
||||
pub(crate) fn role_badge(role: &str) -> Option<gtk4::Label> {
|
||||
let badge = gtk4::Label::new(Some(role_label(role)?));
|
||||
badge.add_css_class("dim-label");
|
||||
badge.add_css_class("caption");
|
||||
badge.set_valign(gtk4::Align::Center);
|
||||
Some(badge)
|
||||
/// What a role lets me do, as a short phrase for the row's pill. A bare
|
||||
/// "Editor" beside a Leave button read like one more button.
|
||||
pub(crate) fn role_access(role: &str) -> Option<&'static str> {
|
||||
match role {
|
||||
"viewer" => Some("Can view"),
|
||||
"editor" => Some("Can edit"),
|
||||
"admin" => Some("Can manage"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A tinted, rounded pill saying what I may do with a share I did not create.
|
||||
pub(crate) fn role_pill(role: &str) -> Option<gtk4::Label> {
|
||||
let pill = gtk4::Label::new(Some(role_access(role)?));
|
||||
pill.add_css_class("role-pill");
|
||||
pill.add_css_class("caption");
|
||||
pill.set_valign(gtk4::Align::Center);
|
||||
pill.set_tooltip_text(role_label(role));
|
||||
Some(pill)
|
||||
}
|
||||
|
||||
/// Download a file shared with me into the daemon's cache and hand it to the
|
||||
@@ -474,119 +547,127 @@ pub(crate) fn repaint_shared(
|
||||
for (group, row) in ui.shared.rows.borrow_mut().drain(..) {
|
||||
group.remove(&row);
|
||||
}
|
||||
ui.shared.title.set_subtitle("");
|
||||
ui.shared.back.set_visible(false);
|
||||
ui.shared.add_bookmark.set_visible(true);
|
||||
if shared.is_empty() && invitations.is_empty() && bookmarks.is_empty() {
|
||||
shared_status(
|
||||
ui,
|
||||
"emblem-shared-symbolic",
|
||||
"Nothing Shared with You",
|
||||
"Files and folders other people share with you appear here. Public links \
|
||||
you bookmark are kept here too.",
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
ui.shared.content.set_visible_child_name("list");
|
||||
ui.shared.invitations_group.set_visible(true);
|
||||
ui.shared.bookmarks_group.set_visible(true);
|
||||
ui.shared.with_me_group.set_title("Shared with me");
|
||||
ui.shared
|
||||
.invitations_group
|
||||
.set_visible(!invitations.is_empty());
|
||||
ui.shared.bookmarks_group.set_visible(!bookmarks.is_empty());
|
||||
ui.shared.with_me_group.set_visible(!shared.is_empty());
|
||||
// The list only needs a heading when another section sits beside it.
|
||||
ui.shared
|
||||
.with_me_group
|
||||
.set_title(if invitations.is_empty() && bookmarks.is_empty() {
|
||||
""
|
||||
} else {
|
||||
"Shared with you"
|
||||
});
|
||||
ui.shared
|
||||
.invitations_group
|
||||
.set_title(&invitations_title(invitations.len()));
|
||||
let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new();
|
||||
|
||||
// Shared with me: name + Leave.
|
||||
if shared.is_empty() {
|
||||
let row = dim_row("Nothing is shared with you.");
|
||||
for entry in shared {
|
||||
let row = shared_entry_row(ui, entry, true);
|
||||
ui.shared.with_me_group.add(&row);
|
||||
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
|
||||
} else {
|
||||
for entry in shared {
|
||||
let row = shared_entry_row(ui, entry);
|
||||
let leave = gtk4::Button::builder()
|
||||
.label("Leave")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
leave.add_css_class("flat");
|
||||
let ui_leave = ui.clone();
|
||||
let uid = entry.uid.clone();
|
||||
let name = entry.name.clone();
|
||||
leave.connect_clicked(move |_| prompt_leave_shared(&ui_leave, &uid, &name));
|
||||
row.add_suffix(&leave);
|
||||
ui.shared.with_me_group.add(&row);
|
||||
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
|
||||
}
|
||||
}
|
||||
|
||||
// Invitations: inviter + item, Accept / Reject.
|
||||
if invitations.is_empty() {
|
||||
let row = dim_row("No pending invitations.");
|
||||
for inv in invitations {
|
||||
let item = inv
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "a shared item".to_string());
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&item)
|
||||
.subtitle(format!("From {}", inv.inviter_email))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name(if inv.is_dir {
|
||||
"folder-symbolic"
|
||||
} else {
|
||||
"text-x-generic-symbolic"
|
||||
}));
|
||||
let reject = gtk4::Button::builder()
|
||||
.icon_name("window-close-symbolic")
|
||||
.tooltip_text("Reject")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
reject.add_css_class("flat");
|
||||
let accept = gtk4::Button::builder()
|
||||
.label("Accept")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
accept.add_css_class("suggested-action");
|
||||
accept.add_css_class("pill");
|
||||
let ui_acc = ui.clone();
|
||||
let id_acc = inv.id.clone();
|
||||
accept.connect_clicked(move |_| {
|
||||
respond_invitation(&ui_acc, &id_acc, true);
|
||||
});
|
||||
let ui_rej = ui.clone();
|
||||
let id_rej = inv.id.clone();
|
||||
let name_rej = inv.name.clone().unwrap_or_else(|| "this item".into());
|
||||
reject.connect_clicked(move |btn| {
|
||||
let ui = ui_rej.clone();
|
||||
let id = id_rej.clone();
|
||||
confirm_destructive(
|
||||
btn,
|
||||
"Reject Invitation?",
|
||||
&format!("You won't have access to {name_rej} unless it is shared again."),
|
||||
"Reject",
|
||||
move || respond_invitation(&ui, &id, false),
|
||||
);
|
||||
});
|
||||
row.add_suffix(&accept);
|
||||
row.add_suffix(&reject);
|
||||
ui.shared.invitations_group.add(&row);
|
||||
rows.push((ui.shared.invitations_group.clone(), row.upcast()));
|
||||
} else {
|
||||
for inv in invitations {
|
||||
let item = inv
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "a shared item".to_string());
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&item)
|
||||
.subtitle(format!("from {}", inv.inviter_email))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name(if inv.is_dir {
|
||||
"folder-symbolic"
|
||||
} else {
|
||||
"text-x-generic-symbolic"
|
||||
}));
|
||||
let reject = gtk4::Button::builder()
|
||||
.icon_name("window-close-symbolic")
|
||||
.tooltip_text("Reject")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
reject.add_css_class("flat");
|
||||
let accept = gtk4::Button::builder()
|
||||
.label("Accept")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
accept.add_css_class("suggested-action");
|
||||
let ui_acc = ui.clone();
|
||||
let id_acc = inv.id.clone();
|
||||
accept.connect_clicked(move |_| {
|
||||
respond_invitation(&ui_acc, &id_acc, true);
|
||||
});
|
||||
let ui_rej = ui.clone();
|
||||
let id_rej = inv.id.clone();
|
||||
reject.connect_clicked(move |_| {
|
||||
respond_invitation(&ui_rej, &id_rej, false);
|
||||
});
|
||||
row.add_suffix(&accept);
|
||||
row.add_suffix(&reject);
|
||||
ui.shared.invitations_group.add(&row);
|
||||
rows.push((ui.shared.invitations_group.clone(), row.upcast()));
|
||||
}
|
||||
}
|
||||
|
||||
// Bookmarks: name/URL, Open / Remove.
|
||||
if bookmarks.is_empty() {
|
||||
let row = dim_row("No saved bookmarks.");
|
||||
for bm in bookmarks {
|
||||
let title = bm.name.clone().unwrap_or_else(|| "Shared link".to_string());
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&title)
|
||||
.subtitle(&bm.url)
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("emblem-symbolic-link"));
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Remove bookmark")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let open = gtk4::Button::builder()
|
||||
.icon_name("external-link-symbolic")
|
||||
.tooltip_text("Open in browser")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
open.add_css_class("flat");
|
||||
let url_open = bm.url.clone();
|
||||
open.connect_clicked(move |_| open_uri(&url_open));
|
||||
let ui_rm = ui.clone();
|
||||
let token = bm.token.clone();
|
||||
let name_rm = title.clone();
|
||||
remove.connect_clicked(move |_| prompt_remove_bookmark(&ui_rm, &token, &name_rm));
|
||||
row.add_suffix(&open);
|
||||
row.add_suffix(&remove);
|
||||
ui.shared.bookmarks_group.add(&row);
|
||||
rows.push((ui.shared.bookmarks_group.clone(), row.upcast()));
|
||||
} else {
|
||||
for bm in bookmarks {
|
||||
let title = bm.name.clone().unwrap_or_else(|| "Shared link".to_string());
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&title)
|
||||
.subtitle(&bm.url)
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("emblem-symbolic-link"));
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Remove bookmark")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let open = gtk4::Button::builder()
|
||||
.icon_name("external-link-symbolic")
|
||||
.tooltip_text("Open in browser")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
open.add_css_class("flat");
|
||||
let url_open = bm.url.clone();
|
||||
open.connect_clicked(move |_| open_uri(&url_open));
|
||||
let ui_rm = ui.clone();
|
||||
let token = bm.token.clone();
|
||||
let name_rm = title.clone();
|
||||
remove.connect_clicked(move |_| prompt_remove_bookmark(&ui_rm, &token, &name_rm));
|
||||
row.add_suffix(&open);
|
||||
row.add_suffix(&remove);
|
||||
ui.shared.bookmarks_group.add(&row);
|
||||
rows.push((ui.shared.bookmarks_group.clone(), row.upcast()));
|
||||
}
|
||||
}
|
||||
|
||||
*ui.shared.rows.borrow_mut() = rows;
|
||||
@@ -761,13 +842,10 @@ mod tests {
|
||||
fn a_share_root_says_who_shared_it() {
|
||||
let mut root = entry(true, "", "viewer");
|
||||
root.shared_by = "alice@proton.me".into();
|
||||
assert_eq!(shared_row_subtitle(&root), "Shared by alice@proton.me");
|
||||
assert_eq!(shared_row_subtitle(&root), "alice@proton.me");
|
||||
root.shared_at = 1_700_000_000;
|
||||
let subtitle = shared_row_subtitle(&root);
|
||||
assert!(
|
||||
subtitle.starts_with("Shared by alice@proton.me on "),
|
||||
"{subtitle}"
|
||||
);
|
||||
assert!(subtitle.starts_with("alice@proton.me · "), "{subtitle}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -783,9 +861,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resident_share_reports_where_it_can_be_reached() {
|
||||
fn a_resident_share_keeps_its_path_out_of_the_subtitle() {
|
||||
let subtitle = shared_row_subtitle(&entry(true, "Shared with me/Team Budget", "editor"));
|
||||
assert_eq!(subtitle, "in Shared with me/Team Budget");
|
||||
assert_eq!(subtitle, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_role_pill_says_what_it_allows() {
|
||||
assert_eq!(role_access("viewer"), Some("Can view"));
|
||||
assert_eq!(role_access("editor"), Some("Can edit"));
|
||||
assert_eq!(role_access("admin"), Some("Can manage"));
|
||||
assert_eq!(role_access(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_invitations_heading_counts_only_several() {
|
||||
assert_eq!(invitations_title(1), "Invitations");
|
||||
assert_eq!(invitations_title(4), "Invitations (4)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -799,4 +891,14 @@ mod tests {
|
||||
assert!(role_label("").is_none());
|
||||
assert!(role_label("inherited").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_header_path_reads_from_the_share_root_down() {
|
||||
assert_eq!(shared_path(&[]), "");
|
||||
let nav = vec![
|
||||
("u1".to_string(), "Team".to_string()),
|
||||
("u2".to_string(), "Reports".to_string()),
|
||||
];
|
||||
assert_eq!(shared_path(&nav), "Team / Reports");
|
||||
}
|
||||
}
|
||||
@@ -22,20 +22,10 @@ pub(crate) struct SharedByMeWidgets {
|
||||
}
|
||||
|
||||
/// The Shared page: one section listing the items I have shared with others —
|
||||
/// each row summarizing who has access and carrying its public link (copy/open)
|
||||
/// and a Manage shortcut into the per-node Share dialog.
|
||||
/// each row summarizing who has access, opening the item when activated, with
|
||||
/// Copy link and a menu for the link and the per-node Share dialog.
|
||||
pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Shared")
|
||||
.halign(gtk4::Align::Start)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
let refresh = refresh_button();
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header.set_hexpand(true);
|
||||
header.append(&title);
|
||||
header.append(&refresh);
|
||||
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let clamp = adw::Clamp::builder().child(&group).build();
|
||||
@@ -65,15 +55,16 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
||||
content.add_named(&status, Some("status"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(12);
|
||||
inner.set_margin_bottom(12);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&header);
|
||||
inner.set_margin_top(18);
|
||||
inner.set_margin_bottom(18);
|
||||
inner.set_margin_start(18);
|
||||
inner.set_margin_end(18);
|
||||
inner.append(&content);
|
||||
let (frame, header, _) = page_frame("Shared by Me", &inner);
|
||||
header.pack_end(&refresh);
|
||||
|
||||
(
|
||||
inner.upcast(),
|
||||
frame.upcast(),
|
||||
SharedByMeWidgets {
|
||||
content,
|
||||
status,
|
||||
@@ -208,8 +199,17 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
||||
.build();
|
||||
row.add_prefix(&file_thumbnail(ui, &entry, 40, 24, true));
|
||||
|
||||
// Copy / open the public link, when there is one with a recovered URL.
|
||||
if let Some(url) = item.link.as_ref().and_then(|l| l.url.clone()) {
|
||||
// A node the daemon can place in my tree opens like it would in My Files.
|
||||
if !item.path.is_empty() {
|
||||
row.set_activatable(true);
|
||||
let ui_open = ui.clone();
|
||||
let entry_open = entry.clone();
|
||||
row.connect_activated(move |_| open_shared_by_me(&ui_open, &entry_open));
|
||||
}
|
||||
|
||||
let url = item.link.as_ref().and_then(|l| l.url.clone());
|
||||
// The one action people come here for most gets its own button.
|
||||
if let Some(url) = url.clone() {
|
||||
let copy = gtk4::Button::builder()
|
||||
.icon_name("edit-copy-symbolic")
|
||||
.tooltip_text("Copy link")
|
||||
@@ -217,34 +217,13 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
||||
.build();
|
||||
copy.add_css_class("flat");
|
||||
let ui_copy = ui.clone();
|
||||
let url_copy = url.clone();
|
||||
copy.connect_clicked(move |btn| {
|
||||
btn.clipboard().set_text(&url_copy);
|
||||
btn.clipboard().set_text(&url);
|
||||
toast(&ui_copy, "Link copied");
|
||||
});
|
||||
let open = gtk4::Button::builder()
|
||||
.icon_name("external-link-symbolic")
|
||||
.tooltip_text("Open link")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
open.add_css_class("flat");
|
||||
let url_open = url.clone();
|
||||
open.connect_clicked(move |_| open_uri(&url_open));
|
||||
row.add_suffix(©);
|
||||
row.add_suffix(&open);
|
||||
}
|
||||
|
||||
// Manage opens the per-node Share dialog. It used to be offered only for
|
||||
// an item whose path the daemon could resolve; the dialog now addresses a
|
||||
// pathless node by uid, so every shared item can be managed from here.
|
||||
let manage = gtk4::Button::builder()
|
||||
.label("Manage")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
manage.add_css_class("flat");
|
||||
let ui_manage = ui.clone();
|
||||
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
|
||||
row.add_suffix(&manage);
|
||||
row.add_suffix(&shared_by_me_menu(ui, &entry, url));
|
||||
|
||||
ui.shared_by_me.group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
@@ -252,6 +231,90 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
||||
*ui.shared_by_me.rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// The ⋮ menu on a shared item: open it, the link, the Share dialog, and
|
||||
/// Stop Sharing.
|
||||
///
|
||||
/// Manage access opens the per-node Share dialog, which addresses a pathless
|
||||
/// node by uid, so every shared item can be managed from here.
|
||||
fn shared_by_me_menu(ui: &Rc<Ui>, entry: &DirEntry, url: Option<String>) -> gtk4::MenuButton {
|
||||
let mut items: Vec<(&str, MenuAction)> = Vec::new();
|
||||
if !entry.path.is_empty() {
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
items.push(("Open", Box::new(move || open_shared_by_me(&ui_c, &entry_c))));
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
items.push((
|
||||
"Show in My Files",
|
||||
Box::new(move || show_in_my_files(&ui_c, &entry_c)),
|
||||
));
|
||||
}
|
||||
if let Some(url) = url {
|
||||
items.push(("Open Link", Box::new(move || open_uri(&url))));
|
||||
}
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
items.push((
|
||||
"Manage Access…",
|
||||
Box::new(move || open_share_dialog(&ui_c, &entry_c)),
|
||||
));
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
items.push((
|
||||
"Stop Sharing…",
|
||||
Box::new(move || prompt_stop_sharing(&ui_c, &entry_c)),
|
||||
));
|
||||
more_menu_button(items)
|
||||
}
|
||||
|
||||
/// Confirm, then remove every member, invitation and the public link.
|
||||
pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Stop sharing?")
|
||||
.body(format!(
|
||||
"Everyone you invited loses access to “{}”, pending invitations are \
|
||||
withdrawn and its public link stops working.",
|
||||
entry.name
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("stop", "Stop Sharing");
|
||||
dialog.set_response_appearance("stop", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
let ui_c = ui.clone();
|
||||
let uid = entry.uid.clone();
|
||||
let name = entry.name.clone();
|
||||
dialog.connect_response(Some("stop"), move |_, _| {
|
||||
run_mutation(
|
||||
&ui_c,
|
||||
Request::StopSharingByUid { uid: uid.clone() },
|
||||
format!("Stopped sharing {name}"),
|
||||
"Couldn't stop sharing",
|
||||
);
|
||||
});
|
||||
dialog.present(ui_window(ui).as_ref());
|
||||
}
|
||||
|
||||
/// Open a shared item: a folder in My Files, a file the way My Files would.
|
||||
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
if entry.is_dir {
|
||||
ui.stack.set_visible_child_name("browser");
|
||||
ui.browser.search.set_text("");
|
||||
browse_to(ui, entry.path.clone());
|
||||
} else {
|
||||
activate_entry(ui, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Go to the folder holding a shared item in My Files.
|
||||
pub(crate) fn show_in_my_files(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let parent = entry
|
||||
.path
|
||||
.rsplit_once('/')
|
||||
.map(|(parent, _)| parent.to_string())
|
||||
.unwrap_or_default();
|
||||
ui.stack.set_visible_child_name("browser");
|
||||
ui.browser.search.set_text("");
|
||||
browse_to(ui, parent);
|
||||
}
|
||||
|
||||
/// A one-line summary of who can reach a shared item, for its row subtitle.
|
||||
pub(crate) fn shared_item_summary(item: &SharedItem) -> String {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
@@ -7,18 +7,24 @@ pub(crate) struct StatusState {
|
||||
/// Guards the [`Request::GetQueueStatus`] poll the same way: at most one
|
||||
/// in-flight at a time so a wedged daemon can't stack worker threads.
|
||||
pub(crate) transfers_inflight: Cell<bool>,
|
||||
/// Activity group + its current rows, hidden when no transfer is in flight.
|
||||
/// Transfers group (on the Sync page) + its current rows, hidden when no
|
||||
/// transfer is in flight.
|
||||
pub(crate) transfers_group: adw::PreferencesGroup,
|
||||
pub(crate) transfer_rows: RefCell<Vec<TransferRow>>,
|
||||
// Main page.
|
||||
pub(crate) account_row: adw::ActionRow,
|
||||
/// Read-only mount status line. The mount is driven by the systemd user
|
||||
/// service (enabled on login), not by the user — so this only reports.
|
||||
pub(crate) mount_row: adw::ActionRow,
|
||||
/// The Preferences dialog, built once and presented on demand.
|
||||
pub(crate) prefs: adw::PreferencesDialog,
|
||||
/// Sidebar footer: signed-in identity.
|
||||
pub(crate) account_name: gtk4::Label,
|
||||
pub(crate) avatar: adw::Avatar,
|
||||
/// Sidebar footer: the one-line sync state. The mount is driven by the
|
||||
/// systemd user service, not by the user — so this only reports.
|
||||
pub(crate) status_icon: gtk4::Image,
|
||||
pub(crate) status_title: gtk4::Label,
|
||||
pub(crate) status_detail: gtk4::Label,
|
||||
pub(crate) cache_bar: gtk4::ProgressBar,
|
||||
pub(crate) cache_label: gtk4::Label,
|
||||
/// Account-quota group + its bar/label. Hidden until the first reading.
|
||||
pub(crate) quota_group: adw::PreferencesGroup,
|
||||
/// Sidebar footer: account quota bar/label. Hidden until the first reading.
|
||||
pub(crate) quota_box: gtk4::Box,
|
||||
pub(crate) quota_bar: gtk4::ProgressBar,
|
||||
pub(crate) quota_label: gtk4::Label,
|
||||
/// One `AccountQuota` in flight at a time, and when it last succeeded — quota
|
||||
@@ -31,9 +37,17 @@ pub(crate) struct StatusState {
|
||||
/// Cache-budget editor (GiB). Populated once from config; user edits drive a
|
||||
/// `SetCacheBudget` round-trip. Guarded by [`Self::settings_suppress`].
|
||||
pub(crate) budget_row: adw::SpinRow,
|
||||
/// Shows where the primary mount lives; the folder itself is managed on the
|
||||
/// Locations page, which owns every local path.
|
||||
/// Bandwidth caps (MiB/s, `0` = none). Populated once from config; an edit
|
||||
/// to either sends both in one `SetBandwidthLimits`. Guarded by
|
||||
/// [`Self::settings_suppress`].
|
||||
pub(crate) upload_limit_row: adw::SpinRow,
|
||||
pub(crate) download_limit_row: adw::SpinRow,
|
||||
/// Shows where the primary mount lives; its Change button picks a new one.
|
||||
pub(crate) mountpoint_row: adw::ActionRow,
|
||||
/// "Proton purple accent" toggle. Guarded by [`Self::settings_suppress`].
|
||||
pub(crate) accent_row: adw::SwitchRow,
|
||||
/// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`].
|
||||
pub(crate) tray_row: adw::SwitchRow,
|
||||
/// Set while a settings widget is being populated programmatically, so its
|
||||
/// change handler skips the IPC/systemd side effect.
|
||||
pub(crate) settings_suppress: Cell<bool>,
|
||||
@@ -42,10 +56,12 @@ pub(crate) struct StatusState {
|
||||
/// stack of toasts and a series of caps the user never asked to apply — so
|
||||
/// only the value they settle on is sent.
|
||||
pub(crate) budget_source: RefCell<Option<glib::SourceId>>,
|
||||
/// Pending debounce for the bandwidth editors, as for the cache budget.
|
||||
pub(crate) limit_source: RefCell<Option<glib::SourceId>>,
|
||||
pub(crate) pins_group: adw::PreferencesGroup,
|
||||
/// Whether the pin list is showing every pin or only the first
|
||||
/// [`PINS_COLLAPSED`]. A long pin list would otherwise push everything below
|
||||
/// it — including the Developer group — off the end of the page.
|
||||
/// it off the end of the Storage page.
|
||||
pub(crate) pins_expanded: Cell<bool>,
|
||||
/// Rows currently shown under [`Self::pins_group`], retained so a refresh can
|
||||
/// diff against them and only rebuild when the pin set actually changes.
|
||||
@@ -89,102 +105,122 @@ pub(crate) struct ActivityLine {
|
||||
pub(crate) fraction: Option<f64>,
|
||||
}
|
||||
|
||||
/// Widgets the settings page hands back for the refresh loop and action wiring.
|
||||
/// Widgets the Preferences dialog and the sidebar footer hand back for the
|
||||
/// refresh loop and action wiring.
|
||||
pub(crate) struct MainWidgets {
|
||||
pub(crate) account_row: adw::ActionRow,
|
||||
pub(crate) mount_row: adw::ActionRow,
|
||||
/// Live upload/download progress, populated by the refresh loop.
|
||||
pub(crate) transfers_group: adw::PreferencesGroup,
|
||||
/// Account-quota group; hidden until `refresh_quota` gets a reading.
|
||||
pub(crate) quota_group: adw::PreferencesGroup,
|
||||
pub(crate) prefs: adw::PreferencesDialog,
|
||||
/// The sidebar footer: sync status, quota, account.
|
||||
pub(crate) footer: gtk4::Box,
|
||||
/// Opens the Sync page; the footer's status strip is this button.
|
||||
pub(crate) status_button: gtk4::Button,
|
||||
pub(crate) account_name: gtk4::Label,
|
||||
pub(crate) avatar: adw::Avatar,
|
||||
pub(crate) status_icon: gtk4::Image,
|
||||
pub(crate) status_title: gtk4::Label,
|
||||
pub(crate) status_detail: gtk4::Label,
|
||||
pub(crate) quota_box: gtk4::Box,
|
||||
pub(crate) quota_bar: gtk4::ProgressBar,
|
||||
pub(crate) quota_label: gtk4::Label,
|
||||
pub(crate) cache_bar: gtk4::ProgressBar,
|
||||
pub(crate) cache_label: gtk4::Label,
|
||||
pub(crate) pins_group: adw::PreferencesGroup,
|
||||
pub(crate) logout_button: gtk4::Button,
|
||||
/// "Start on login" toggle, reflecting the systemd unit's enabled state.
|
||||
pub(crate) autostart_row: adw::SwitchRow,
|
||||
/// Cache soft-cap editor, in GiB; `0` = unlimited.
|
||||
pub(crate) budget_row: adw::SpinRow,
|
||||
/// Upload and download caps, in MiB/s; `0` = unlimited.
|
||||
pub(crate) upload_limit_row: adw::SpinRow,
|
||||
pub(crate) download_limit_row: adw::SpinRow,
|
||||
/// Purges all unpinned cached content.
|
||||
pub(crate) purge_button: gtk4::Button,
|
||||
/// Shows the active mountpoint; its suffix button opens the Locations page,
|
||||
/// which is where the folder is changed.
|
||||
/// Shows the active mountpoint; its suffix button picks a new one.
|
||||
pub(crate) mountpoint_row: adw::ActionRow,
|
||||
pub(crate) mountpoint_button: gtk4::Button,
|
||||
/// Opens the Google Photos Takeout import page.
|
||||
pub(crate) import_row: adw::ActionRow,
|
||||
pub(crate) accent_row: adw::SwitchRow,
|
||||
pub(crate) tray_row: adw::SwitchRow,
|
||||
}
|
||||
|
||||
/// The main (logged-in) page: a libadwaita settings surface — account header,
|
||||
/// mount status, storage controls (cache budget + purge), system integration
|
||||
/// (start-on-login, mountpoint), the pin list, and developer overrides. Returns
|
||||
/// the widgets the refresh loop updates plus the controls to wire.
|
||||
pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
||||
// Account group: identity + sign-out.
|
||||
let account_group = adw::PreferencesGroup::new();
|
||||
let account_row = adw::ActionRow::builder().title("Not signed in").build();
|
||||
let avatar = adw::Avatar::new(40, None, true);
|
||||
account_row.add_prefix(&avatar);
|
||||
let logout_button = gtk4::Button::builder()
|
||||
.label("Sign out")
|
||||
/// Build the two surfaces that replaced the old Settings page:
|
||||
///
|
||||
/// - the Preferences dialog (General: start on login, mount location,
|
||||
/// appearance; Storage: cache usage, budget, clear, offline files), and
|
||||
/// - the sidebar footer (sync status strip, account quota, account menu).
|
||||
///
|
||||
/// Live state (transfers, the folder list) lives on the Sync page instead, and
|
||||
/// the app version and user agent in About → Troubleshooting.
|
||||
pub(crate) fn build_main_page() -> MainWidgets {
|
||||
// ---- Preferences: General
|
||||
let startup_group = adw::PreferencesGroup::builder().title("Startup").build();
|
||||
let autostart_row = adw::SwitchRow::builder()
|
||||
.title("Start on login")
|
||||
.subtitle("Connect Proton Drive automatically when you log in")
|
||||
.build();
|
||||
startup_group.add(&autostart_row);
|
||||
|
||||
let location_group = adw::PreferencesGroup::builder().title("Location").build();
|
||||
let mountpoint_row = adw::ActionRow::builder()
|
||||
.title("Proton Drive folder")
|
||||
.subtitle("—")
|
||||
.build();
|
||||
mountpoint_row.add_css_class("property");
|
||||
let mountpoint_button = gtk4::Button::builder()
|
||||
.label("Change…")
|
||||
.tooltip_text("Choose a different folder for the Proton Drive mount")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
logout_button.add_css_class("flat");
|
||||
account_row.add_suffix(&logout_button);
|
||||
account_group.add(&account_row);
|
||||
mountpoint_button.add_css_class("flat");
|
||||
mountpoint_row.add_suffix(&mountpoint_button);
|
||||
location_group.add(&mountpoint_row);
|
||||
|
||||
// Account storage: the Proton account quota (used of total), distinct from
|
||||
// the local content cache below. A progress bar + "X of Y used" line, painted
|
||||
// by `refresh_quota`. Hidden until the first successful read so a cold start
|
||||
// (or an account the API can't report) shows nothing rather than an empty bar.
|
||||
let quota_group = adw::PreferencesGroup::builder()
|
||||
.title("Account storage")
|
||||
.description("Your Proton storage across all products.")
|
||||
.visible(false)
|
||||
// Bandwidth caps in MiB/s. 0 = unlimited; the smallest cap is one 0.5 step,
|
||||
// since anything slower would stall a single block for most of a minute.
|
||||
let network_group = adw::PreferencesGroup::builder()
|
||||
.title("Network")
|
||||
.description("Limit how fast files move, shared by every transfer. 0 means no limit.")
|
||||
.build();
|
||||
let quota_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
quota_box.set_margin_top(6);
|
||||
quota_box.set_margin_bottom(6);
|
||||
let quota_bar = gtk4::ProgressBar::new();
|
||||
let quota_label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||
quota_label.add_css_class("dim-label");
|
||||
quota_box.append("a_bar);
|
||||
quota_box.append("a_label);
|
||||
let quota_row = adw::PreferencesRow::builder()
|
||||
.activatable(false)
|
||||
.child("a_box)
|
||||
.build();
|
||||
quota_group.add("a_row);
|
||||
let limit_row = |title: &str| {
|
||||
adw::SpinRow::builder()
|
||||
.title(title)
|
||||
.adjustment(>k4::Adjustment::new(0.0, 0.0, 1000.0, 0.5, 5.0, 0.0))
|
||||
.digits(1)
|
||||
.build()
|
||||
};
|
||||
let upload_limit_row = limit_row("Upload limit (MiB/s)");
|
||||
let download_limit_row = limit_row("Download limit (MiB/s)");
|
||||
network_group.add(&upload_limit_row);
|
||||
network_group.add(&download_limit_row);
|
||||
|
||||
// Mount group: a read-only status line. The mount is managed automatically
|
||||
// by the systemd user service; there is no toggle to fiddle with.
|
||||
let mount_group = adw::PreferencesGroup::builder().title("Drive").build();
|
||||
let mount_row = adw::ActionRow::builder()
|
||||
.title("Proton Drive")
|
||||
.subtitle("Not mounted")
|
||||
let appearance_group = adw::PreferencesGroup::builder().title("Appearance").build();
|
||||
let accent_row = adw::SwitchRow::builder()
|
||||
.title("Proton purple accent")
|
||||
.subtitle("Use the Proton brand color instead of the system accent color")
|
||||
.build();
|
||||
mount_group.add(&mount_row);
|
||||
|
||||
// Activity group: live upload/download progress. Hidden until the refresh
|
||||
// loop sees an in-flight transfer from `Request::GetQueueStatus`.
|
||||
let transfers_group = adw::PreferencesGroup::builder()
|
||||
.title("Activity")
|
||||
.description("Files moving to and from Proton Drive.")
|
||||
.visible(false)
|
||||
appearance_group.add(&accent_row);
|
||||
let tray_row = adw::SwitchRow::builder()
|
||||
.title("Show tray icon")
|
||||
.subtitle("Sync status and quick actions in the panel")
|
||||
.build();
|
||||
appearance_group.add(&tray_row);
|
||||
|
||||
// Storage group: a progress bar + "X of Y used" label, plus the cache-budget
|
||||
// editor and a purge button. `budget_row`/`purge_button` are wired in
|
||||
// `wire_settings`; the bar + label are repainted by the refresh loop.
|
||||
let general = adw::PreferencesPage::builder()
|
||||
.title("General")
|
||||
.icon_name("preferences-system-symbolic")
|
||||
.build();
|
||||
general.add(&startup_group);
|
||||
general.add(&location_group);
|
||||
general.add(&network_group);
|
||||
general.add(&appearance_group);
|
||||
|
||||
// ---- Preferences: Storage
|
||||
let storage_group = adw::PreferencesGroup::builder()
|
||||
.title("Storage")
|
||||
.description("Local cache for pinned and recently opened files.")
|
||||
.title("Cache")
|
||||
.description("Pinned and recently opened files, kept on this computer.")
|
||||
.build();
|
||||
let storage_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
storage_box.set_margin_top(6);
|
||||
storage_box.set_margin_bottom(6);
|
||||
storage_box.set_margin_top(12);
|
||||
storage_box.set_margin_bottom(12);
|
||||
storage_box.set_margin_start(12);
|
||||
storage_box.set_margin_end(12);
|
||||
let cache_bar = gtk4::ProgressBar::new();
|
||||
let cache_label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||
cache_label.add_css_class("dim-label");
|
||||
@@ -200,132 +236,155 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
||||
// as "no eviction". Step in 0.5 GiB; the upper bound is generous.
|
||||
let budget_adj = gtk4::Adjustment::new(0.0, 0.0, 1024.0, 0.5, 1.0, 0.0);
|
||||
let budget_row = adw::SpinRow::builder()
|
||||
.title("Cache budget (GiB)")
|
||||
.subtitle("Soft cap for cached content; 0 = unlimited.")
|
||||
.title("Cache size limit (GiB)")
|
||||
.subtitle("Older files are removed past this size; 0 means no limit")
|
||||
.adjustment(&budget_adj)
|
||||
.digits(1)
|
||||
.build();
|
||||
storage_group.add(&budget_row);
|
||||
let purge_row = adw::ActionRow::builder()
|
||||
.title("Purge cache")
|
||||
.subtitle("Delete cached content. Pinned files are kept.")
|
||||
.title("Clear cache")
|
||||
.subtitle("Remove cached copies. Files kept offline stay.")
|
||||
.build();
|
||||
let purge_button = gtk4::Button::builder()
|
||||
.label("Purge")
|
||||
.label("Clear…")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
purge_button.add_css_class("destructive-action");
|
||||
purge_button.add_css_class("flat");
|
||||
purge_row.add_suffix(&purge_button);
|
||||
storage_group.add(&purge_row);
|
||||
|
||||
// System integration: start-on-login + mountpoint chooser.
|
||||
let system_group = adw::PreferencesGroup::builder()
|
||||
.title("System integration")
|
||||
.build();
|
||||
let autostart_row = adw::SwitchRow::builder()
|
||||
.title("Start on login")
|
||||
.subtitle("Mount Proton Drive automatically when you log in.")
|
||||
.build();
|
||||
system_group.add(&autostart_row);
|
||||
// The mountpoint is a *location*, and every other local path this client
|
||||
// owns is managed on the Locations page. Keeping a second chooser here would
|
||||
// be a second source of truth for the same setting, so this row reports the
|
||||
// path and hands the change over.
|
||||
let mountpoint_row = adw::ActionRow::builder()
|
||||
.title("Mountpoint")
|
||||
.subtitle("—")
|
||||
.build();
|
||||
let mountpoint_button = gtk4::Button::builder()
|
||||
.label("Locations")
|
||||
.tooltip_text("Manage this computer's Proton Drive locations")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
mountpoint_button.add_css_class("flat");
|
||||
mountpoint_row.add_suffix(&mountpoint_button);
|
||||
system_group.add(&mountpoint_row);
|
||||
|
||||
// Import: a one-off migration rather than a setting, so it is a doorway to
|
||||
// its own page rather than controls inlined here — staging a set of archives
|
||||
// and watching an hours-long upload needs the room.
|
||||
let import_group = adw::PreferencesGroup::builder()
|
||||
.title("Import")
|
||||
.description("Bring a photo library from another service into Proton Photos.")
|
||||
.build();
|
||||
let import_row = adw::ActionRow::builder()
|
||||
.title("Import from Google Photos")
|
||||
.subtitle("Add a Google Takeout export to your timeline.")
|
||||
.activatable(true)
|
||||
.build();
|
||||
import_row.add_prefix(>k4::Image::from_icon_name("folder-download-symbolic"));
|
||||
import_row.add_suffix(>k4::Image::from_icon_name("go-next-symbolic"));
|
||||
import_group.add(&import_row);
|
||||
|
||||
// Pins group: filled in by refresh.
|
||||
let pins_group = adw::PreferencesGroup::builder()
|
||||
.title("Pinned files")
|
||||
.description("Kept available offline on this device.")
|
||||
.title("Available offline")
|
||||
.description("Files kept on this computer, even without a connection.")
|
||||
.build();
|
||||
|
||||
// Developer overrides: read-only client identity, for support/debugging.
|
||||
let dev_group = adw::PreferencesGroup::builder().title("Developer").build();
|
||||
let version_row = adw::ActionRow::builder()
|
||||
.title("App version")
|
||||
.subtitle(pdfs_core::config::APP_VERSION)
|
||||
let storage = adw::PreferencesPage::builder()
|
||||
.title("Storage")
|
||||
.icon_name("drive-harddisk-symbolic")
|
||||
.build();
|
||||
version_row.add_css_class("property");
|
||||
let agent_row = adw::ActionRow::builder()
|
||||
.title("User agent")
|
||||
.subtitle(pdfs_core::config::USER_AGENT)
|
||||
storage.add(&storage_group);
|
||||
storage.add(&pins_group);
|
||||
|
||||
let prefs = adw::PreferencesDialog::builder()
|
||||
.search_enabled(false)
|
||||
.build();
|
||||
agent_row.add_css_class("property");
|
||||
dev_group.add(&version_row);
|
||||
dev_group.add(&agent_row);
|
||||
prefs.add(&general);
|
||||
prefs.add(&storage);
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
inner.set_margin_top(18);
|
||||
inner.set_margin_bottom(18);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&account_group);
|
||||
inner.append("a_group);
|
||||
inner.append(&mount_group);
|
||||
inner.append(&transfers_group);
|
||||
inner.append(&storage_group);
|
||||
inner.append(&system_group);
|
||||
inner.append(&import_group);
|
||||
inner.append(&pins_group);
|
||||
inner.append(&dev_group);
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(560)
|
||||
.child(&inner)
|
||||
// ---- Sidebar footer
|
||||
let status_icon = gtk4::Image::from_icon_name("content-loading-symbolic");
|
||||
let status_title = gtk4::Label::builder()
|
||||
.label("Connecting…")
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build();
|
||||
status_title.add_css_class("heading");
|
||||
let status_detail = gtk4::Label::builder()
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.visible(false)
|
||||
.build();
|
||||
status_detail.add_css_class("caption");
|
||||
status_detail.add_css_class("dim-label");
|
||||
let status_text = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
status_text.set_hexpand(true);
|
||||
status_text.append(&status_title);
|
||||
status_text.append(&status_detail);
|
||||
let status_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||
status_content.append(&status_icon);
|
||||
status_content.append(&status_text);
|
||||
let status_button = gtk4::Button::builder()
|
||||
.child(&status_content)
|
||||
.tooltip_text("Show sync status")
|
||||
.build();
|
||||
status_button.add_css_class("flat");
|
||||
status_button.add_css_class("sidebar-status");
|
||||
|
||||
(
|
||||
scroll.upcast(),
|
||||
MainWidgets {
|
||||
account_row,
|
||||
mount_row,
|
||||
transfers_group,
|
||||
quota_group,
|
||||
quota_bar,
|
||||
quota_label,
|
||||
cache_bar,
|
||||
cache_label,
|
||||
pins_group,
|
||||
logout_button,
|
||||
autostart_row,
|
||||
budget_row,
|
||||
purge_button,
|
||||
mountpoint_row,
|
||||
mountpoint_button,
|
||||
import_row,
|
||||
},
|
||||
)
|
||||
let quota_bar = gtk4::ProgressBar::new();
|
||||
let quota_label = gtk4::Label::builder()
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
quota_label.add_css_class("caption");
|
||||
quota_label.add_css_class("dim-label");
|
||||
let quota_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
quota_box.add_css_class("sidebar-quota");
|
||||
quota_box.set_margin_start(8);
|
||||
quota_box.set_margin_end(8);
|
||||
quota_box.set_visible(false);
|
||||
quota_box.append("a_bar);
|
||||
quota_box.append("a_label);
|
||||
|
||||
let avatar = adw::Avatar::new(28, None, true);
|
||||
let account_name = gtk4::Label::builder()
|
||||
.halign(gtk4::Align::Start)
|
||||
.hexpand(true)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||
.build();
|
||||
let account_menu = gio::Menu::new();
|
||||
account_menu.append(Some("Preferences"), Some("win.preferences"));
|
||||
let sign_out = gio::Menu::new();
|
||||
sign_out.append(Some("Sign Out…"), Some("win.sign-out"));
|
||||
account_menu.append_section(None, &sign_out);
|
||||
let account_button = gtk4::MenuButton::builder()
|
||||
.icon_name("view-more-symbolic")
|
||||
.tooltip_text("Account")
|
||||
.menu_model(&account_menu)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
account_button.add_css_class("flat");
|
||||
let account = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||
account.add_css_class("sidebar-account");
|
||||
account.append(&avatar);
|
||||
account.append(&account_name);
|
||||
account.append(&account_button);
|
||||
|
||||
let footer = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
|
||||
footer.add_css_class("sidebar-footer");
|
||||
footer.append(&status_button);
|
||||
footer.append("a_box);
|
||||
footer.append(&account);
|
||||
|
||||
MainWidgets {
|
||||
prefs,
|
||||
footer,
|
||||
status_button,
|
||||
account_name,
|
||||
avatar,
|
||||
status_icon,
|
||||
status_title,
|
||||
status_detail,
|
||||
quota_box,
|
||||
quota_bar,
|
||||
quota_label,
|
||||
cache_bar,
|
||||
cache_label,
|
||||
pins_group,
|
||||
autostart_row,
|
||||
budget_row,
|
||||
upload_limit_row,
|
||||
download_limit_row,
|
||||
purge_button,
|
||||
mountpoint_row,
|
||||
mountpoint_button,
|
||||
accent_row,
|
||||
tray_row,
|
||||
}
|
||||
}
|
||||
|
||||
/// How many pins the Settings list shows before collapsing the rest behind a
|
||||
/// The Sync page's live-transfers group, hidden until the refresh loop sees an
|
||||
/// in-flight transfer from [`Request::GetQueueStatus`].
|
||||
pub(crate) fn build_transfers_group() -> adw::PreferencesGroup {
|
||||
adw::PreferencesGroup::builder()
|
||||
.title("Transfers")
|
||||
.description("Files moving to and from Proton Drive.")
|
||||
.visible(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// How many pins the Storage preferences list before collapsing the rest behind a
|
||||
/// "Show all" row. Enough to recognise the list at a glance; short enough that
|
||||
/// the groups below it stay reachable.
|
||||
pub(crate) const PINS_COLLAPSED: usize = 6;
|
||||
@@ -337,8 +396,11 @@ pub(crate) const BUDGET_DEBOUNCE: Duration = Duration::from_millis(600);
|
||||
/// Bytes per GiB, for the cache-budget editor's unit conversion.
|
||||
pub(crate) const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
|
||||
|
||||
/// Wire the Settings-page controls: the cache-budget editor, the purge button,
|
||||
/// the start-on-login switch and the mountpoint chooser. Initial widget state is
|
||||
/// Bytes per MiB, for the bandwidth editors' unit conversion.
|
||||
pub(crate) const MIB: f64 = 1024.0 * 1024.0;
|
||||
|
||||
/// Wire the Preferences controls: the cache-budget editor, the purge button, the
|
||||
/// start-on-login switch, the mountpoint chooser and the accent toggle. Initial widget state is
|
||||
/// read once from config / systemd here (the refresh loop owns only the live
|
||||
/// mount + cache-usage read-out), with [`Ui::settings_suppress`] set around the
|
||||
/// programmatic populate so the change handlers don't fire on it.
|
||||
@@ -346,7 +408,6 @@ pub(crate) fn wire_settings(
|
||||
ui: &Rc<Ui>,
|
||||
purge_button: >k4::Button,
|
||||
mountpoint_button: >k4::Button,
|
||||
import_row: &adw::ActionRow,
|
||||
) {
|
||||
let config = ui.dirs.load_config();
|
||||
|
||||
@@ -355,10 +416,20 @@ pub(crate) fn wire_settings(
|
||||
ui.status
|
||||
.budget_row
|
||||
.set_value(config.resolved_cache_budget() as f64 / GIB);
|
||||
ui.status
|
||||
.upload_limit_row
|
||||
.set_value(config.upload_limit.unwrap_or(0) as f64 / MIB);
|
||||
ui.status
|
||||
.download_limit_row
|
||||
.set_value(config.download_limit.unwrap_or(0) as f64 / MIB);
|
||||
ui.status
|
||||
.mountpoint_row
|
||||
.set_subtitle(&ui.dirs.resolved_mountpoint(&config).display().to_string());
|
||||
ui.status.autostart_row.set_active(service::is_enabled());
|
||||
ui.status
|
||||
.accent_row
|
||||
.set_active(config.proton_accent.unwrap_or(false));
|
||||
ui.status.tray_row.set_active(!config.tray_hidden);
|
||||
ui.status.settings_suppress.set(false);
|
||||
|
||||
// Cache budget: a user edit applies the new soft cap on the daemon (which
|
||||
@@ -386,16 +457,46 @@ pub(crate) fn wire_settings(
|
||||
*ui_budget.status.budget_source.borrow_mut() = Some(src);
|
||||
});
|
||||
|
||||
// Bandwidth: either editor sends both caps, since the daemon sets them as a
|
||||
// pair. Debounced like the budget.
|
||||
for row in [&ui.status.upload_limit_row, &ui.status.download_limit_row] {
|
||||
let ui_limit = ui.clone();
|
||||
row.connect_value_notify(move |_| {
|
||||
if ui_limit.status.settings_suppress.get() {
|
||||
return;
|
||||
}
|
||||
if let Some(src) = ui_limit.status.limit_source.borrow_mut().take() {
|
||||
src.remove();
|
||||
}
|
||||
let ui_fire = ui_limit.clone();
|
||||
let src = glib::timeout_add_local_once(BUDGET_DEBOUNCE, move || {
|
||||
ui_fire.status.limit_source.borrow_mut().take();
|
||||
let upload = (ui_fire.status.upload_limit_row.value() * MIB).round() as u64;
|
||||
let download = (ui_fire.status.download_limit_row.value() * MIB).round() as u64;
|
||||
settings_request(
|
||||
&ui_fire,
|
||||
Request::SetBandwidthLimits { upload, download },
|
||||
"Bandwidth limits updated",
|
||||
"Couldn't set bandwidth limits",
|
||||
);
|
||||
});
|
||||
*ui_limit.status.limit_source.borrow_mut() = Some(src);
|
||||
});
|
||||
}
|
||||
|
||||
// Purge: confirm, then drop all unpinned cached content via the daemon.
|
||||
let ui_purge = ui.clone();
|
||||
purge_button.connect_clicked(move |_| {
|
||||
let ui = ui_purge.clone();
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Purge cache")
|
||||
.body("Delete all cached content that isn't pinned? Pinned files stay offline.")
|
||||
.heading("Clear Cache?")
|
||||
.body(
|
||||
"Cached copies are removed from this computer and download again when \
|
||||
you open them. Files kept available offline stay.",
|
||||
)
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("purge", "Purge");
|
||||
dialog.add_response("purge", "Clear Cache");
|
||||
dialog.set_response_appearance("purge", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -404,12 +505,12 @@ pub(crate) fn wire_settings(
|
||||
settings_request(
|
||||
&ui,
|
||||
Request::PurgeCache,
|
||||
"Cache purged",
|
||||
"Couldn't purge cache",
|
||||
"Cache cleared",
|
||||
"Couldn't clear the cache",
|
||||
);
|
||||
}
|
||||
});
|
||||
dialog.present(ui_window(&ui_purge).as_ref());
|
||||
dialog.present(Some(&ui_purge.status.prefs));
|
||||
});
|
||||
|
||||
// Start on login: enable/disable the systemd unit without stopping a live
|
||||
@@ -426,14 +527,44 @@ pub(crate) fn wire_settings(
|
||||
}
|
||||
});
|
||||
|
||||
// Mountpoint: the chooser lives on the Locations page, next to every other
|
||||
// local path; this button is the way there.
|
||||
let ui_mp = ui.clone();
|
||||
mountpoint_button.connect_clicked(move |_| ui_mp.stack.set_visible_child_name("locations"));
|
||||
mountpoint_button.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
|
||||
|
||||
// Import: a doorway, not a setting — everything about it lives on its page.
|
||||
let ui_import = ui.clone();
|
||||
import_row.connect_activated(move |_| ui_import.stack.set_visible_child_name("takeout"));
|
||||
// Accent: saved with the rest of the config, applied right away.
|
||||
let ui_accent = ui.clone();
|
||||
ui.status.accent_row.connect_active_notify(move |row| {
|
||||
if ui_accent.status.settings_suppress.get() {
|
||||
return;
|
||||
}
|
||||
let on = row.is_active();
|
||||
set_proton_accent(on);
|
||||
let mut config = ui_accent.dirs.load_config();
|
||||
config.proton_accent = Some(on);
|
||||
if let Err(e) = ui_accent.dirs.save_config(&config) {
|
||||
toast_error(&ui_accent, "Couldn't save the accent color", &e.to_string());
|
||||
}
|
||||
});
|
||||
|
||||
// Tray icon: the tray reads the same flag at start, so hiding it here also
|
||||
// keeps the login autostart from bringing it back.
|
||||
let ui_tray = ui.clone();
|
||||
ui.status.tray_row.connect_active_notify(move |row| {
|
||||
if ui_tray.status.settings_suppress.get() {
|
||||
return;
|
||||
}
|
||||
let show = row.is_active();
|
||||
let mut config = ui_tray.dirs.load_config();
|
||||
config.tray_hidden = !show;
|
||||
if let Err(e) = ui_tray.dirs.save_config(&config) {
|
||||
toast_error(&ui_tray, "Couldn't save the tray setting", &e.to_string());
|
||||
return;
|
||||
}
|
||||
if show {
|
||||
spawn_tray();
|
||||
} else {
|
||||
pdfs_core::tray::quit(&ui_tray.dirs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Run a settings control-socket round-trip (budget / purge) on a worker thread,
|
||||
@@ -547,8 +678,11 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
||||
ui.stack.set_visible_child_name("browser");
|
||||
}
|
||||
ui.nav.set_collapsed(false);
|
||||
ui.status.account_row.set_title(&s.username);
|
||||
ui.status.account_row.set_subtitle("Proton account");
|
||||
if ui.status.account_name.label() != s.username {
|
||||
ui.status.account_name.set_label(&s.username);
|
||||
ui.status.account_name.set_tooltip_text(Some(&s.username));
|
||||
ui.status.avatar.set_text(Some(&s.username));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.stack.set_visible_child_name("login");
|
||||
@@ -568,12 +702,21 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
||||
// A Takeout import outlives the page that started it, so it is polled
|
||||
// wherever the user has navigated to — otherwise leaving the page means
|
||||
// never being told it finished.
|
||||
ui.gallery
|
||||
.import_banner
|
||||
.set_revealed(ui.takeout.running.get());
|
||||
if ui.stack.visible_child_name().as_deref() == Some("takeout") || ui.takeout.running.get() {
|
||||
refresh_takeout(ui);
|
||||
}
|
||||
// The quota sits in the sidebar footer, visible on every page; its TTL
|
||||
// keeps this from asking more than once a minute.
|
||||
refresh_quota(ui);
|
||||
match ui.stack.visible_child_name().as_deref() {
|
||||
Some("main" | "browser") => refresh_quota(ui),
|
||||
Some("locations") => refresh_locations(ui),
|
||||
Some("locations") => {
|
||||
refresh_locations(ui);
|
||||
refresh_queue(ui);
|
||||
refresh_conflicts(ui, false);
|
||||
}
|
||||
Some("activity") => refresh_activity(ui),
|
||||
_ => {}
|
||||
}
|
||||
@@ -584,7 +727,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
||||
const QUOTA_TTL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Fetch the account quota (if the last reading is stale) and paint both the
|
||||
/// Settings storage group and Files status bar. A failed fetch leaves the last
|
||||
/// sidebar footer and Files status bar. A failed fetch leaves the last
|
||||
/// good reading in place.
|
||||
pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
|
||||
if ui.status.quota_inflight.get() {
|
||||
@@ -608,7 +751,7 @@ pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
|
||||
{
|
||||
ui.status.quota_checked_at.set(Some(Instant::now()));
|
||||
paint_account_quota(&ui, max_space, used_space);
|
||||
ui.status.quota_group.set_visible(true);
|
||||
ui.status.quota_box.set_visible(true);
|
||||
} else if ui.status.quota_checked_at.get().is_none() {
|
||||
// Match Dolphin: capacity information does not occupy the bar until
|
||||
// the backing observer has real figures.
|
||||
@@ -680,19 +823,13 @@ fn quota_status_display(max_space: i64, used_space: i64) -> Option<(f64, String,
|
||||
/// that can only fail. A greyed control says so up front.
|
||||
pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
|
||||
*ui.mounted.borrow_mut() = mounted;
|
||||
ui.browser.new_folder.set_sensitive(mounted);
|
||||
ui.browser.upload.set_sensitive(mounted);
|
||||
ui.browser.upload_folder.set_sensitive(mounted);
|
||||
ui.browser.build_thumbnails.set_sensitive(
|
||||
mounted
|
||||
&& (!ui.browser.thumbnail_build_running.get()
|
||||
|| !ui.browser.thumbnail_cancel_pending.get()),
|
||||
);
|
||||
sync_mounted_actions(ui, mounted);
|
||||
ui.gallery.upload.set_sensitive(mounted);
|
||||
ui.details.details.pin_row.set_sensitive(mounted);
|
||||
ui.details.details.rename_button.set_sensitive(mounted);
|
||||
ui.details.details.trash_button.set_sensitive(mounted);
|
||||
ui.details.details.open_button.set_sensitive(mounted);
|
||||
ui.details.details.versions_button.set_sensitive(mounted);
|
||||
|
||||
// Only notify on a real edge, and never for the first reading: at startup the
|
||||
// service is usually still coming up, and "disconnected" would be a lie.
|
||||
@@ -895,6 +1032,10 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
||||
online,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
failing_ops,
|
||||
failing_error,
|
||||
paused,
|
||||
paused_until,
|
||||
..
|
||||
})) => {
|
||||
set_mounted(&ui, true);
|
||||
@@ -902,14 +1043,24 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
||||
// in it: it is why a file that looks saved is not on the remote
|
||||
// yet, and offline is usually the reason it is still queued.
|
||||
let queued = pending_summary(pending_uploads, pending_changes);
|
||||
ui.status.mount_row.set_subtitle(&match (online, queued) {
|
||||
(true, None) => format!("Mounted at {mountpoint}"),
|
||||
(true, Some(q)) => format!("Mounted at {mountpoint} — {q}"),
|
||||
(false, None) => {
|
||||
format!("Mounted at {mountpoint} — offline, cached files only")
|
||||
let state = if paused {
|
||||
SyncState::Paused {
|
||||
until: paused_until,
|
||||
queued,
|
||||
}
|
||||
(false, Some(q)) => format!("Mounted at {mountpoint} — offline, {q}"),
|
||||
});
|
||||
} else if failing_ops > 0 {
|
||||
SyncState::Attention {
|
||||
count: failing_ops,
|
||||
error: failing_error,
|
||||
}
|
||||
} else if !online {
|
||||
SyncState::Offline { queued }
|
||||
} else if let Some(queued) = queued {
|
||||
SyncState::Syncing { queued }
|
||||
} else {
|
||||
SyncState::UpToDate { mountpoint }
|
||||
};
|
||||
paint_sync_status(&ui, state);
|
||||
let fraction = if budget == 0 {
|
||||
0.0
|
||||
} else {
|
||||
@@ -931,7 +1082,7 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
||||
// rows and cache read-out so the page doesn't flicker on a blip.
|
||||
_ => {
|
||||
set_mounted(&ui, false);
|
||||
ui.status.mount_row.set_subtitle("Not mounted");
|
||||
paint_sync_status(&ui, SyncState::Disconnected);
|
||||
for r in ui.status.pin_rows.borrow().iter() {
|
||||
if let Some(b) = &r.unpin {
|
||||
b.set_sensitive(false);
|
||||
@@ -942,6 +1093,132 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// What the sidebar's status strip reports, most urgent first.
|
||||
pub(crate) enum SyncState {
|
||||
/// The user paused syncing; nothing goes up until it resumes. Outranks
|
||||
/// everything else, because it is the reason for everything else.
|
||||
Paused {
|
||||
/// Unix second the pause ends by itself; `None` until resumed.
|
||||
until: Option<i64>,
|
||||
queued: Option<String>,
|
||||
},
|
||||
/// Operations keep failing; the user may have to act.
|
||||
Attention {
|
||||
count: u64,
|
||||
error: Option<String>,
|
||||
},
|
||||
/// No connection to Proton: cached files only, changes wait.
|
||||
Offline {
|
||||
queued: Option<String>,
|
||||
},
|
||||
/// Local changes are on their way up.
|
||||
Syncing {
|
||||
queued: String,
|
||||
},
|
||||
UpToDate {
|
||||
mountpoint: String,
|
||||
},
|
||||
/// No mount daemon answered: still starting, or the service is down.
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
/// Paint the sidebar status strip and the Sync page's status card: icon,
|
||||
/// one-line state, and a detail line.
|
||||
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||
let paused = matches!(state, SyncState::Paused { .. });
|
||||
let connected = !matches!(state, SyncState::Disconnected);
|
||||
let (icon, class, title, detail) = match state {
|
||||
SyncState::Paused { until, queued } => (
|
||||
"media-playback-pause-symbolic",
|
||||
Some("warning"),
|
||||
"Sync paused".to_string(),
|
||||
Some(match (until, queued) {
|
||||
(Some(until), Some(queued)) => format!("{queued} · resumes {}", clock_time(until)),
|
||||
(Some(until), None) => format!("Resumes {}", clock_time(until)),
|
||||
(None, Some(queued)) => queued,
|
||||
(None, None) => "Until you resume".to_string(),
|
||||
}),
|
||||
),
|
||||
SyncState::Attention { count, error } => (
|
||||
"dialog-warning-symbolic",
|
||||
Some("error"),
|
||||
format!(
|
||||
"{} need{} attention",
|
||||
count_noun(count as usize, "change", "changes"),
|
||||
if count == 1 { "s" } else { "" }
|
||||
),
|
||||
error,
|
||||
),
|
||||
SyncState::Offline { queued } => (
|
||||
"network-offline-symbolic",
|
||||
Some("warning"),
|
||||
"Offline".to_string(),
|
||||
Some(queued.unwrap_or_else(|| "Cached files only".to_string())),
|
||||
),
|
||||
SyncState::Syncing { queued } => (
|
||||
"emblem-synchronizing-symbolic",
|
||||
None,
|
||||
"Syncing".to_string(),
|
||||
Some(queued),
|
||||
),
|
||||
SyncState::UpToDate { mountpoint } => (
|
||||
"emblem-ok-symbolic",
|
||||
Some("success"),
|
||||
"Up to date".to_string(),
|
||||
Some(mountpoint),
|
||||
),
|
||||
SyncState::Disconnected => (
|
||||
"network-offline-symbolic",
|
||||
Some("warning"),
|
||||
"Not connected".to_string(),
|
||||
Some("Proton Drive isn't running".to_string()),
|
||||
),
|
||||
};
|
||||
let image = &ui.status.status_icon;
|
||||
image.set_icon_name(Some(icon));
|
||||
for c in ["success", "warning", "error"] {
|
||||
image.remove_css_class(c);
|
||||
}
|
||||
if let Some(class) = class {
|
||||
image.add_css_class(class);
|
||||
}
|
||||
ui.status.status_title.set_label(&title);
|
||||
ui.status.status_detail.set_visible(detail.is_some());
|
||||
ui.status
|
||||
.status_detail
|
||||
.set_label(detail.as_deref().unwrap_or_default());
|
||||
ui.status.status_detail.set_tooltip_text(detail.as_deref());
|
||||
paint_sync_card(
|
||||
ui,
|
||||
icon,
|
||||
class,
|
||||
&title,
|
||||
detail.as_deref(),
|
||||
paused,
|
||||
connected,
|
||||
);
|
||||
}
|
||||
|
||||
/// "at 14:30" today, "Tue 09:00" within the week, a date beyond that.
|
||||
pub(crate) fn clock_time(unix: i64) -> String {
|
||||
let (Ok(at), Ok(now)) = (
|
||||
glib::DateTime::from_unix_local(unix),
|
||||
glib::DateTime::now_local(),
|
||||
) else {
|
||||
return "later".to_string();
|
||||
};
|
||||
let format = if at.ymd() == now.ymd() {
|
||||
"at %H:%M"
|
||||
} else if at.difference(&now).as_seconds() < 6 * 86_400 {
|
||||
"%a %H:%M"
|
||||
} else {
|
||||
"%e %b"
|
||||
};
|
||||
at.format(format)
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| "later".to_string())
|
||||
}
|
||||
|
||||
/// Render the pins group from `pins`, with the unpin buttons enabled only while a
|
||||
/// mount daemon is running (`mounted`). Diffs against the last batch by path: when
|
||||
/// the set is unchanged (the common case on the 2s tick) it only flips the unpin
|
||||
@@ -965,8 +1242,8 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
||||
|
||||
if pins.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No pinned files")
|
||||
.subtitle("Right-click a file in the mount to keep it offline.")
|
||||
.title("No files kept offline")
|
||||
.subtitle("Right-click a file and choose “Make available offline”.")
|
||||
.build();
|
||||
ui.status.pins_group.add(&row);
|
||||
ui.status
|
||||
@@ -994,25 +1271,37 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
||||
.title(&name)
|
||||
.subtitle(&pin.path)
|
||||
.build();
|
||||
let icon = gtk4::Image::from_icon_name("emblem-documents-symbolic");
|
||||
let icon = gtk4::Image::from_icon_name("pdfs-offline-symbolic");
|
||||
row.add_prefix(&icon);
|
||||
|
||||
let unpin = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.icon_name("pdfs-online-only-symbolic")
|
||||
.valign(gtk4::Align::Center)
|
||||
.tooltip_text("Unpin (remove offline copy)")
|
||||
.tooltip_text("Make online only")
|
||||
.sensitive(mounted)
|
||||
.build();
|
||||
unpin.add_css_class("flat");
|
||||
let ui_btn = ui.clone();
|
||||
let path = pin.path.clone();
|
||||
unpin.connect_clicked(move |_| {
|
||||
let socket = ui_btn.dirs.control_socket();
|
||||
match send(&socket, &Request::Unpin { path: path.clone() }) {
|
||||
Ok(Response::Error { message, .. }) => tracing::error!("unpin failed: {message}"),
|
||||
Ok(_) => refresh(&ui_btn),
|
||||
Err(e) => tracing::error!("unpin request failed: {e}"),
|
||||
}
|
||||
let rx = spawn_request(
|
||||
ui_btn.dirs.control_socket(),
|
||||
Request::Unpin { path: path.clone() },
|
||||
);
|
||||
let ui = ui_btn.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't make it online only", &message, kind)
|
||||
}
|
||||
Ok(Ok(_)) => refresh(&ui),
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
"Couldn't make it online only",
|
||||
"The mount service didn't respond.",
|
||||
),
|
||||
}
|
||||
});
|
||||
});
|
||||
row.add_suffix(&unpin);
|
||||
|
||||
@@ -1028,7 +1317,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
||||
.title(if expanded {
|
||||
"Show fewer".to_string()
|
||||
} else {
|
||||
format!("Show all {} pinned files", pins.len())
|
||||
format!("Show all {}", count_noun(pins.len(), "file", "files"))
|
||||
})
|
||||
.activatable(true)
|
||||
.build();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! The Import page: bringing a Google Photos **Takeout** export into Proton
|
||||
//! Photos.
|
||||
//!
|
||||
//! Reached from Settings → *Import from Google Photos* (and from the Gallery's
|
||||
//! import button), because it is a one-off migration rather than something the
|
||||
//! Reached from the Photos page's import button (and its empty timeline),
|
||||
//! because it is a one-off migration rather than something the
|
||||
//! photo timeline does every day. It gets a page of its own instead of a dialog
|
||||
//! for two reasons: an export is a *set* of archives that has to be staged and
|
||||
//! checked before anything is sent, and the import that follows runs for hours,
|
||||
@@ -17,12 +17,12 @@
|
||||
//! The daemon owns the run itself ([`Request::ImportTakeout`]): this page only
|
||||
//! stages paths, launches, polls [`Request::ImportStatus`] for the final report,
|
||||
//! and reads live progress off the import's job in the transfer snapshot the
|
||||
//! Settings tick already fetches (see [`takeout_progress`]).
|
||||
//! status tick already fetches (see [`takeout_progress`]).
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// The title the daemon gives the import's job in [`Request::GetQueueStatus`].
|
||||
/// Matching on it is how this page borrows the progress the Settings tick is
|
||||
/// Matching on it is how this page borrows the progress the status tick is
|
||||
/// already polling instead of adding a second poll of its own.
|
||||
const IMPORT_JOB: &str = "Importing Google Photos";
|
||||
|
||||
@@ -126,20 +126,15 @@ pub(crate) struct TakeoutWidgets {
|
||||
}
|
||||
|
||||
pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||
// Header: a back button out to Settings, since this page is reached from
|
||||
// Header: a back button out to Photos, since this page is reached from
|
||||
// there and has no sidebar row of its own to navigate back with.
|
||||
let back_button = gtk4::Button::builder()
|
||||
.icon_name("go-previous-symbolic")
|
||||
.tooltip_text("Back to Settings")
|
||||
.tooltip_text("Back to Photos")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
back_button.add_css_class("flat");
|
||||
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Import from Google Photos")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
let subtitle = gtk4::Label::builder()
|
||||
.label("Add a Google Takeout export to your Proton Photos timeline.")
|
||||
.halign(gtk4::Align::Start)
|
||||
@@ -148,15 +143,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||
.build();
|
||||
subtitle.add_css_class("dim-label");
|
||||
|
||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
titles.set_hexpand(true);
|
||||
titles.append(&title);
|
||||
titles.append(&subtitle);
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header.append(&back_button);
|
||||
header.append(&titles);
|
||||
let header_clamp = adw::Clamp::builder().child(&header).build();
|
||||
let subtitle_clamp = adw::Clamp::builder().child(&subtitle).build();
|
||||
|
||||
// Drop zone. It is a hint, not the only way in — the button under it does
|
||||
// the same thing for anyone not dragging, and the drop target covers the
|
||||
@@ -235,7 +222,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||
actions.append(&import_button);
|
||||
|
||||
// Progress: the import's job from the transfer snapshot, so this page shows
|
||||
// the same numbers the Settings Activity list does.
|
||||
// the same numbers the Sync page's Transfers list does.
|
||||
let progress_group = adw::PreferencesGroup::builder()
|
||||
.title("Import in progress")
|
||||
.visible(false)
|
||||
@@ -284,8 +271,10 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||
page.set_margin_bottom(18);
|
||||
page.set_margin_start(18);
|
||||
page.set_margin_end(18);
|
||||
page.append(&header_clamp);
|
||||
page.append(&subtitle_clamp);
|
||||
page.append(&scroll);
|
||||
let (frame, header, _) = page_frame("Import from Google Photos", &page);
|
||||
header.pack_start(&back_button);
|
||||
|
||||
let widgets = TakeoutWidgets {
|
||||
list_group: list_group.clone(),
|
||||
@@ -302,7 +291,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||
progress_bar: progress_bar.clone(),
|
||||
summary_group: summary_group.clone(),
|
||||
};
|
||||
(page.upcast(), widgets)
|
||||
(frame.upcast(), widgets)
|
||||
}
|
||||
|
||||
pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
||||
@@ -386,9 +375,10 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Import this export?")
|
||||
.body(format!(
|
||||
"{count} archive(s) will be uploaded to Proton Photos, creating albums as they \
|
||||
appear in the export. This can take hours; you can stop it at any point and \
|
||||
what has been uploaded stays. Photos you already have are skipped.",
|
||||
"{} will be uploaded to Proton Photos, creating albums as they appear in \
|
||||
the export. This can take hours; you can stop it at any point and what has \
|
||||
been uploaded stays. Photos you already have are skipped.",
|
||||
count_noun(count, "archive", "archives")
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
@@ -429,7 +419,7 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
||||
let ui_back = ui.clone();
|
||||
widgets
|
||||
.back_button
|
||||
.connect_clicked(move |_| ui_back.stack.set_visible_child_name("main"));
|
||||
.connect_clicked(move |_| ui_back.stack.set_visible_child_name("gallery"));
|
||||
|
||||
// Restore whatever was staged when the window last closed, before the first
|
||||
// paint, so the page comes up as the user left it.
|
||||
@@ -473,7 +463,10 @@ pub(crate) fn add_archives(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
||||
"Only Takeout .zip files can be imported — extracted folders aren't supported.",
|
||||
);
|
||||
} else if added > 0 {
|
||||
toast(ui, &format!("Added {added} archive(s)"));
|
||||
toast(
|
||||
ui,
|
||||
&format!("Added {}", count_noun(added, "archive", "archives")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,8 +519,8 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
|
||||
rows.push(row.upcast());
|
||||
}
|
||||
ui.takeout.list_group.set_description(Some(&format!(
|
||||
"{} archive(s), {} in total. {EMPTY_LIST_DESCRIPTION}",
|
||||
staged.len(),
|
||||
"{}, {} in total. {EMPTY_LIST_DESCRIPTION}",
|
||||
count_noun(staged.len(), "archive", "archives"),
|
||||
human_bytes(total)
|
||||
)));
|
||||
}
|
||||
@@ -751,7 +744,7 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
|
||||
ui.takeout.summary_group.set_visible(true);
|
||||
}
|
||||
|
||||
/// Paint the import's progress from the transfer snapshot the Settings tick
|
||||
/// Paint the import's progress from the transfer snapshot the status tick
|
||||
/// already fetched, so this page costs no extra poll. A job with no known total
|
||||
/// pulses instead of showing a fraction, the same as the Activity list.
|
||||
pub(crate) fn takeout_progress(ui: &Rc<Ui>, jobs: &[JobItem]) {
|
||||
|
||||
@@ -10,7 +10,11 @@ pub(crate) struct TrashState {
|
||||
/// Empties the trash; insensitive while it is empty (or unread).
|
||||
pub(crate) empty: gtk4::Button,
|
||||
/// "12 items" under the page title.
|
||||
pub(crate) subtitle: gtk4::Label,
|
||||
pub(crate) subtitle: adw::WindowTitle,
|
||||
pub(crate) selection: gtk4::MultiSelection,
|
||||
/// The bottom bar acting on the selection; revealed while anything is selected.
|
||||
pub(crate) selection_bar: gtk4::Revealer,
|
||||
pub(crate) selection_label: gtk4::Label,
|
||||
}
|
||||
|
||||
/// The widgets of the Trash page that a load repaints.
|
||||
@@ -22,11 +26,18 @@ pub(crate) struct TrashWidgets {
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) empty: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
pub(crate) subtitle: gtk4::Label,
|
||||
pub(crate) subtitle: adw::WindowTitle,
|
||||
pub(crate) selection: gtk4::MultiSelection,
|
||||
pub(crate) selection_bar: gtk4::Revealer,
|
||||
pub(crate) selection_label: gtk4::Label,
|
||||
pub(crate) restore_selected: gtk4::Button,
|
||||
pub(crate) delete_selected: gtk4::Button,
|
||||
}
|
||||
|
||||
/// The Trash page: a flat list of everything Drive is holding in the trash, each
|
||||
/// row offering Restore and Delete Forever, with Empty Trash in the header.
|
||||
/// row offering Restore and Delete Forever, with Empty Trash in the header. A
|
||||
/// selection (click, Shift/Ctrl+click, Ctrl+A) gets the same two actions in a
|
||||
/// bottom bar.
|
||||
///
|
||||
/// A trashed node has no path inside the mount — the daemon forgets it when it is
|
||||
/// trashed — so unlike the Files page this one addresses entries by uid and always
|
||||
@@ -35,31 +46,13 @@ pub(crate) struct TrashWidgets {
|
||||
pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||
let model = gio::ListStore::new::<BoxedAnyObject>();
|
||||
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Trash")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
let subtitle = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||
subtitle.add_css_class("dim-label");
|
||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
titles.set_hexpand(true);
|
||||
titles.append(&title);
|
||||
titles.append(&subtitle);
|
||||
|
||||
let empty = gtk4::Button::builder()
|
||||
.label("Empty Trash")
|
||||
.valign(gtk4::Align::Center)
|
||||
.label("Empty Trash…")
|
||||
.tooltip_text("Permanently delete everything in the Trash")
|
||||
.sensitive(false)
|
||||
.build();
|
||||
empty.add_css_class("destructive-action");
|
||||
let refresh = refresh_button();
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header.append(&titles);
|
||||
header.append(&refresh);
|
||||
header.append(&empty);
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.halign(gtk4::Align::Center)
|
||||
@@ -74,11 +67,10 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||
.build();
|
||||
status.add_css_class("compact");
|
||||
|
||||
// No selection model: every action lives on the row it acts on, so a
|
||||
// mis-aimed Delete Forever isn't one click away from a stale selection.
|
||||
let list = gtk4::ListView::builder()
|
||||
.model(>k4::NoSelection::new(Some(model.clone())))
|
||||
.build();
|
||||
// Bulk Delete Forever always confirms with the count, so a stale selection
|
||||
// can't delete anything silently.
|
||||
let selection = gtk4::MultiSelection::new(Some(model.clone()));
|
||||
let list = gtk4::ListView::builder().model(&selection).build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.child(&list)
|
||||
@@ -91,15 +83,32 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||
content.add_named(&status, Some("status"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(12);
|
||||
inner.set_margin_bottom(12);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&header);
|
||||
inner.set_margin_top(18);
|
||||
inner.set_margin_bottom(18);
|
||||
inner.set_margin_start(18);
|
||||
inner.set_margin_end(18);
|
||||
inner.append(&content);
|
||||
|
||||
let selection_label = gtk4::Label::new(None);
|
||||
let restore_selected = gtk4::Button::with_label("Restore");
|
||||
let delete_selected = gtk4::Button::with_label("Delete Permanently…");
|
||||
delete_selected.add_css_class("destructive-action");
|
||||
let bar = gtk4::ActionBar::new();
|
||||
bar.set_center_widget(Some(&selection_label));
|
||||
bar.pack_start(&restore_selected);
|
||||
bar.pack_end(&delete_selected);
|
||||
let selection_bar = gtk4::Revealer::builder()
|
||||
.transition_type(gtk4::RevealerTransitionType::SlideUp)
|
||||
.child(&bar)
|
||||
.build();
|
||||
inner.append(&selection_bar);
|
||||
|
||||
let (frame, header, subtitle) = page_frame("Trash", &inner);
|
||||
header.pack_start(&empty);
|
||||
header.pack_end(&refresh);
|
||||
|
||||
(
|
||||
inner.upcast(),
|
||||
frame.upcast(),
|
||||
TrashWidgets {
|
||||
model,
|
||||
list,
|
||||
@@ -109,6 +118,11 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||
empty,
|
||||
refresh,
|
||||
subtitle,
|
||||
selection,
|
||||
selection_bar,
|
||||
selection_label,
|
||||
restore_selected,
|
||||
delete_selected,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -116,7 +130,9 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||
/// Install the row factory and the Empty Trash button. The row's two buttons read
|
||||
/// the entry off the [`gtk4::ListItem`] they were clicked on rather than a
|
||||
/// captured copy, so a recycled row always acts on the item it currently shows.
|
||||
pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::Button) {
|
||||
pub(crate) fn wire_trash(ui: &Rc<Ui>, widgets: &TrashWidgets) {
|
||||
let list = &widgets.list;
|
||||
let empty = &widgets.empty;
|
||||
let factory = gtk4::SignalListItemFactory::new();
|
||||
let ui_setup = ui.clone();
|
||||
factory.connect_setup(move |_, item| {
|
||||
@@ -160,7 +176,7 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::Butto
|
||||
let item_purge = item.clone();
|
||||
purge.connect_clicked(move |_| {
|
||||
if let Some(entry) = bound_entry(&item_purge) {
|
||||
prompt_delete_forever(&ui_purge, &entry);
|
||||
prompt_delete_forever(&ui_purge, std::slice::from_ref(&entry));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -207,6 +223,46 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::Butto
|
||||
|
||||
let ui_empty = ui.clone();
|
||||
empty.connect_clicked(move |_| prompt_empty_trash(&ui_empty));
|
||||
|
||||
let ui_sel = ui.clone();
|
||||
widgets
|
||||
.selection
|
||||
.connect_selection_changed(move |_, _, _| sync_trash_selection(&ui_sel));
|
||||
let ui_restore = ui.clone();
|
||||
widgets.restore_selected.connect_clicked(move |_| {
|
||||
let entries = selected_trash(&ui_restore);
|
||||
if !entries.is_empty() {
|
||||
restore_entries(&ui_restore, &entries);
|
||||
}
|
||||
});
|
||||
let ui_delete = ui.clone();
|
||||
widgets.delete_selected.connect_clicked(move |_| {
|
||||
let entries = selected_trash(&ui_delete);
|
||||
if !entries.is_empty() {
|
||||
prompt_delete_forever(&ui_delete, &entries);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The trashed entries currently selected, in list order.
|
||||
fn selected_trash(ui: &Rc<Ui>) -> Vec<DirEntry> {
|
||||
let selected = ui.trash.selection.selection();
|
||||
(0..selected.size())
|
||||
.filter_map(|i| ui.trash.model.item(selected.nth(i as u32)))
|
||||
.filter_map(|obj| obj.downcast::<BoxedAnyObject>().ok())
|
||||
.map(|obj| obj.borrow::<DirEntry>().clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reveal the selection bar while anything is selected and count what is.
|
||||
fn sync_trash_selection(ui: &Rc<Ui>) {
|
||||
let count = ui.trash.selection.selection().size() as usize;
|
||||
ui.trash.selection_bar.set_reveal_child(count > 0);
|
||||
if count > 0 {
|
||||
ui.trash
|
||||
.selection_label
|
||||
.set_label(&format!("{} selected", count_noun(count, "item", "items")));
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`DirEntry`] a list item is currently bound to, or `None` for an unbound
|
||||
@@ -224,6 +280,7 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
||||
// that may already be gone.
|
||||
ui.trash.model.remove_all();
|
||||
ui.trash.empty.set_sensitive(false);
|
||||
sync_trash_selection(ui);
|
||||
trash_status(
|
||||
ui,
|
||||
"user-trash-symbolic",
|
||||
@@ -294,13 +351,14 @@ pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &s
|
||||
ui.trash.status.set_description(Some(description));
|
||||
ui.trash.retry.set_visible(retry);
|
||||
ui.trash.content.set_visible_child_name("status");
|
||||
ui.trash.subtitle.set_label("");
|
||||
ui.trash.subtitle.set_subtitle("");
|
||||
}
|
||||
|
||||
/// Repopulate the trash list, most recently modified first — the order in which a
|
||||
/// user looks for what they just deleted.
|
||||
pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
ui.trash.model.remove_all();
|
||||
sync_trash_selection(ui);
|
||||
ui.trash.empty.set_sensitive(!entries.is_empty());
|
||||
if entries.is_empty() {
|
||||
trash_status(
|
||||
@@ -313,10 +371,9 @@ pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
return;
|
||||
}
|
||||
ui.trash.content.set_visible_child_name("list");
|
||||
ui.trash.subtitle.set_label(&match entries.len() {
|
||||
1 => "1 item".to_string(),
|
||||
n => format!("{n} items"),
|
||||
});
|
||||
ui.trash
|
||||
.subtitle
|
||||
.set_subtitle(&count_noun(entries.len(), "item", "items"));
|
||||
|
||||
let mut sorted = entries.to_vec();
|
||||
sorted.sort_by_key(|e| std::cmp::Reverse(e.modified));
|
||||
@@ -327,27 +384,37 @@ pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
|
||||
/// Restore one trashed entry to the folder it was trashed from.
|
||||
pub(crate) fn restore_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let name = entry.name.clone();
|
||||
restore_entries(ui, std::slice::from_ref(entry));
|
||||
}
|
||||
|
||||
/// Restore trashed entries to the folders they were trashed from.
|
||||
pub(crate) fn restore_entries(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
run_mutation(
|
||||
ui,
|
||||
Request::Restore {
|
||||
uids: vec![entry.uid.clone()],
|
||||
uids: entries.iter().map(|e| e.uid.clone()).collect(),
|
||||
},
|
||||
format!("Restored “{name}”"),
|
||||
format!("Restored {}", entries_label(entries)),
|
||||
"Couldn't restore",
|
||||
);
|
||||
}
|
||||
|
||||
/// Confirm, then permanently delete one trashed entry. Irreversible, so it asks.
|
||||
pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
/// “name” for one entry, “3 items” for several.
|
||||
fn entries_label(entries: &[DirEntry]) -> String {
|
||||
match entries {
|
||||
[one] => format!("“{}”", one.name),
|
||||
_ => count_noun(entries.len(), "item", "items"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm, then permanently delete trashed entries. Irreversible, so it asks.
|
||||
pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
let win = ui_window(ui);
|
||||
let uid = entry.uid.clone();
|
||||
let name = entry.name.clone();
|
||||
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
|
||||
let name = entries_label(entries);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Delete permanently")
|
||||
.body(format!(
|
||||
"Permanently delete “{name}”? This cannot be undone."
|
||||
))
|
||||
.heading("Delete Permanently")
|
||||
.body(format!("Permanently delete {name}? This cannot be undone."))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("delete", "Delete Permanently");
|
||||
@@ -360,10 +427,8 @@ pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
if resp == "delete" {
|
||||
run_mutation(
|
||||
&ui,
|
||||
Request::DeleteForever {
|
||||
uids: vec![uid.clone()],
|
||||
},
|
||||
format!("Deleted “{name}” permanently"),
|
||||
Request::DeleteForever { uids: uids.clone() },
|
||||
format!("Deleted {name} permanently"),
|
||||
"Couldn't delete",
|
||||
);
|
||||
}
|
||||
@@ -378,7 +443,8 @@ pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Empty Trash")
|
||||
.body(format!(
|
||||
"Permanently delete all {count} item(s) in the trash? This cannot be undone."
|
||||
"Permanently delete all {} in the trash? This cannot be undone.",
|
||||
count_noun(count as usize, "item", "items")
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
|
||||
@@ -9,10 +9,9 @@ pub(crate) struct DetailsState {
|
||||
/// Set while the details pane is being populated, so setting the offline
|
||||
/// switch programmatically doesn't fire a pin/unpin round-trip.
|
||||
pub(crate) details_suppress: Cell<bool>,
|
||||
/// The grid's and list's selection models. Both wrap the one browser model, so
|
||||
/// a selection in either drives the details pane.
|
||||
pub(crate) grid_selection: gtk4::MultiSelection,
|
||||
pub(crate) list_selection: gtk4::MultiSelection,
|
||||
/// The one selection model behind both the grid and the list, so switching
|
||||
/// views keeps what is selected; it drives the details pane.
|
||||
pub(crate) selection: gtk4::MultiSelection,
|
||||
}
|
||||
|
||||
/// The widgets in the browser's details pane that a selection repaints.
|
||||
@@ -26,17 +25,34 @@ pub(crate) struct DetailsWidgets {
|
||||
pub(crate) pin_row: adw::SwitchRow,
|
||||
pub(crate) open_button: gtk4::Button,
|
||||
pub(crate) rename_button: gtk4::Button,
|
||||
/// Who has access and whether there is a public link; filled on selection.
|
||||
pub(crate) sharing_row: adw::ActionRow,
|
||||
pub(crate) share_button: gtk4::Button,
|
||||
pub(crate) copy_link_button: gtk4::Button,
|
||||
/// Files only — folders have no revision history.
|
||||
pub(crate) versions_group: adw::PreferencesGroup,
|
||||
pub(crate) versions_row: adw::ActionRow,
|
||||
pub(crate) versions_button: gtk4::Button,
|
||||
pub(crate) trash_button: gtk4::Button,
|
||||
pub(crate) close_button: gtk4::Button,
|
||||
/// The header toggle that asks for the pane. Selecting an entry only
|
||||
/// fills the pane; it is shown while this is on, so a click never moves
|
||||
/// the files under the pointer halfway through a double-click.
|
||||
pub(crate) toggle: gtk4::ToggleButton,
|
||||
/// "details" while an entry is shown, "empty" with nothing selected.
|
||||
pub(crate) pages: gtk4::Stack,
|
||||
}
|
||||
|
||||
/// The details pane shown beside the file views: a big type icon over the entry's
|
||||
/// name, its properties, an offline (pin) toggle and the primary actions. Built
|
||||
/// empty; [`repaint_details`] fills it from the selected [`DirEntry`] and
|
||||
/// [`wire_details`] connects the buttons.
|
||||
/// name, then Info, Offline, Sharing and Versions sections and the primary
|
||||
/// actions. Built empty; [`show_details`] fills it from the selected
|
||||
/// [`DirEntry`] and [`wire_details`] connects the buttons.
|
||||
pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
let toggle = gtk4::ToggleButton::builder()
|
||||
.icon_name("sidebar-show-right-symbolic")
|
||||
.tooltip_text("Details (Alt+Enter)")
|
||||
.build();
|
||||
|
||||
let close_button = gtk4::Button::builder()
|
||||
.icon_name("window-close-symbolic")
|
||||
.tooltip_text("Close details")
|
||||
@@ -65,7 +81,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
head.append(&name);
|
||||
head.append(&kind);
|
||||
|
||||
let props = adw::PreferencesGroup::new();
|
||||
let props = adw::PreferencesGroup::builder().title("Info").build();
|
||||
let size_row = adw::ActionRow::builder()
|
||||
.title("Size")
|
||||
.subtitle("—")
|
||||
@@ -86,20 +102,50 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
props.add(&modified_row);
|
||||
props.add(&path_row);
|
||||
|
||||
let offline = adw::PreferencesGroup::new();
|
||||
let offline = adw::PreferencesGroup::builder().title("Offline").build();
|
||||
let pin_row = adw::SwitchRow::builder()
|
||||
.title("Available offline")
|
||||
.subtitle("Keep a local copy on this device.")
|
||||
.subtitle("Keep a copy on this computer, even without a connection.")
|
||||
.build();
|
||||
offline.add(&pin_row);
|
||||
|
||||
let sharing = adw::PreferencesGroup::builder().title("Sharing").build();
|
||||
let sharing_row = adw::ActionRow::builder()
|
||||
.title("Access")
|
||||
.subtitle("—")
|
||||
.build();
|
||||
sharing_row.add_css_class("property");
|
||||
sharing.add(&sharing_row);
|
||||
let share_button = gtk4::Button::builder().label("Share…").build();
|
||||
let copy_link_button = gtk4::Button::builder().label("Copy Link").build();
|
||||
let share_actions = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Horizontal)
|
||||
.spacing(6)
|
||||
.homogeneous(true)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
share_actions.append(&share_button);
|
||||
share_actions.append(©_link_button);
|
||||
sharing.add(&share_actions);
|
||||
|
||||
let versions_group = adw::PreferencesGroup::builder().title("Versions").build();
|
||||
let versions_row = adw::ActionRow::builder()
|
||||
.title("History")
|
||||
.subtitle("—")
|
||||
.build();
|
||||
versions_row.add_css_class("property");
|
||||
versions_group.add(&versions_row);
|
||||
let versions_button = gtk4::Button::builder()
|
||||
.label("Versions…")
|
||||
.margin_top(6)
|
||||
.build();
|
||||
versions_group.add(&versions_button);
|
||||
|
||||
let open_button = gtk4::Button::builder().label("Open").build();
|
||||
open_button.add_css_class("suggested-action");
|
||||
open_button.add_css_class("pill");
|
||||
let rename_button = gtk4::Button::builder().label("Rename").build();
|
||||
rename_button.add_css_class("pill");
|
||||
let versions_button = gtk4::Button::builder().label("Versions").build();
|
||||
versions_button.add_css_class("pill");
|
||||
let trash_button = gtk4::Button::builder().label("Move to Trash").build();
|
||||
trash_button.add_css_class("destructive-action");
|
||||
trash_button.add_css_class("pill");
|
||||
@@ -108,7 +154,6 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
actions.set_margin_top(6);
|
||||
actions.append(&open_button);
|
||||
actions.append(&rename_button);
|
||||
actions.append(&versions_button);
|
||||
actions.append(&trash_button);
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
@@ -120,14 +165,45 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
inner.append(&head);
|
||||
inner.append(&props);
|
||||
inner.append(&offline);
|
||||
inner.append(&sharing);
|
||||
inner.append(&versions_group);
|
||||
inner.append(&actions);
|
||||
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.child(&inner)
|
||||
.build();
|
||||
// With the pane open and nothing selected (a fresh folder, a cleared
|
||||
// selection) it says so rather than closing: opening and closing it by
|
||||
// itself would move the files under the pointer.
|
||||
let empty_close = gtk4::Button::builder()
|
||||
.icon_name("window-close-symbolic")
|
||||
.tooltip_text("Close details")
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(12)
|
||||
.margin_end(12)
|
||||
.build();
|
||||
empty_close.add_css_class("flat");
|
||||
empty_close.add_css_class("circular");
|
||||
let toggle_off = toggle.clone();
|
||||
empty_close.connect_clicked(move |_| toggle_off.set_active(false));
|
||||
let empty_status = adw::StatusPage::builder()
|
||||
.icon_name("sidebar-show-right-symbolic")
|
||||
.title("No item selected")
|
||||
.description("Select a file or folder to see its details.")
|
||||
.vexpand(true)
|
||||
.build();
|
||||
empty_status.add_css_class("compact");
|
||||
let empty = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
empty.append(&empty_close);
|
||||
empty.append(&empty_status);
|
||||
|
||||
let pages = gtk4::Stack::new();
|
||||
pages.add_named(&scroll, Some("details"));
|
||||
pages.add_named(&empty, Some("empty"));
|
||||
pages.set_visible_child_name("empty");
|
||||
let pane = adw::ToolbarView::new();
|
||||
pane.set_content(Some(&scroll));
|
||||
pane.set_content(Some(&pages));
|
||||
pane.add_css_class("background");
|
||||
|
||||
(
|
||||
@@ -140,11 +216,18 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
modified_row,
|
||||
path_row,
|
||||
pin_row,
|
||||
sharing_row,
|
||||
share_button,
|
||||
copy_link_button,
|
||||
versions_group,
|
||||
versions_row,
|
||||
open_button,
|
||||
rename_button,
|
||||
versions_button,
|
||||
trash_button,
|
||||
close_button,
|
||||
toggle,
|
||||
pages,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -152,29 +235,40 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
/// Connect the details pane: mirror the grid's and list's selection into it, and
|
||||
/// wire its buttons back onto the entry it's showing.
|
||||
pub(crate) fn wire_details(ui: &Rc<Ui>) {
|
||||
// Both views share one model but have their own selection, so watch both.
|
||||
for selection in [
|
||||
ui.details.grid_selection.clone(),
|
||||
ui.details.list_selection.clone(),
|
||||
] {
|
||||
{
|
||||
let ui_sel = ui.clone();
|
||||
selection.connect_selection_changed(move |_, _, _| {
|
||||
// The details pane describes *one* entry. A batch is the bulk bar's
|
||||
// business, so the pane steps aside rather than picking a member of
|
||||
// the selection to speak for the rest.
|
||||
let entries = selected_entries(&ui_sel);
|
||||
match entries.as_slice() {
|
||||
[entry] => show_details(&ui_sel, entry),
|
||||
_ => hide_details(&ui_sel),
|
||||
}
|
||||
sync_bulk_bar(&ui_sel);
|
||||
});
|
||||
ui.details
|
||||
.selection
|
||||
.connect_selection_changed(move |_, _, _| {
|
||||
// The details pane describes *one* entry. A batch is the bulk bar's
|
||||
// business, so the pane steps aside rather than picking a member of
|
||||
// the selection to speak for the rest.
|
||||
let entries = selected_entries(&ui_sel);
|
||||
match entries.as_slice() {
|
||||
[entry] => show_details(&ui_sel, entry),
|
||||
_ => clear_details(&ui_sel),
|
||||
}
|
||||
sync_bulk_bar(&ui_sel);
|
||||
});
|
||||
}
|
||||
|
||||
let ui_close = ui.clone();
|
||||
ui.details.details.close_button.connect_clicked(move |_| {
|
||||
clear_selection(&ui_close);
|
||||
hide_details(&ui_close);
|
||||
ui_close.details.details.toggle.set_active(false);
|
||||
});
|
||||
|
||||
let ui_toggle = ui.clone();
|
||||
ui.details.details.toggle.connect_toggled(move |toggle| {
|
||||
ui_toggle.browser.split.set_show_sidebar(toggle.is_active());
|
||||
});
|
||||
// On a narrow window the pane overlays the files, and a click outside it
|
||||
// closes it; the toggle follows so the next click on it opens it again.
|
||||
let ui_split = ui.clone();
|
||||
ui.browser.split.connect_show_sidebar_notify(move |split| {
|
||||
let toggle = &ui_split.details.details.toggle;
|
||||
if toggle.is_active() != split.shows_sidebar() {
|
||||
toggle.set_active(split.shows_sidebar());
|
||||
}
|
||||
});
|
||||
|
||||
let ui_open = ui.clone();
|
||||
@@ -204,11 +298,28 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
|
||||
open_versions_dialog(&ui_versions, &entry);
|
||||
}
|
||||
});
|
||||
let ui_share = ui.clone();
|
||||
ui.details.details.share_button.connect_clicked(move |_| {
|
||||
let entry = ui_share.details.details_entry.borrow().clone();
|
||||
if let Some(entry) = entry {
|
||||
open_share_dialog(&ui_share, &entry);
|
||||
}
|
||||
});
|
||||
let ui_link = ui.clone();
|
||||
ui.details
|
||||
.details
|
||||
.copy_link_button
|
||||
.connect_clicked(move |_| {
|
||||
let entry = ui_link.details.details_entry.borrow().clone();
|
||||
if let Some(entry) = entry {
|
||||
copy_entry_link(&ui_link, &entry);
|
||||
}
|
||||
});
|
||||
let ui_trash = ui.clone();
|
||||
ui.details.details.trash_button.connect_clicked(move |_| {
|
||||
let entry = ui_trash.details.details_entry.borrow().clone();
|
||||
if let Some(entry) = entry {
|
||||
prompt_delete(&ui_trash, &entry);
|
||||
trash_entry(&ui_trash, &entry);
|
||||
}
|
||||
});
|
||||
let ui_pin = ui.clone();
|
||||
@@ -230,7 +341,7 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Reveal the details pane and paint it from `entry`.
|
||||
/// Paint the details pane from `entry`. It shows while the header toggle is on.
|
||||
pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
ui.details.details_suppress.set(true);
|
||||
let d = &ui.details.details;
|
||||
@@ -256,34 +367,133 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
} else {
|
||||
parent
|
||||
});
|
||||
// Pinning is per file: the context menu and the selection bar offer it for
|
||||
// files only, so the pane must not offer it for folders either.
|
||||
d.pin_row.set_active(entry.pinned);
|
||||
d.pin_row.set_visible(!entry.is_dir);
|
||||
d.pin_row.set_sensitive(*ui.mounted.borrow());
|
||||
d.open_button
|
||||
.set_label(if entry.is_dir { "Open folder" } else { "Open" });
|
||||
// Only files have revisions, and only a connected daemon can fetch them.
|
||||
d.versions_button.set_visible(!entry.is_dir);
|
||||
d.versions_button.set_sensitive(*ui.mounted.borrow());
|
||||
let mounted = *ui.mounted.borrow();
|
||||
d.versions_group.set_visible(!entry.is_dir);
|
||||
d.versions_button.set_sensitive(mounted);
|
||||
d.share_button.set_sensitive(mounted);
|
||||
d.copy_link_button.set_sensitive(mounted);
|
||||
d.sharing_row.set_subtitle("—");
|
||||
d.versions_row.set_subtitle("—");
|
||||
ui.details.details_suppress.set(false);
|
||||
|
||||
*ui.details.details_entry.borrow_mut() = Some(entry.clone());
|
||||
// Reveal the pane on idle, not inline. This runs from `selection_changed`,
|
||||
// which fires on the *first* press of a double-click; mutating the widget
|
||||
// tree here cancels GtkGridView's multi-press tracking, so the second press
|
||||
// restarts the count and `activate` never fires — the folder needs two
|
||||
// double-clicks. Deferring lets the click gesture finish first. Guard on the
|
||||
// entry still being present so a navigation that clears it wins the race.
|
||||
let ui = ui.clone();
|
||||
glib::idle_add_local_once(move || {
|
||||
if ui.details.details_entry.borrow().is_some() {
|
||||
ui.browser.split.set_show_sidebar(true);
|
||||
if mounted {
|
||||
load_details_extras(ui, entry);
|
||||
}
|
||||
// Only the content changes. Whether the pane shows is the toggle's call:
|
||||
// this runs from `selection_changed`, on the *first* press of a
|
||||
// double-click, and sliding the pane in there would move the files so the
|
||||
// second press lands elsewhere and the folder never opens.
|
||||
d.pages.set_visible_child_name("details");
|
||||
}
|
||||
|
||||
/// Fill the Sharing and Versions rows, which need the network. The answers
|
||||
/// land only if the pane still shows the same entry.
|
||||
fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let by_uid = entry.path.is_empty() && !entry.uid.is_empty();
|
||||
let rel = entry_rel(ui, entry);
|
||||
let share_req = if by_uid {
|
||||
Request::ListShareByUid {
|
||||
uid: entry.uid.clone(),
|
||||
}
|
||||
} else {
|
||||
Request::ListShare { path: rel.clone() }
|
||||
};
|
||||
let rx = spawn_request(ui.dirs.control_socket(), share_req);
|
||||
let (ui_c, uid) = (ui.clone(), entry.uid.clone());
|
||||
glib::spawn_future_local(async move {
|
||||
let text = match rx.recv().await {
|
||||
Ok(Ok(Response::Share { entries, link })) => {
|
||||
sharing_summary(entries.len(), link.is_some())
|
||||
}
|
||||
_ => "Couldn't load sharing".to_string(),
|
||||
};
|
||||
if details_showing(&ui_c, &uid) {
|
||||
ui_c.details.details.sharing_row.set_subtitle(&text);
|
||||
}
|
||||
});
|
||||
|
||||
if entry.is_dir {
|
||||
return;
|
||||
}
|
||||
let req = if by_uid {
|
||||
Request::ListRevisionsByUid {
|
||||
uid: entry.uid.clone(),
|
||||
}
|
||||
} else {
|
||||
Request::ListRevisions { path: rel }
|
||||
};
|
||||
let rx = spawn_request(ui.dirs.control_socket(), req);
|
||||
let (ui_c, uid) = (ui.clone(), entry.uid.clone());
|
||||
glib::spawn_future_local(async move {
|
||||
let text = match rx.recv().await {
|
||||
Ok(Ok(Response::Revisions { items })) => versions_summary(&items),
|
||||
_ => "Couldn't load versions".to_string(),
|
||||
};
|
||||
if details_showing(&ui_c, &uid) {
|
||||
ui_c.details.details.versions_row.set_subtitle(&text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Hide the details pane and forget the entry it was showing, so a stale entry
|
||||
/// can't be acted on after the listing moves on.
|
||||
pub(crate) fn hide_details(ui: &Rc<Ui>) {
|
||||
ui.browser.split.set_show_sidebar(false);
|
||||
fn details_showing(ui: &Rc<Ui>, uid: &str) -> bool {
|
||||
ui.details
|
||||
.details_entry
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.is_some_and(|e| e.uid == uid)
|
||||
}
|
||||
|
||||
/// "Only you", "3 people", "Public link" or both.
|
||||
pub(crate) fn sharing_summary(people: usize, link: bool) -> String {
|
||||
let people = match people {
|
||||
0 => None,
|
||||
1 => Some("1 person".to_string()),
|
||||
n => Some(format!("{n} people")),
|
||||
};
|
||||
match (people, link) {
|
||||
(None, false) => "Only you".to_string(),
|
||||
(None, true) => "Anyone with the link".to_string(),
|
||||
(Some(p), false) => format!("Shared with {p}"),
|
||||
(Some(p), true) => format!("Shared with {p} · Public link"),
|
||||
}
|
||||
}
|
||||
|
||||
/// "3 versions · latest 2 Sep 2026".
|
||||
pub(crate) fn versions_summary(items: &[RevisionInfo]) -> String {
|
||||
let latest = items.iter().map(|r| r.created).max();
|
||||
match (items.len(), latest) {
|
||||
(0, _) | (_, None) => "No earlier versions".to_string(),
|
||||
(1, Some(t)) => format!("1 version · {}", format_modified(t)),
|
||||
(n, Some(t)) => format!("{n} versions · latest {}", format_modified(t)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the pane for `entry`, turning the header toggle on.
|
||||
pub(crate) fn open_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
ui.details.details.toggle.set_active(true);
|
||||
show_details(ui, entry);
|
||||
}
|
||||
|
||||
/// Alt+Enter: show or hide the pane for the selected entry.
|
||||
pub(crate) fn toggle_details(ui: &Rc<Ui>) {
|
||||
let toggle = &ui.details.details.toggle;
|
||||
toggle.set_active(!toggle.is_active());
|
||||
}
|
||||
|
||||
/// Forget the entry the pane was showing, so a stale entry can't be acted on
|
||||
/// after the listing moves on. An open pane stays open and says nothing is
|
||||
/// selected.
|
||||
pub(crate) fn clear_details(ui: &Rc<Ui>) {
|
||||
ui.details.details.pages.set_visible_child_name("empty");
|
||||
*ui.details.details_entry.borrow_mut() = None;
|
||||
}
|
||||
|
||||
@@ -293,3 +503,35 @@ pub(crate) fn hide_details(ui: &Rc<Ui>) {
|
||||
pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> {
|
||||
ui.details.details_entry.borrow().clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sharing_summary_names_people_and_link() {
|
||||
assert_eq!(sharing_summary(0, false), "Only you");
|
||||
assert_eq!(sharing_summary(0, true), "Anyone with the link");
|
||||
assert_eq!(sharing_summary(1, false), "Shared with 1 person");
|
||||
assert_eq!(
|
||||
sharing_summary(3, true),
|
||||
"Shared with 3 people · Public link"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_summary_counts_revisions() {
|
||||
let rev = |created| RevisionInfo {
|
||||
id: String::new(),
|
||||
is_active: false,
|
||||
created,
|
||||
size_on_storage: 0,
|
||||
claimed_size: None,
|
||||
claimed_modified: None,
|
||||
signed_by: None,
|
||||
has_thumbnails: false,
|
||||
};
|
||||
assert_eq!(versions_summary(&[]), "No earlier versions");
|
||||
assert!(versions_summary(&[rev(10), rev(20)]).starts_with("2 versions · latest "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use crate::*;
|
||||
|
||||
/// An action run by an [`ActionMenu`] item.
|
||||
pub(crate) type MenuAction = Box<dyn Fn()>;
|
||||
|
||||
/// A `gio::Menu` whose items run closures, for menus whose contents depend on
|
||||
/// what they were opened for (a row, a selection, a photo). Each item gets its
|
||||
/// own action in a private `menu` group, installed on whatever hosts the
|
||||
/// popover, so the result is a real [`gtk4::PopoverMenu`]: arrow keys, Enter,
|
||||
/// Escape and mnemonics work, and it reads as a menu to assistive technology.
|
||||
pub(crate) struct ActionMenu {
|
||||
menu: gio::Menu,
|
||||
section: gio::Menu,
|
||||
group: gio::SimpleActionGroup,
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl ActionMenu {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
menu: gio::Menu::new(),
|
||||
section: gio::Menu::new(),
|
||||
group: gio::SimpleActionGroup::new(),
|
||||
count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn next_action(&mut self) -> String {
|
||||
self.count += 1;
|
||||
format!("a{}", self.count)
|
||||
}
|
||||
|
||||
/// A plain item.
|
||||
pub(crate) fn item(&mut self, label: &str, run: impl Fn() + 'static) -> &mut Self {
|
||||
let name = self.next_action();
|
||||
let action = gio::SimpleAction::new(&name, None);
|
||||
action.connect_activate(move |_, _| run());
|
||||
self.group.add_action(&action);
|
||||
self.section
|
||||
.append(Some(label), Some(&format!("menu.{name}")));
|
||||
self
|
||||
}
|
||||
|
||||
/// A check item showing `active`; `run` gets the state the user asked for.
|
||||
pub(crate) fn toggle(
|
||||
&mut self,
|
||||
label: &str,
|
||||
active: bool,
|
||||
run: impl Fn(bool) + 'static,
|
||||
) -> &mut Self {
|
||||
let name = self.next_action();
|
||||
let action = gio::SimpleAction::new_stateful(&name, None, &active.to_variant());
|
||||
action.connect_activate(move |action, _| {
|
||||
let next = !action
|
||||
.state()
|
||||
.and_then(|s| s.get::<bool>())
|
||||
.unwrap_or(false);
|
||||
action.set_state(&next.to_variant());
|
||||
run(next);
|
||||
});
|
||||
self.group.add_action(&action);
|
||||
self.section
|
||||
.append(Some(label), Some(&format!("menu.{name}")));
|
||||
self
|
||||
}
|
||||
|
||||
/// Close the current section; the next item starts a new one below a
|
||||
/// separator. An empty section is skipped, so callers can end a group of
|
||||
/// conditional items unconditionally.
|
||||
pub(crate) fn section(&mut self) -> &mut Self {
|
||||
self.flush(None);
|
||||
self
|
||||
}
|
||||
|
||||
/// Close the current section under a heading.
|
||||
pub(crate) fn labelled_section(&mut self, heading: &str) -> &mut Self {
|
||||
self.flush(Some(heading));
|
||||
self
|
||||
}
|
||||
|
||||
fn flush(&mut self, heading: Option<&str>) {
|
||||
if self.section.n_items() > 0 {
|
||||
self.menu.append_section(heading, &self.section);
|
||||
self.section = gio::Menu::new();
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(mut self) -> (gio::Menu, gio::SimpleActionGroup) {
|
||||
self.flush(None);
|
||||
(self.menu, self.group)
|
||||
}
|
||||
|
||||
/// Pop the menu up over `anchor` at `(x, y)` in its coordinates. The
|
||||
/// popover removes itself once dismissed; it waits for an idle turn, so the
|
||||
/// item that closed it still finds its action.
|
||||
pub(crate) fn popup_at(self, anchor: &impl IsA<gtk4::Widget>, x: f64, y: f64) {
|
||||
let (menu, group) = self.finish();
|
||||
let anchor = anchor.as_ref();
|
||||
let host = popover_host(anchor);
|
||||
let (x, y) = anchor
|
||||
.compute_point(&host, >k4::graphene::Point::new(x as f32, y as f32))
|
||||
.map(|p| (p.x() as f64, p.y() as f64))
|
||||
.unwrap_or((x, y));
|
||||
let popover = gtk4::PopoverMenu::from_model(Some(&menu));
|
||||
popover.set_has_arrow(false);
|
||||
popover.set_position(gtk4::PositionType::Bottom);
|
||||
popover.set_halign(gtk4::Align::Start);
|
||||
popover.set_pointing_to(Some(>k4::gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
|
||||
popover.insert_action_group("menu", Some(&group));
|
||||
popover.set_parent(&host);
|
||||
popover.connect_closed(|p| {
|
||||
let p = p.clone();
|
||||
glib::idle_add_local_once(move || p.unparent());
|
||||
});
|
||||
popover.popup();
|
||||
}
|
||||
|
||||
/// A flat ⋮ button that opens this menu.
|
||||
pub(crate) fn button(self) -> gtk4::MenuButton {
|
||||
let (menu, group) = self.finish();
|
||||
let button = gtk4::MenuButton::builder()
|
||||
.icon_name("view-more-symbolic")
|
||||
.tooltip_text("More")
|
||||
.valign(gtk4::Align::Center)
|
||||
.menu_model(&menu)
|
||||
.build();
|
||||
button.insert_action_group("menu", Some(&group));
|
||||
button.add_css_class("flat");
|
||||
button
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a menu opened over `anchor` should live in the widget tree: the
|
||||
/// outermost grid or list view around it, or `anchor` itself. CSS descendant
|
||||
/// selectors reach through a popover to its parent, and themes style the
|
||||
/// insides of list items (`gridview > child box { margin: 12px }` in
|
||||
/// Catppuccin); a menu parented to a tile picks those rules up and puts a wide
|
||||
/// margin around every section. Under the view itself it is no item's child.
|
||||
fn popover_host(anchor: >k4::Widget) -> gtk4::Widget {
|
||||
let mut host = anchor.clone();
|
||||
let mut widget = anchor.parent();
|
||||
while let Some(w) = widget {
|
||||
widget = w.parent();
|
||||
if w.is::<gtk4::GridView>() || w.is::<gtk4::ListView>() || w.is::<gtk4::ColumnView>() {
|
||||
host = w;
|
||||
}
|
||||
}
|
||||
host
|
||||
}
|
||||
|
||||
/// A flat ⋮ button whose menu lists `items` as (label, action).
|
||||
pub(crate) fn more_menu_button(items: Vec<(&str, MenuAction)>) -> gtk4::MenuButton {
|
||||
let mut menu = ActionMenu::new();
|
||||
for (label, run) in items {
|
||||
menu.item(label, run);
|
||||
}
|
||||
menu.button()
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub(crate) mod details;
|
||||
pub(crate) mod menu;
|
||||
pub(crate) mod share_dialog;
|
||||
pub(crate) mod thumbnails;
|
||||
pub(crate) mod versions_dialog;
|
||||
@@ -21,6 +21,99 @@ pub(crate) fn role_wire_to_index(role: &str) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The expiry choices offered when creating a public link, in dropdown order,
|
||||
/// with their lifetime in days (`None` never expires).
|
||||
pub(crate) const LINK_EXPIRY: [(&str, Option<i64>); 4] = [
|
||||
("Never", None),
|
||||
("1 day", Some(1)),
|
||||
("7 days", Some(7)),
|
||||
("30 days", Some(30)),
|
||||
];
|
||||
|
||||
/// The Unix expiry for a [`LINK_EXPIRY`] choice, counted from `now`.
|
||||
pub(crate) fn link_expiry_at(idx: u32, now: i64) -> Option<i64> {
|
||||
LINK_EXPIRY
|
||||
.get(idx as usize)
|
||||
.and_then(|(_, days)| *days)
|
||||
.map(|days| now + days * 86_400)
|
||||
}
|
||||
|
||||
/// A loose shape check for an email address: one `@` with something before
|
||||
/// it and a dotted domain after it. The server does the real validation; this
|
||||
/// only catches typos before the Invite button is pressed.
|
||||
pub(crate) fn looks_like_email(s: &str) -> bool {
|
||||
let Some((local, domain)) = s.split_once('@') else {
|
||||
return false;
|
||||
};
|
||||
!local.is_empty()
|
||||
&& !domain.contains('@')
|
||||
&& !s.chars().any(char::is_whitespace)
|
||||
&& domain
|
||||
.split_once('.')
|
||||
.is_some_and(|(host, rest)| !host.is_empty() && !rest.is_empty())
|
||||
&& !domain.ends_with('.')
|
||||
}
|
||||
|
||||
/// Split the invite field into addresses. Returns `Err` with the first entry
|
||||
/// that doesn't look like an email, and `Ok` with an empty list when the field
|
||||
/// is blank.
|
||||
pub(crate) fn parse_emails(raw: &str) -> Result<Vec<String>, String> {
|
||||
raw.split([',', ' ', ';'])
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| {
|
||||
if looks_like_email(s) {
|
||||
Ok(s.to_string())
|
||||
} else {
|
||||
Err(s.to_string())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A button showing a spinner while its request runs. It goes insensitive
|
||||
/// on [`Busy::start`] and gets its label or icon back when dropped, so moving
|
||||
/// the guard into the request's future ends the busy state on every path.
|
||||
pub(crate) struct Busy {
|
||||
button: gtk4::Button,
|
||||
label: Option<glib::GString>,
|
||||
icon: Option<glib::GString>,
|
||||
}
|
||||
|
||||
impl Busy {
|
||||
pub(crate) fn start(button: >k4::Button) -> Self {
|
||||
let label = button.label();
|
||||
let icon = button.icon_name();
|
||||
let spinner = gtk4::Spinner::builder().spinning(true).build();
|
||||
match &label {
|
||||
Some(text) => {
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
||||
content.append(&spinner);
|
||||
content.append(>k4::Label::new(Some(text)));
|
||||
button.set_child(Some(&content));
|
||||
}
|
||||
None => button.set_child(Some(&spinner)),
|
||||
}
|
||||
button.set_sensitive(false);
|
||||
Self {
|
||||
button: button.clone(),
|
||||
label,
|
||||
icon,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Busy {
|
||||
fn drop(&mut self) {
|
||||
if let Some(label) = &self.label {
|
||||
self.button.set_label(label);
|
||||
} else if let Some(icon) = &self.icon {
|
||||
self.button.set_icon_name(icon);
|
||||
}
|
||||
self.button.set_sensitive(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// How an open Share dialog addresses its node.
|
||||
///
|
||||
/// A node the browser is showing has a mountpoint-relative path. A node reached
|
||||
@@ -94,19 +187,19 @@ impl ShareTarget {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_link(&self, role: String, password: Option<String>) -> Request {
|
||||
fn create_link(&self, role: String, password: Option<String>, expires: Option<i64>) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::CreatePublicLink {
|
||||
path: path.clone(),
|
||||
role,
|
||||
password,
|
||||
expires: None,
|
||||
expires,
|
||||
},
|
||||
ShareTarget::Uid(uid) => Request::CreatePublicLinkByUid {
|
||||
uid: uid.clone(),
|
||||
role,
|
||||
password,
|
||||
expires: None,
|
||||
expires,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -179,6 +272,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
.margin_top(6)
|
||||
.build();
|
||||
invite_btn.add_css_class("suggested-action");
|
||||
invite_btn.set_sensitive(false);
|
||||
let invite_wrap = adw::PreferencesRow::builder()
|
||||
.activatable(false)
|
||||
.child(&invite_btn)
|
||||
@@ -227,6 +321,25 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
link_rows: RefCell::new(Vec::new()),
|
||||
});
|
||||
|
||||
// Check the addresses as they are typed: a malformed one marks the row as
|
||||
// an error and keeps the Invite button off, so a typo never reaches the
|
||||
// server as a failed invitation.
|
||||
let btn = invite_btn.clone();
|
||||
email_row.connect_changed(move |row| {
|
||||
let parsed = parse_emails(&row.text());
|
||||
match &parsed {
|
||||
Err(bad) => {
|
||||
row.add_css_class("error");
|
||||
row.set_tooltip_text(Some(&format!("“{bad}” isn't an email address")));
|
||||
}
|
||||
Ok(_) => {
|
||||
row.remove_css_class("error");
|
||||
row.set_tooltip_text(None);
|
||||
}
|
||||
}
|
||||
btn.set_sensitive(parsed.is_ok_and(|emails| !emails.is_empty()));
|
||||
});
|
||||
|
||||
// Enter in either free-text field sends the invitations, no mouse needed.
|
||||
let btn = invite_btn.clone();
|
||||
email_row.connect_entry_activated(move |_| btn.emit_clicked());
|
||||
@@ -235,16 +348,15 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
|
||||
// Invite button.
|
||||
let state_inv = state.clone();
|
||||
invite_btn.connect_clicked(move |_| {
|
||||
let raw = email_row.text();
|
||||
let emails: Vec<String> = raw
|
||||
.split([',', ' ', ';'])
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
invite_btn.connect_clicked(move |btn| {
|
||||
// Enter in a field reaches here even while the button is off.
|
||||
if !btn.is_sensitive() {
|
||||
return;
|
||||
}
|
||||
let Ok(emails) = parse_emails(&email_row.text()) else {
|
||||
return;
|
||||
};
|
||||
if emails.is_empty() {
|
||||
toast_error(&state_inv.ui, "Couldn't share", "Enter at least one email.");
|
||||
return;
|
||||
}
|
||||
let role = role_index_to_wire(role_drop.selected()).to_string();
|
||||
@@ -257,6 +369,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
state_inv.target.invite(emails, role, message),
|
||||
"Invitations sent",
|
||||
"Couldn't send invitations",
|
||||
Some(Busy::start(btn)),
|
||||
Some(Box::new(move || {
|
||||
email_clear.set_text("");
|
||||
msg_clear.set_text("");
|
||||
@@ -340,6 +453,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
"Role updated",
|
||||
"Couldn't update the role",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
});
|
||||
row.add_suffix(&drop);
|
||||
@@ -361,13 +475,26 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
let state_rm = state.clone();
|
||||
let id = entry.id.clone();
|
||||
let kind = entry.kind;
|
||||
remove.connect_clicked(move |_| {
|
||||
share_dialog_op(
|
||||
&state_rm,
|
||||
state_rm.target.remove_entry(id.clone(), kind),
|
||||
"Access removed",
|
||||
"Couldn't remove access",
|
||||
None,
|
||||
let who = entry.email.clone();
|
||||
remove.connect_clicked(move |btn| {
|
||||
let state = state_rm.clone();
|
||||
let id = id.clone();
|
||||
let button = btn.clone();
|
||||
confirm_destructive(
|
||||
btn,
|
||||
"Remove Access?",
|
||||
&format!("{who} will no longer be able to open this item."),
|
||||
"Remove",
|
||||
move || {
|
||||
share_dialog_op(
|
||||
&state,
|
||||
state.target.remove_entry(id.clone(), kind),
|
||||
"Access removed",
|
||||
"Couldn't remove access",
|
||||
Some(Busy::start(&button)),
|
||||
None,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
row.add_suffix(&remove);
|
||||
@@ -389,11 +516,14 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
match link {
|
||||
Some(link) => {
|
||||
let url = link.url.clone().unwrap_or_default();
|
||||
let subtitle = if link.has_password {
|
||||
format!("Anyone with the link ({}) · password-protected", link.role)
|
||||
} else {
|
||||
format!("Anyone with the link ({})", link.role)
|
||||
};
|
||||
let mut subtitle = format!("Anyone with the link ({})", capitalize(&link.role));
|
||||
if link.has_password {
|
||||
subtitle.push_str(" · password-protected");
|
||||
}
|
||||
if let Some(expires) = link.expires {
|
||||
subtitle.push_str(" · ");
|
||||
subtitle.push_str(&link_expiry_label(expires, glib::real_time() / 1_000_000));
|
||||
}
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(if url.is_empty() { "Public link" } else { &url })
|
||||
.subtitle(&subtitle)
|
||||
@@ -424,13 +554,26 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
remove.add_css_class("flat");
|
||||
let state_rm = state.clone();
|
||||
let id = link.id.clone();
|
||||
remove.connect_clicked(move |_| {
|
||||
share_dialog_op(
|
||||
&state_rm,
|
||||
state_rm.target.remove_link(id.clone()),
|
||||
"Public link removed",
|
||||
"Couldn't remove the link",
|
||||
None,
|
||||
remove.connect_clicked(move |btn| {
|
||||
let state = state_rm.clone();
|
||||
let id = id.clone();
|
||||
let button = btn.clone();
|
||||
confirm_destructive(
|
||||
btn,
|
||||
"Remove Public Link?",
|
||||
"The link stops working for everyone who has it. A new link will have \
|
||||
a different address.",
|
||||
"Remove Link",
|
||||
move || {
|
||||
share_dialog_op(
|
||||
&state,
|
||||
state.target.remove_link(id.clone()),
|
||||
"Public link removed",
|
||||
"Couldn't remove the link",
|
||||
Some(Busy::start(&button)),
|
||||
None,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
row.add_suffix(&remove);
|
||||
@@ -450,6 +593,14 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
let pw_row = adw::PasswordEntryRow::builder()
|
||||
.title("Password (optional)")
|
||||
.build();
|
||||
let expiry_names: Vec<&str> = LINK_EXPIRY.iter().map(|(name, _)| *name).collect();
|
||||
let expiry_drop = gtk4::DropDown::builder()
|
||||
.model(>k4::StringList::new(&expiry_names))
|
||||
.selected(0)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let expiry_row = adw::ActionRow::builder().title("Expires").build();
|
||||
expiry_row.add_suffix(&expiry_drop);
|
||||
let create = gtk4::Button::builder()
|
||||
.label("Create Public Link")
|
||||
.halign(gtk4::Align::End)
|
||||
@@ -467,7 +618,10 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
|
||||
let state_c = state.clone();
|
||||
let pw_for = pw_row.clone();
|
||||
create.connect_clicked(move |_| {
|
||||
create.connect_clicked(move |btn| {
|
||||
if !btn.is_sensitive() {
|
||||
return;
|
||||
}
|
||||
let role = if role_drop.selected() == 1 {
|
||||
"editor"
|
||||
} else {
|
||||
@@ -476,14 +630,17 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
.to_string();
|
||||
let pw = pw_for.text().to_string();
|
||||
let password = if pw.is_empty() { None } else { Some(pw) };
|
||||
share_dialog_create_link(&state_c, role, password);
|
||||
let expires = link_expiry_at(expiry_drop.selected(), glib::real_time() / 1_000_000);
|
||||
share_dialog_create_link(&state_c, role, password, expires, Busy::start(btn));
|
||||
});
|
||||
|
||||
state.link_group.add(&role_row);
|
||||
state.link_group.add(&pw_row);
|
||||
state.link_group.add(&expiry_row);
|
||||
state.link_group.add(&create_wrap);
|
||||
rows.push(role_row.upcast());
|
||||
rows.push(pw_row.upcast());
|
||||
rows.push(expiry_row.upcast());
|
||||
rows.push(create_wrap.upcast());
|
||||
}
|
||||
}
|
||||
@@ -496,14 +653,18 @@ pub(crate) fn share_dialog_create_link(
|
||||
state: &Rc<ShareDialog>,
|
||||
role: String,
|
||||
password: Option<String>,
|
||||
expires: Option<i64>,
|
||||
busy: Busy,
|
||||
) {
|
||||
let rx = spawn_request(
|
||||
state.ui.dirs.control_socket(),
|
||||
state.target.create_link(role, password),
|
||||
state.target.create_link(role, password, expires),
|
||||
);
|
||||
let state = state.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
let reply = rx.recv().await;
|
||||
drop(busy);
|
||||
match reply {
|
||||
Ok(Ok(Response::PublicLink { .. })) => {
|
||||
toast(&state.ui, "Public link created");
|
||||
share_dialog_reload(&state);
|
||||
@@ -521,18 +682,22 @@ pub(crate) fn share_dialog_create_link(
|
||||
}
|
||||
|
||||
/// Run a Share-dialog mutation, then reload the dialog on success. `on_success`
|
||||
/// runs an extra UI tweak (e.g. clearing the invite fields) before the reload.
|
||||
/// runs an extra UI tweak (e.g. clearing the invite fields) before the reload;
|
||||
/// `busy` keeps the button that started it spinning until the reply arrives.
|
||||
pub(crate) fn share_dialog_op(
|
||||
state: &Rc<ShareDialog>,
|
||||
req: Request,
|
||||
done: &'static str,
|
||||
failed: &'static str,
|
||||
busy: Option<Busy>,
|
||||
on_success: Option<Box<dyn Fn()>>,
|
||||
) {
|
||||
let rx = spawn_request(state.ui.dirs.control_socket(), req);
|
||||
let state = state.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
let reply = rx.recv().await;
|
||||
drop(busy);
|
||||
match reply {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
if let Some(cb) = on_success {
|
||||
cb();
|
||||
@@ -550,3 +715,61 @@ pub(crate) fn share_dialog_op(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// How an existing link's expiry reads in its subtitle: the date it stops
|
||||
/// working, or that it already has.
|
||||
pub(crate) fn link_expiry_label(expires: i64, now: i64) -> String {
|
||||
if expires <= now {
|
||||
return "expired".to_string();
|
||||
}
|
||||
let date = glib::DateTime::from_unix_local(expires)
|
||||
.ok()
|
||||
.and_then(|at| at.format("%x").ok())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
format!("expires {date}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn email_check_accepts_plain_addresses() {
|
||||
assert!(looks_like_email("a@b.co"));
|
||||
assert!(looks_like_email("first.last+tag@mail.example.org"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_check_rejects_typos() {
|
||||
for bad in [
|
||||
"a", "a@", "@b.co", "a@b", "a@b.", "a@.co", "a@@b.co", "a@b@c.co",
|
||||
] {
|
||||
assert!(!looks_like_email(bad), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_emails_splits_and_reports_first_bad_entry() {
|
||||
assert_eq!(
|
||||
parse_emails("a@b.co, c@d.org;e@f.net"),
|
||||
Ok(vec!["a@b.co".into(), "c@d.org".into(), "e@f.net".into()])
|
||||
);
|
||||
assert_eq!(parse_emails(" , "), Ok(vec![]));
|
||||
assert_eq!(parse_emails("a@b.co oops"), Err("oops".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_expiry_counts_days_from_now() {
|
||||
assert_eq!(link_expiry_at(0, 1_000), None);
|
||||
assert_eq!(link_expiry_at(1, 1_000), Some(1_000 + 86_400));
|
||||
assert_eq!(link_expiry_at(3, 0), Some(30 * 86_400));
|
||||
assert_eq!(link_expiry_at(99, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn past_expiry_reads_expired() {
|
||||
assert_eq!(link_expiry_label(10, 20), "expired");
|
||||
assert!(link_expiry_label(2_000_000_000, 0).starts_with("expires "));
|
||||
}
|
||||
}
|
||||
@@ -182,16 +182,20 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
||||
.filter(|size| *size >= 0)
|
||||
.unwrap_or(item.size_on_storage)
|
||||
.max(0) as u64;
|
||||
let mut subtitle = format!("{} · {}", activity_time(item.created), human_bytes(size));
|
||||
// A revision id means nothing to a person; name earlier versions by
|
||||
// when they were saved instead.
|
||||
let (title, mut subtitle) = if item.is_active {
|
||||
(
|
||||
"Current version".to_string(),
|
||||
format!("{} · {}", activity_time(item.created), human_bytes(size)),
|
||||
)
|
||||
} else {
|
||||
(activity_time(item.created), human_bytes(size))
|
||||
};
|
||||
if let Some(email) = &item.signed_by {
|
||||
subtitle.push_str(" · ");
|
||||
subtitle.push_str(email);
|
||||
}
|
||||
let title = if item.is_active {
|
||||
"Current version".to_string()
|
||||
} else {
|
||||
format!("Version {}", item.id)
|
||||
};
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(title)
|
||||
.subtitle(subtitle)
|
||||
|
||||
@@ -142,6 +142,7 @@ mod tests {
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
uid: "uid".into(),
|
||||
mounted_path: None,
|
||||
score: 0,
|
||||
|
||||
@@ -268,6 +268,7 @@ mod tests {
|
||||
size: 7,
|
||||
modified: 9,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
uid: "uid~1".into(),
|
||||
mounted_path: None,
|
||||
score: 42,
|
||||
|
||||
+258
-81
@@ -1,14 +1,16 @@
|
||||
//! `pdfs-tray` — system-tray front-end for the Proton Drive Linux client.
|
||||
//!
|
||||
//! Slice 1 of the GUI phase: a StatusNotifierItem (SNI) tray icon with a menu
|
||||
//! showing login/mount status and offering mount, unmount, open-folder and quit.
|
||||
//! It talks to the running mount daemon over the same control socket the CLI
|
||||
//! uses, so the tray is a thin presentation layer over [`pdfs_core::control`].
|
||||
//! A StatusNotifierItem (SNI) tray icon whose icon and tooltip say what the
|
||||
//! drive is doing (synced, syncing, paused, offline, needs attention), with a
|
||||
//! menu to open the app or the folder, pause sync, connect, hide the icon and
|
||||
//! stop Proton Drive. It talks to the running mount daemon over the same
|
||||
//! control socket the CLI uses, so the tray is a thin presentation layer over
|
||||
//! [`pdfs_core::control`]. Anything that needs a dialog opens `pdfs-app`.
|
||||
//!
|
||||
//! GTK4 has no tray widget (GNOME dropped the systray), so the tray itself is an
|
||||
//! SNI item via the `ksni` crate; the GTK4/libadwaita settings window arrives in
|
||||
//! a later slice.
|
||||
//! SNI item via the `ksni` crate.
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
@@ -16,7 +18,7 @@ use std::time::Duration;
|
||||
use anyhow::{Context, Result};
|
||||
use ksni::blocking::TrayMethods;
|
||||
use ksni::menu::StandardItem;
|
||||
use ksni::{MenuItem, Tray};
|
||||
use ksni::{MenuItem, Status, ToolTip, Tray};
|
||||
use pdfs_core::auth;
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
@@ -27,11 +29,45 @@ use pdfs_core::service;
|
||||
/// How often the tray re-polls the daemon to refresh its menu.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
||||
|
||||
/// The one word the icon says about the drive, derived from each poll.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Phase {
|
||||
/// Mounted, online, nothing queued.
|
||||
Synced,
|
||||
/// Mounted with changes queued or bytes moving.
|
||||
Syncing,
|
||||
Paused,
|
||||
/// Mounted but the API is unreachable: cached files only.
|
||||
Offline,
|
||||
/// Changes keep failing; the user has to look.
|
||||
Attention,
|
||||
/// Signed in, but no mount daemon is running.
|
||||
Disconnected,
|
||||
SignedOut,
|
||||
}
|
||||
|
||||
impl Phase {
|
||||
/// A stock symbolic icon for the phase. Stock names follow the panel's
|
||||
/// theme and colour, and need nothing installed beside the binary.
|
||||
fn icon(self) -> &'static str {
|
||||
match self {
|
||||
Phase::Synced => "folder-remote-symbolic",
|
||||
Phase::Syncing => "emblem-synchronizing-symbolic",
|
||||
Phase::Paused => "media-playback-pause-symbolic",
|
||||
Phase::Offline => "network-offline-symbolic",
|
||||
Phase::Attention => "dialog-warning-symbolic",
|
||||
Phase::Disconnected | Phase::SignedOut => "network-offline-symbolic",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of what the tray knows about the daemon, recomputed each poll.
|
||||
#[derive(Clone)]
|
||||
struct DriveState {
|
||||
/// Human-readable first menu line ("Mounted at …", "Not running", …).
|
||||
/// Human-readable first menu line ("Mounted at …", "Not running", …), also
|
||||
/// the tooltip.
|
||||
line: String,
|
||||
phase: Phase,
|
||||
/// Whether a mount daemon is currently serving the control socket.
|
||||
mounted: bool,
|
||||
/// Mountpoint to act on (from the daemon when mounted, else the default).
|
||||
@@ -39,6 +75,10 @@ struct DriveState {
|
||||
/// One-line sync summary of in-flight transfers, empty when idle. Shown as a
|
||||
/// disabled menu line under the status line.
|
||||
sync: String,
|
||||
/// Whether the user has sync paused; decides Pause vs Resume.
|
||||
paused: bool,
|
||||
/// Queued changes that keep failing, for the "N issues" item.
|
||||
failing: u64,
|
||||
}
|
||||
|
||||
/// Summarise a work snapshot into one menu line, or empty when idle. A single
|
||||
@@ -82,6 +122,7 @@ fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
|
||||
|
||||
struct DriveTray {
|
||||
state: DriveState,
|
||||
socket: PathBuf,
|
||||
}
|
||||
|
||||
/// Ask the daemon for its status, falling back to the stored session so the
|
||||
@@ -94,64 +135,130 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
online,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
failing_ops,
|
||||
paused,
|
||||
..
|
||||
}) => DriveState {
|
||||
line: match (online, pending_summary(pending_uploads, pending_changes)) {
|
||||
(true, None) => format!("Mounted at {mountpoint} ({pinned} pinned)"),
|
||||
(true, Some(q)) => format!("Syncing — {q} ({pinned} pinned)"),
|
||||
(false, None) => format!("Offline — cached files only ({pinned} pinned)"),
|
||||
(false, Some(q)) => format!("Offline — {q}"),
|
||||
},
|
||||
mounted: true,
|
||||
mountpoint: PathBuf::from(mountpoint),
|
||||
}) => {
|
||||
// Same daemon is up, so a cheap follow-up poll gives the sync line.
|
||||
sync: match send(socket, &Request::GetQueueStatus) {
|
||||
let sync = match send(socket, &Request::GetQueueStatus) {
|
||||
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
|
||||
_ => String::new(),
|
||||
},
|
||||
},
|
||||
};
|
||||
let queued = pending_summary(pending_uploads, pending_changes);
|
||||
let phase = phase_of(
|
||||
paused,
|
||||
failing_ops,
|
||||
online,
|
||||
queued.is_some() || !sync.is_empty(),
|
||||
);
|
||||
DriveState {
|
||||
line: match (online, queued) {
|
||||
_ if paused => "Sync paused".to_string(),
|
||||
_ if failing_ops > 0 => match failing_ops {
|
||||
1 => "1 change needs attention".to_string(),
|
||||
n => format!("{n} changes need attention"),
|
||||
},
|
||||
(true, None) => format!("Mounted at {mountpoint} ({pinned} pinned)"),
|
||||
(true, Some(q)) => format!("Syncing — {q} ({pinned} pinned)"),
|
||||
(false, None) => format!("Offline — cached files only ({pinned} pinned)"),
|
||||
(false, Some(q)) => format!("Offline — {q}"),
|
||||
},
|
||||
phase,
|
||||
mounted: true,
|
||||
paused,
|
||||
mountpoint: PathBuf::from(mountpoint),
|
||||
sync,
|
||||
failing: failing_ops,
|
||||
}
|
||||
}
|
||||
// Socket answered but with something unexpected — treat as up but odd.
|
||||
Ok(_) => DriveState {
|
||||
line: "Mount: unexpected daemon response".into(),
|
||||
phase: Phase::Attention,
|
||||
mounted: true,
|
||||
mountpoint: default_mountpoint.to_path_buf(),
|
||||
sync: String::new(),
|
||||
paused: false,
|
||||
failing: 0,
|
||||
},
|
||||
// No daemon: describe login state instead so the menu is still useful.
|
||||
Err(_) => {
|
||||
let line = match auth::load() {
|
||||
Ok(s) => format!("Logged in as {} — not mounted", s.username),
|
||||
Err(pdfs_core::Error::NotLoggedIn) => "Not logged in".into(),
|
||||
Err(e) => format!("Error: {e}"),
|
||||
let (line, phase) = match auth::load() {
|
||||
Ok(s) => (
|
||||
format!("Logged in as {} — not mounted", s.username),
|
||||
Phase::Disconnected,
|
||||
),
|
||||
Err(pdfs_core::Error::NotLoggedIn) => ("Not signed in".into(), Phase::SignedOut),
|
||||
Err(e) => (format!("Error: {e}"), Phase::Attention),
|
||||
};
|
||||
DriveState {
|
||||
line,
|
||||
phase,
|
||||
mounted: false,
|
||||
mountpoint: default_mountpoint.to_path_buf(),
|
||||
sync: String::new(),
|
||||
paused: false,
|
||||
failing: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The phase a running mount is in. A pause is the user's own doing and says
|
||||
/// so first; failures outrank everything else the daemon reports.
|
||||
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
|
||||
if paused {
|
||||
Phase::Paused
|
||||
} else if failing > 0 {
|
||||
Phase::Attention
|
||||
} else if !online {
|
||||
Phase::Offline
|
||||
} else if busy {
|
||||
Phase::Syncing
|
||||
} else {
|
||||
Phase::Synced
|
||||
}
|
||||
}
|
||||
|
||||
/// "3 issues — View": the menu item that opens the Sync page's queue.
|
||||
fn issues_label(failing: u64) -> String {
|
||||
match failing {
|
||||
1 => "1 issue — View".to_string(),
|
||||
n => format!("{n} issues — View"),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_folder(mountpoint: &Path) {
|
||||
pdfs_core::opener::open_default(mountpoint, true);
|
||||
}
|
||||
|
||||
/// Launch (or, since it's `SingleMainWindow`, raise) the settings/management
|
||||
/// window. The app binary lives next to the tray binary; fall back to the bare
|
||||
/// Launch (or, since it's `SingleMainWindow`, raise) the desktop app with
|
||||
/// `args`. The app binary lives next to the tray binary; fall back to the bare
|
||||
/// name so a PATH install still works.
|
||||
fn open_manager() {
|
||||
fn open_manager(args: &[&str]) {
|
||||
let exe = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join("pdfs-app")))
|
||||
.filter(|p| p.exists())
|
||||
.unwrap_or_else(|| PathBuf::from("pdfs-app"));
|
||||
if let Err(e) = Command::new(&exe).spawn() {
|
||||
if let Err(e) = Command::new(&exe).args(args).spawn() {
|
||||
tracing::error!("failed to launch {}: {e}", exe.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember that the user hid the icon, so the next login's autostart and the
|
||||
/// app both leave it away, then exit.
|
||||
fn hide_tray() {
|
||||
if let Ok(dirs) = AppDirs::new() {
|
||||
let mut config = dirs.load_config();
|
||||
config.tray_hidden = true;
|
||||
if let Err(e) = dirs.save_config(&config) {
|
||||
tracing::warn!("hide tray icon: {e}");
|
||||
}
|
||||
}
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
impl Tray for DriveTray {
|
||||
fn id(&self) -> String {
|
||||
"io.narl.proton-drive-linux".into()
|
||||
@@ -162,91 +269,121 @@ impl Tray for DriveTray {
|
||||
}
|
||||
|
||||
fn icon_name(&self) -> String {
|
||||
"folder-remote".into()
|
||||
self.state.phase.icon().into()
|
||||
}
|
||||
|
||||
fn status(&self) -> Status {
|
||||
match self.state.phase {
|
||||
Phase::Attention => Status::NeedsAttention,
|
||||
_ => Status::Active,
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_tip(&self) -> ToolTip {
|
||||
let mut description = self.state.line.clone();
|
||||
if !self.state.sync.is_empty() {
|
||||
description = format!("{description}\n{}", self.state.sync);
|
||||
}
|
||||
ToolTip {
|
||||
title: "Proton Drive".into(),
|
||||
description,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A left click opens the app, as it does for most tray icons.
|
||||
fn activate(&mut self, _x: i32, _y: i32) {
|
||||
open_manager(&[]);
|
||||
}
|
||||
|
||||
fn menu(&self) -> Vec<MenuItem<Self>> {
|
||||
// Value captured into the action closure (which only gets `&mut Self`).
|
||||
let open_mp = self.state.mountpoint.clone();
|
||||
|
||||
let mut items: Vec<MenuItem<Self>> = vec![
|
||||
let label = |text: &str| -> MenuItem<Self> {
|
||||
StandardItem {
|
||||
label: self.state.line.clone(),
|
||||
label: text.to_string(),
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
];
|
||||
|
||||
// Live sync status, shown only while something is transferring.
|
||||
if !self.state.sync.is_empty() {
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: self.state.sync.clone(),
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
items.extend([
|
||||
MenuItem::Separator,
|
||||
.into()
|
||||
};
|
||||
let item = |text: &str, run: fn(&mut Self)| -> MenuItem<Self> {
|
||||
StandardItem {
|
||||
label: "Open Manager".into(),
|
||||
activate: Box::new(|_: &mut Self| open_manager()),
|
||||
label: text.to_string(),
|
||||
activate: Box::new(run),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
]);
|
||||
.into()
|
||||
};
|
||||
|
||||
let mut items: Vec<MenuItem<Self>> = vec![label(&self.state.line)];
|
||||
// Live sync status, shown only while something is transferring.
|
||||
if !self.state.sync.is_empty() {
|
||||
items.push(label(&self.state.sync));
|
||||
}
|
||||
if self.state.failing > 0 {
|
||||
items.push(item(&issues_label(self.state.failing), |_| {
|
||||
open_manager(&["--page", "locations"])
|
||||
}));
|
||||
}
|
||||
items.push(MenuItem::Separator);
|
||||
|
||||
match self.state.phase {
|
||||
// Nothing to mount without a session; the app's sign-in page is the
|
||||
// way in.
|
||||
Phase::SignedOut => items.push(item("Sign In…", |_| open_manager(&[]))),
|
||||
_ => items.push(item("Open Proton Drive", |_| open_manager(&[]))),
|
||||
}
|
||||
|
||||
if self.state.mounted {
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: "Open folder".into(),
|
||||
label: "Open Folder".into(),
|
||||
activate: Box::new(move |_: &mut Self| open_folder(&open_mp)),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
// Stop the systemd service (clean SIGTERM → lazy unmount). The next
|
||||
// login or reboot brings it back; this is a deliberate disconnect.
|
||||
// Pause holds uploads back but leaves the mount readable, so it
|
||||
// sits apart from Stop, which takes the whole mount away.
|
||||
let paused = self.state.paused;
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: "Disconnect".into(),
|
||||
label: if paused { "Resume Sync" } else { "Pause Sync" }.into(),
|
||||
activate: Box::new(move |this: &mut Self| {
|
||||
service::stop();
|
||||
this.state.mounted = false;
|
||||
this.state.line = "Disconnecting…".into();
|
||||
let request = Request::SetSyncPaused {
|
||||
paused: !paused,
|
||||
until: None,
|
||||
};
|
||||
match send(&this.socket, &request) {
|
||||
Ok(Response::Ok { .. }) => this.state.paused = !paused,
|
||||
Ok(other) => tracing::warn!("pause sync: {other:?}"),
|
||||
Err(e) => tracing::warn!("pause sync: {e}"),
|
||||
}
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
} else if self.state.phase == Phase::Disconnected {
|
||||
// Enable+start the service so it mounts now and on future logins.
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: "Connect".into(),
|
||||
activate: Box::new(|this: &mut Self| {
|
||||
service::enable_start();
|
||||
this.state.line = "Connecting…".into();
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
items.push(item("Connect", |this| {
|
||||
service::enable_start();
|
||||
this.state.line = "Connecting…".into();
|
||||
}));
|
||||
}
|
||||
|
||||
items.push(MenuItem::Separator);
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: "Quit".into(),
|
||||
activate: Box::new(|_: &mut Self| std::process::exit(0)),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
items.push(item("Hide Tray Icon", |_| hide_tray()));
|
||||
if self.state.mounted {
|
||||
// Stopping unmounts the drive, so the app asks first; the tray has no
|
||||
// GTK of its own to ask with.
|
||||
items.push(item("Stop Proton Drive…", |_| {
|
||||
open_manager(&["--confirm-stop"])
|
||||
}));
|
||||
} else {
|
||||
items.push(item("Quit", |_| std::process::exit(0)));
|
||||
}
|
||||
items
|
||||
}
|
||||
}
|
||||
@@ -259,6 +396,12 @@ fn main() -> Result<()> {
|
||||
.init();
|
||||
|
||||
let dirs = AppDirs::new().context("resolve app dirs")?;
|
||||
// Hidden from its own menu: the session's autostart still runs the tray, and
|
||||
// this is where that choice is kept.
|
||||
if dirs.load_config().tray_hidden {
|
||||
tracing::info!("tray icon is hidden in the config; exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Ensure only one instance of the tray runs.
|
||||
let tray_sock = dirs.tray_socket();
|
||||
@@ -267,7 +410,7 @@ fn main() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = std::fs::remove_file(&tray_sock);
|
||||
let _lock_socket = std::os::unix::net::UnixListener::bind(&tray_sock)
|
||||
let lock_socket = std::os::unix::net::UnixListener::bind(&tray_sock)
|
||||
.context("failed to bind tray single-instance socket")?;
|
||||
// Owner-only like the control socket (B6). This one only guards single
|
||||
// instancing, so a failure is logged rather than fatal — but there is no
|
||||
@@ -276,11 +419,25 @@ fn main() -> Result<()> {
|
||||
tracing::warn!(error = %e, "could not restrict tray socket permissions");
|
||||
}
|
||||
|
||||
// The app asks the tray to leave through its socket, when the user hides
|
||||
// the icon from Preferences or stops Proton Drive.
|
||||
std::thread::spawn(move || {
|
||||
for stream in lock_socket.incoming().flatten() {
|
||||
let mut line = String::new();
|
||||
if BufReader::new(stream).read_line(&mut line).is_ok()
|
||||
&& line.trim() == pdfs_core::tray::QUIT
|
||||
{
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let socket = dirs.control_socket();
|
||||
let default_mountpoint = dirs.default_mountpoint();
|
||||
|
||||
let tray = DriveTray {
|
||||
state: poll_state(&socket, &default_mountpoint),
|
||||
socket: socket.clone(),
|
||||
};
|
||||
|
||||
// The tray autostarts with the session and can beat the desktop's SNI
|
||||
@@ -299,3 +456,23 @@ fn main() -> Result<()> {
|
||||
handle.update(move |t: &mut DriveTray| t.state = st);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_pause_outranks_failures_and_failures_outrank_the_rest() {
|
||||
assert_eq!(phase_of(true, 3, false, true), Phase::Paused);
|
||||
assert_eq!(phase_of(false, 3, false, true), Phase::Attention);
|
||||
assert_eq!(phase_of(false, 0, false, true), Phase::Offline);
|
||||
assert_eq!(phase_of(false, 0, true, true), Phase::Syncing);
|
||||
assert_eq!(phase_of(false, 0, true, false), Phase::Synced);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_issues_item_counts_in_the_singular_and_plural() {
|
||||
assert_eq!(issues_label(1), "1 issue — View");
|
||||
assert_eq!(issues_label(4), "4 issues — View");
|
||||
}
|
||||
}
|
||||
@@ -908,6 +908,7 @@ impl Ui {
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: true,
|
||||
cached: pin.cached,
|
||||
uid: pin.uid,
|
||||
mounted_path: None,
|
||||
score: 0,
|
||||
@@ -1613,6 +1614,7 @@ mod tests {
|
||||
size: 1,
|
||||
modified: 1,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
uid: "v~a".into(),
|
||||
mounted_path: None,
|
||||
score: 100,
|
||||
|
||||
@@ -45,6 +45,7 @@ pub(crate) fn pins(socket: &Path) -> Result<Vec<Hit>, String> {
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: true,
|
||||
cached: pin.cached,
|
||||
uid: pin.uid,
|
||||
mounted_path: None,
|
||||
score: 0,
|
||||
@@ -178,6 +179,7 @@ mod tests {
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
uid: "uid".into(),
|
||||
mounted_path: None,
|
||||
score: 0,
|
||||
|
||||
+153
-2
@@ -9,11 +9,157 @@ release ships. Migrations are forward-only — a database written by a newer cli
|
||||
refuse-to-open, not a downgrade, so rolling back a release means restoring the cache from
|
||||
scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
Schema: **31** (unchanged).
|
||||
|
||||
### Added
|
||||
- **Pause and resume sync.** The Sync page, the tray and `pdfs sync pause [--for 1h]` /
|
||||
`pdfs sync resume` hold back queued uploads and mirror-folder reconciles. Reads through the mount
|
||||
keep working, writes keep being accepted and staged, and a timed pause ends by itself. The pause
|
||||
survives a restart.
|
||||
- **See what is waiting to upload.** The Sync page lists the queue with each item's last error and
|
||||
next retry, with Retry now per item and Retry All; `pdfs sync queue` and `pdfs sync retry [id]`
|
||||
do the same from the CLI. Nothing in the queue is ever dropped from here.
|
||||
- **Resolve sync conflicts.** `(sync-conflict …)` copies under My Files are listed on the Sync page
|
||||
next to the file they conflict with, with sizes, times and whether the content differs. Keep the
|
||||
original, keep the copy or keep both under a new name; whatever is not kept goes to Trash.
|
||||
`pdfs conflicts` and `pdfs conflicts resolve <path> --keep original|copy|both` do the same from
|
||||
the CLI. Files that are open or still uploading are refused until they settle.
|
||||
- **Pause one folder.** A mirrored folder on the Sync page has its own pause button; while paused
|
||||
it is not reconciled in either direction and its mode cannot be switched, and resuming syncs it
|
||||
straight away. `pdfs sync pause --folder <id>` / `pdfs sync resume --folder <id>` do the same, and
|
||||
`pdfs sync list` marks paused folders. The pause is kept across restarts. On-demand folders
|
||||
upload through the shared queue, so only the global pause holds them back.
|
||||
- **Bandwidth limits.** Preferences → General → Network caps upload and download speed in MiB/s,
|
||||
shared by every transfer and applied to running ones from their next block; downloads include
|
||||
reads through the mount. `pdfs sync limit --up 2M --down 0` does the same, and `pdfs sync limit`
|
||||
on its own prints the current caps. The caps are kept in `config.json` across restarts.
|
||||
- **Tray states and menu.** The tray icon shows whether the drive is synced, syncing, paused,
|
||||
offline or needs attention. It asks the panel for attention while changes keep failing, and
|
||||
its tooltip gives the same line as the menu. A left click opens the app. The menu gains
|
||||
"N issues — View", which opens the Sync page, and "Sign In…" when signed out. "Hide Tray Icon"
|
||||
is remembered across logins (`tray_hidden` in `config.json`), and Preferences → Appearance
|
||||
→ Show tray icon brings the icon back. "Stop Proton Drive…" asks for confirmation in the app,
|
||||
then stops the service and the tray. `pdfs-app` accepts `--page <name>` and `--confirm-stop`.
|
||||
A second launch raises the running window instead of opening another one.
|
||||
- **Stop sharing.** Shared by Me → item menu → "Stop Sharing…" and `pdfs unshare <path>` (with no
|
||||
id) remove the public link, every invitation and every member in one go. Each step is tried even
|
||||
if an earlier one fails, and a partial result names what is still shared.
|
||||
- **Restore another computer's folders.** Every computer's menu on the Computers page has
|
||||
"Restore to This Computer…", which syncs that computer's backed-up folders down to paths you
|
||||
pick; they keep syncing with that computer's copy, and this machine keeps its own identity.
|
||||
This computer's own restore moved from the header into its row's menu. `pdfs sync restore
|
||||
--device <uid>` does the same from the CLI.
|
||||
- **Public link expiry.** The Share dialog's create-link form has an "Expires" choice (Never,
|
||||
1 day, 7 days, 30 days), and an existing link shows when it expires or that it has expired.
|
||||
|
||||
### Changed
|
||||
- **New app shell.** Every page has its own header bar; the sidebar ends in a footer with sync
|
||||
state, storage and the account; the Settings page became a Preferences dialog (Ctrl+,), with
|
||||
transfers on the Sync page and developer details in About. The app follows the system accent
|
||||
colour; the Proton purple accent is a preference.
|
||||
- **My Files header.** New folder and both uploads sit in one **New** menu; the view button switches
|
||||
grid and list, and its menu sorts by name, size or date, reversed or not, folders first or not.
|
||||
The choice is remembered. Build Thumbnails and Open in File Manager moved to the folder menu.
|
||||
- **Browsing history.** Back and forward buttons (Alt+Left/Right, mouse back/forward) replace the
|
||||
single Up button; Alt+Up goes to the parent folder. Back leaves a search first.
|
||||
- **Move to…** picks the destination from a folder browser instead of a typed path, and works on a
|
||||
whole selection. Rename selects the name without its extension.
|
||||
- **Drop to upload.** Files and folders dropped on My Files upload into the folder on screen. The
|
||||
upload toast links to the Sync page.
|
||||
- **Photos.** The main view is called Timeline. Right-clicking a photo opens a menu to open it,
|
||||
favourite it, select it or move it to Trash; Ctrl+A selects every loaded photo. An empty filter
|
||||
says what it filtered ("No videos in June 2024") instead of the first-run hint. A banner shows
|
||||
while a Takeout import runs and links to it. Pages load 200 photos at a time and keep loading
|
||||
until the window is full.
|
||||
- **Trash.** Select several items (click, Shift/Ctrl+click, Ctrl+A) to restore or permanently
|
||||
delete them together; the permanent delete still asks first, with the count.
|
||||
- **Shared by Me.** A row opens the item. Copy link stays one click away; Open, Show in My Files,
|
||||
Open Link and Manage Access… are in the row's menu.
|
||||
- **Computers.** Rename, Use This Computer's Identity and Remove are in one menu per computer.
|
||||
Removing a computer deletes its backups, so the dialog now asks for its name to be typed. The
|
||||
page says which computer this machine backs up as, and says "computer" throughout.
|
||||
- **Sign in.** Failures read as sentences — wrong password, too many attempts, no connection, no
|
||||
keyring, a skipped two-factor or verification step — instead of the raw error. The button spins
|
||||
while signing in, and the page links to account creation and password reset. The two-factor
|
||||
dialog mentions recovery codes.
|
||||
- **Real menus.** Right-click menus in My Files, Photos, Shared by Me and Computers are GTK menus:
|
||||
arrow keys, Enter and Escape work, and the Menu key or Shift+F10 opens the one for the selection
|
||||
in My Files. Items follow one order — Open, Open With…, Play · Available offline · Share…,
|
||||
Copy Link · Rename…, Move To…, Versions… · Move to Trash. Right-clicking empty space offers New
|
||||
Folder, the uploads, Select All, Refresh and Open in File Manager. Open With… asks which
|
||||
application to use.
|
||||
- **One selection in My Files.** The grid and the list share their selection, so switching views
|
||||
keeps what is selected, and dragging across empty space draws a selection box in both.
|
||||
- **Details pane.** On a window at least 1000 px wide the details pane docks beside the files
|
||||
instead of covering them. It has Info, Offline, Sharing and Versions sections: who has access or
|
||||
whether there is a public link, how many versions there are, and Share…, Copy Link and
|
||||
Versions… buttons.
|
||||
- **Search in this folder.** Inside a folder, a folder button beside the search box limits the search
|
||||
to that folder and everything below it. `pdfs search --in <folder>` does the same from the CLI.
|
||||
In the list view a Location column shows which folder each hit is in. Pinned files listed by the
|
||||
search prompt and `pdfs-query` show whether they are on disk, instead of always showing them as
|
||||
not downloaded.
|
||||
- **Album management.** The Albums view has a New Album button, and an album's right-click menu
|
||||
offers Rename… and Delete Album…. Deleting an album keeps its photos in the timeline. A photo's
|
||||
menu and the selection bar offer Add to Album…, and inside an album, Remove from Album takes a
|
||||
photo out without trashing it. Albums shared with you only open. The CLI gains `pdfs new-album`,
|
||||
`rename-album`, `delete-album`, `add-to-album` and `remove-from-album`. This needs
|
||||
proton-drive-rs 0.6.6.
|
||||
- **Albums view fixes.** The timeline's filter row no longer shows over the album grid. The header
|
||||
counts albums. Back from an album returns to the grid where you left it instead of reloading it,
|
||||
and going from Albums back to Timeline keeps the timeline's scroll position unless an album was
|
||||
opened in between.
|
||||
- **Photo viewer rewrite.** The lightbox's controls fade out after two seconds without pointer
|
||||
movement. Photos zoom with Ctrl+scroll, a pinch, `+`/`-`, `1` for 100 % and double-click, and
|
||||
pan by dragging; `0` fits them again. The details panel docks beside the photo instead of
|
||||
covering it. Stepping past the last loaded photo loads the next page, and the counter shows the
|
||||
library's total rather than the loaded count. A video plays inline. Save a Copy uses the photo's
|
||||
real name and reports failures, and Open With… offers the app chooser.
|
||||
- **Photos scroll without a Load More button.** The timeline loads the next page while you are
|
||||
still a screen and a half from the end, with a spinner under it while a page is on its way. A
|
||||
month scrubber on the right edge of the whole timeline, marked by year, jumps to the month you
|
||||
drag it to and follows the scroll otherwise.
|
||||
- **Activity log redesign.** The feed is grouped by day (Today, Yesterday, weekday, date), with
|
||||
a time column and filter chips for All, Transfers, Changes, Sharing and Problems. A conflict the
|
||||
daemon logs again on every run now shows once, with a count. A "Needs attention" section on top
|
||||
lists the conflicts still waiting for a decision. Each logged conflict has a Resolve… button that
|
||||
opens the same dialog as the Sync page, and reads as resolved once the copy is gone.
|
||||
- **Share dialog.** The Invite button stays off until every address looks like an email; a
|
||||
malformed one marks the field red and names the entry in a tooltip. Buttons show a spinner
|
||||
while their request runs, so a slow invite or link removal can't be sent twice.
|
||||
- **New app icon.** A white cloud with the download arrow cut out of it, on a rounded Proton
|
||||
purple tile with a darker lower edge, drawn to the GNOME app-icon grid. It stays legible down to
|
||||
16 px on light and dark panels.
|
||||
- **Shared with Me redesign.** Only sections with something in them are shown, and a page
|
||||
with nothing shared says so in one place. Pending invitations sit at the top, with an Accept
|
||||
button beside each. A row reads as the name over who shared it, when, and its size; what the
|
||||
share allows is a tinted pill ("Can view", "Can edit", "Can manage") rather than a bare
|
||||
role word that looked like a button. Open, Show in My Files and Leave… are in the row's ⋮
|
||||
menu, and folders show a chevron. Inside a shared folder the header shows the path from the
|
||||
share down, with a back button. Add Bookmark is an icon in the header.
|
||||
- **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the
|
||||
`pdfs favorite` command.
|
||||
|
||||
## [1.11.1] — 2026-09-19
|
||||
|
||||
Schema: **30** (unchanged).
|
||||
Schema: **31** (unchanged).
|
||||
|
||||
### Fixed
|
||||
- **Double-click opens folders reliably.** Selecting a file no longer slides the details pane in
|
||||
by itself: the pane moved the files under the pointer between the two clicks of a double-click,
|
||||
so the folder did not open. The pane now shows on request, from the header's Details toggle,
|
||||
Alt+Enter or the item menu's Details, and then follows the selection. Closing it keeps the
|
||||
selection.
|
||||
- **The details pane opens only when asked.** On a wide window it used to show at start, empty,
|
||||
and close again whenever you entered a folder. It now follows the header toggle alone. With
|
||||
nothing selected it says so instead of closing, so opening a folder no longer moves the files.
|
||||
- **Context menus no longer have wide margins under some themes.** A menu opened on a file tile
|
||||
was placed inside the tile, so a theme's rules for tile contents (Catppuccin's
|
||||
`gridview > child box { margin: 12px }`) spaced out every section.
|
||||
- **My Files behaves more like a file manager.** A click on empty space between the files clears
|
||||
the selection; Ctrl or Shift held keeps it. Backspace goes back while the focus is on the files.
|
||||
- **An upload parked behind a temp-file rename no longer stays parked forever.** A create for a
|
||||
name that looks like a writer's temporary file waits for the rename to the final name. If that
|
||||
rename never came — the writer crashed, the file was deleted, or the name was never temporary —
|
||||
@@ -38,7 +184,7 @@ Schema: **30** (unchanged).
|
||||
|
||||
## [1.11.0] — 2026-09-19
|
||||
|
||||
Schema: **30** (`photos.content_hash` / `main_uid` / `group_key`); SDK bumped to `proton-sdk` /
|
||||
Schema: **31** (V30 `photos.content_hash` / `main_uid` / `group_key`, V31 `photos.resolved_at`); SDK bumped to `proton-sdk` /
|
||||
`proton-drive-rs` **0.6.5**.
|
||||
|
||||
### Changed
|
||||
@@ -824,6 +970,11 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
|
||||
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||
by a new POSIX compliance suite for the filesystem.
|
||||
|
||||
[1.11.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.11.1
|
||||
[1.11.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.11.0
|
||||
[1.10.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.10.2
|
||||
[1.10.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.10.1
|
||||
[1.10.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.10.0
|
||||
[1.9.3]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.3
|
||||
[1.9.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.2
|
||||
[1.9.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.1
|
||||
|
||||
+4
-4
@@ -14,7 +14,7 @@ Consolidated from the historical feature roadmap; current bug status lives in [`
|
||||
* **Custom FUSE Mount Options (Horizon 1)**: Expose custom mounting parameters in configuration (e.g. `ro` for read-only, `direct_io` to bypass kernel caching, `allow_other`). Auto-generate systemd user mount units upon sync folder configuration.
|
||||
* **Symbolic Link & Hard Link Virtualization (Horizon 2)**: Add symbolic link virtualization inside the metadata store (`NodeType::Symlink`) by intercepting `readlink(2)` and `symlink(2)`. Targets should sync remotely as small encrypted metadata placeholder files on Drive (`proton-vfs-symlink: <target>`).
|
||||
* **Desktop File Manager Integration (Horizon 2)**: Develop shell extension plugins for popular Linux file managers (Nautilus Python plugin, Dolphin Service Menus, Thunar custom actions) to offer context-menu shortcuts ("Pin", "Unpin", "Copy Share Link", "View Version History") and file overlay sync badges.
|
||||
* **Integrated Photo Gallery Enhancements (Horizon 2)**: Album *editing* — rename, cover photo, delete, and removing photos from an album — is still unported in the SDK (TS `updateAlbum` / `deleteAlbum` / `removePhotos`), as is adding a photo that lives on someone else's volume (TS `copyPhoto`). Album **creation** and adding own-volume photos landed with the Google Photos import. Still open: an auto-upload Pictures folder pipeline, and a GUI for creating albums by hand. Exif display, the date-based timeline scrubber and favourites are implemented.
|
||||
* **Integrated Photo Gallery Enhancements (Horizon 2)**: Album *editing* — rename, cover photo, delete, and removing photos from an album — is still unported in the SDK (TS `updateAlbum` / `deleteAlbum` / `removePhotos`), as is adding a photo that lives on someone else's volume (TS `copyPhoto`). Album **creation** and adding own-volume photos landed with the Google Photos import. Still open: an auto-upload Pictures folder pipeline, and a GUI for creating albums by hand. Exif display, the date-based timeline scrubber and favorites are implemented.
|
||||
* **Google Photos import follow-ups**: the importer uploads photos one at a time — a large export would finish sooner with a bounded concurrent upload pool. Trash folders are recognized by a hardcoded list of localized names ([`takeout.rs`](../crates/pdfs-core/src/takeout.rs) `TRASH_FOLDERS`); a locale not on it imports its trash as ordinary photos. Motion-photo `.MP`/`.MV` sidecar videos are ignored rather than uploaded as related photos. Live validation against a real export is pending.
|
||||
* **Random-Access Media Streaming seek support (Horizon 2)**: Intercept out-of-order sparse reads (player seeks) in `pdfs-fuse` and prioritize downloading blocks around the seek offset, aborting/postponing sequential pre-fetches.
|
||||
* **Dynamic Sync Dashboard & Queue Visualization (Horizon 2)**: GUI real-time transfer list (progress bars, speed, ETA), sync history feed, storage usage breakdown, and global pause/resume sync controls.
|
||||
@@ -135,10 +135,10 @@ Three properties that shape the code:
|
||||
- **The active revision cannot be deleted.** `Core::delete_revision_for_uid` checks that locally so the user gets a sentence rather than an API code, and the dialog gives the current row no delete button.
|
||||
- **Saving a version never overwrites.** `SaveRevisionAs` refuses an existing destination and removes a partial file if the download fails — a half-written export looks identical to a good one to every tool that opens it afterwards.
|
||||
|
||||
### Photo Favourites (Implemented)
|
||||
### Photo Favorites (Implemented)
|
||||
**Files**: [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs)
|
||||
|
||||
The gallery can mark and filter favourites, through the SDK's photo-tag API (`update_photos`). Schema **v20** adds `photos.favorite`; the flag is learned in the timeline enrichment pass that already resolves each photo's name and media type, and is kept across a refresh that could not resolve a photo — the same learned-and-kept rule as `media_type`. The lightbox carries a star toggle, the Photos header a favourites filter, and the CLI has `pdfs favorite <uid> [--remove]` plus `pdfs photos --favorites`.
|
||||
The gallery can mark and filter favorites, through the SDK's photo-tag API (`update_photos`). Schema **v20** adds `photos.favorite`; the flag is learned in the timeline enrichment pass that already resolves each photo's name and media type, and is kept across a refresh that could not resolve a photo — the same learned-and-kept rule as `media_type`. The lightbox carries a star toggle, the Photos header a favorites filter, and the CLI has `pdfs favorite <uid> [--remove]` plus `pdfs photos --favorites`.
|
||||
|
||||
Favouriting a photo that is not on this account's own photos volume (shared with us, or album-only) needs it re-encrypted for our timeline root, which the SDK does not implement; the daemon surfaces that as an error rather than silently doing nothing.
|
||||
|
||||
@@ -155,7 +155,7 @@ The gallery lays each day out in justified rows (`justify_rows` / `plan_rows` /
|
||||
### RAW + JPEG Grouping (Implemented)
|
||||
**Files**: [`photos.rs`](../crates/pdfs-core/src/db/photos.rs), [`migrations.rs`](../crates/pdfs-core/src/db/migrations.rs), [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs)
|
||||
|
||||
Schema **v30** adds `content_hash`, `main_uid` and an indexed `group_key` to `photos`. The first two come from the server's `PhotoProperties` and are filled by the enrichment pass in `refresh_timeline` that already resolves each photo's name, media type and favourite tag; `group_key` is computed in `photos_replace`, where the whole timeline is in hand.
|
||||
Schema **v30** adds `content_hash`, `main_uid` and an indexed `group_key` to `photos`. The first two come from the server's `PhotoProperties` and are filled by the enrichment pass in `refresh_timeline` that already resolves each photo's name, media type and favorite tag; `group_key` is computed in `photos_replace`, where the whole timeline is in hand.
|
||||
|
||||
- **Precedence.** The server relation first (`main_photo_uid` and `related_photo_uids`, read from whichever end resolves — the two can land in different enrichment chunks); then same capture day, same case-insensitive name stem, and one member raw while the other is not; otherwise the photo is its own group. The union-find pass is over the timeline in memory, so it costs one pass per refresh rather than a query per photo.
|
||||
- **Representative.** The non-raw member when there is one — a JPEG decodes in milliseconds and is what the person expects to see. `group_key` holds that member's uid, so "is this the tile" is `uid = group_key`, an index scan.
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
# UI/UX audit and improvement plan
|
||||
|
||||
Audit date: 2026-09-23. Scope: `pdfs-gui` (all pages, dialogs, photo viewer), `pdfs-tray`, and the
|
||||
backend capabilities the UI would need. File references are to `crates/pdfs-gui/src/app/` unless
|
||||
stated otherwise.
|
||||
|
||||
## 1. Summary
|
||||
|
||||
The client is functionally rich but the UI is built page by page, and it shows:
|
||||
|
||||
- **No sync control surface.** There is no way to pause or resume sync, no list of what is stuck or
|
||||
failing, and no conflict overview. The backend has no pause at all, no per-op listing, and no
|
||||
per-file conflict record (conflicts exist only as `(sync-conflict <ts>)` files plus activity rows).
|
||||
The GUI reads `online`, `pending_uploads` and `pending_changes` from `Status` and ignores
|
||||
`failing_ops`, `failing_error`, `parked_uploads` and `staged_*`.
|
||||
- **Information architecture is scattered.** Live transfers are a group on *Settings* while the
|
||||
*Activity* page is history only. *Locations* and *Computers* point at each other. Import lives under
|
||||
Settings but is launched from Gallery and returns to Settings. Sign-out, quota, mount status,
|
||||
pinned files, import and developer info all share the Settings page.
|
||||
- **Chrome is non-standard.** Every page draws its own title and toolbar row under an almost empty
|
||||
`AdwHeaderBar` whose title is always "Proton Drive". Two stacked bars, wasted height, no current
|
||||
folder in the title.
|
||||
- **Icons and wording drift.** Upload uses `document-send-symbolic` (reads as "send"), offline uses
|
||||
stars (reads as "favorite"), trash icons mean "stop syncing", "remove device" and "remove
|
||||
bookmark". "Keep offline" / "Unpin" / "Remove offline copy" / "Available offline" are one concept.
|
||||
"Computers" vs "device", "Gallery" vs "Photos", "mount service" vs "daemon" vs "Connect".
|
||||
- **Theming is hardcoded.** `PROTON_PURPLE` overrides the system accent; badge, viewer and tile
|
||||
colours are hex/rgba literals and ignore light, dark and high-contrast modes.
|
||||
- **Several visible bugs** (section 9).
|
||||
|
||||
## 2. Design principles
|
||||
|
||||
1. **Follow GNOME HIG and current libadwaita.** The system has libadwaita 1.9; the crate is pinned to
|
||||
feature `v1_5`. Raise it to at least `v1_7` to get `adw::Spinner`, `ToggleGroup`,
|
||||
`InlineViewSwitcher`, `ButtonRow`, `WrapBox`, `BottomSheet`, and (1.8) `ShortcutsDialog`.
|
||||
2. **Sync state is first class.** The user must always see: up to date / syncing / paused / offline /
|
||||
needs attention, and be one click from pausing or from the list of problems.
|
||||
3. **One term, one icon, one place** per concept (glossary in section 8).
|
||||
4. **Secondary and destructive actions go into menus.** Rows get at most one visible suffix action
|
||||
plus a `view-more-symbolic` menu. Destructive actions are red inside the menu and in the confirm
|
||||
dialog, not as filled buttons in headers.
|
||||
5. **Never block on the network visibly.** Keep old content on screen while reloading, show a header
|
||||
spinner, never flash the whole page to a "Loading…" status page.
|
||||
6. **Respect the system.** Use the system accent by default (brand purple stays on login, app icon
|
||||
and an opt-in toggle). Use named colours (`@success_color`, `@warning_color`, `@error_color`).
|
||||
|
||||
## 3. New information architecture
|
||||
|
||||
### Sidebar
|
||||
|
||||
```
|
||||
Proton Drive (header: app name)
|
||||
My files
|
||||
Photos
|
||||
Shared with me (badge: pending invitations)
|
||||
Shared by me
|
||||
Computers
|
||||
Trash
|
||||
─────────────
|
||||
Sync (badge: issue count, icon reflects state)
|
||||
───────────── footer
|
||||
[●] Up to date · Pause ← sync status strip, click opens Sync
|
||||
▓▓▓░░░ 269 GiB of 1.6 TiB ← quota
|
||||
(avatar) user@proton.me ⋮ ← account menu: Preferences, Sign out…
|
||||
```
|
||||
|
||||
- **Remove** sidebar rows: *Locations* (merged into Sync), *Activity* (becomes the Sync "History"
|
||||
view), *Settings* (becomes an `AdwPreferencesDialog`, `Ctrl+,`).
|
||||
- **Rename** *Gallery* to *Photos* and *Shared* to *Shared by me*.
|
||||
- Primary menu: Preferences, Keyboard Shortcuts, About. The About dialog's `debug_info` gets app
|
||||
version, user agent, daemon status and config path (replacing the "Developer" group).
|
||||
|
||||
### Sync page (new, replaces Locations + Activity + Settings→Activity)
|
||||
|
||||
Top: an `AdwBanner`-style status card with state icon, one-line summary ("Syncing 12 files · 3.4
|
||||
MB/s", "Paused until you resume", "2 files need attention") and one primary button: **Pause** /
|
||||
**Resume** (split button with "Pause for 1 hour / until tomorrow / until resumed").
|
||||
|
||||
Below, an `InlineViewSwitcher` with four views:
|
||||
|
||||
1. **Issues** (default when non-empty): conflicts, failing uploads (`last_error`, attempts, next
|
||||
retry), folders in `error`/`conflict` state, parked uploads older than N minutes. Each row has a
|
||||
primary fix action (Resolve…, Retry now) and a menu (Show in folder, Discard change…).
|
||||
2. **Transfers**: in-flight uploads and downloads with progress, speed, cancel; background jobs
|
||||
(thumbnail build, Takeout import, re-date) with their own cancel.
|
||||
3. **Folders**: the old Locations list. Row: path, mode as a dropdown suffix (Synced / On-demand, with
|
||||
confirm when going on-demand deletes local copies), state subtitle, `Sync now` and a menu (Open,
|
||||
Change location…, Pause this folder, Stop syncing…). "Add folder…" as a `ButtonRow` at the end.
|
||||
The primary mount appears first and is not removable.
|
||||
4. **History**: the old Activity feed, with a kind filter, failures in `@error_color`, rows
|
||||
activatable (open or reveal the target), and "Clear history".
|
||||
|
||||
### Preferences dialog (slimmed Settings)
|
||||
|
||||
| Page | Contents |
|
||||
|---|---|
|
||||
| General | Start on login; Mount location (path + Change…); Notifications (on sync issues, on import done) |
|
||||
| Storage | Cache usage bar; Cache budget (spin row, in GB, "Unlimited" switch instead of magic 0); Clear cache…; Offline files (count + "Manage" → Sync › Folders filter or a sub-page) |
|
||||
| Sync | Ignore patterns (editable list); Conflict clean-up: Off / Report / Remove identical copies (`conflict_sweep`); later: bandwidth limits |
|
||||
| Advanced | Open-with rules, search prompt config (only if kept in GUI) |
|
||||
|
||||
Moved out: account row and Sign out (sidebar account menu, with confirm), quota (sidebar footer),
|
||||
mount status (sidebar status strip), live transfers (Sync), Google Photos import (Photos page menu),
|
||||
version/user agent (About → Debug info).
|
||||
|
||||
## 4. Backend work required
|
||||
|
||||
Everything below is new; nothing like it exists today.
|
||||
|
||||
### B-1 Pause and resume (global, then per folder)
|
||||
|
||||
- `Request::SetSyncPaused { paused: bool, until: Option<i64> }`, persisted (config key
|
||||
`sync_paused_until` or a DB setting) so a pause survives restarts; `Response::Status` gains
|
||||
`paused` and `paused_until`.
|
||||
- Drain: `run_pending_drain` (`pdfs-fuse/src/drain.rs:265`) already releases claimed ops while
|
||||
offline; gate on `paused` at the same point and reuse the wake path.
|
||||
- Sync engine: add `SyncMsg::Pause/Resume` or gate `reconcile_all` / `reconcile_folder`; the 120 s
|
||||
poll and watcher keep recording, they just do not apply.
|
||||
- FUSE reads must keep working while paused (on-demand hydration is a read, not a sync); document that
|
||||
pause means "no uploads, no mirror reconcile".
|
||||
- Per folder later: `sync_folder.paused` column, skipped in `reconcile_all`.
|
||||
- Bandwidth limits later: token bucket in `CountingReader` / `CountingWriter`
|
||||
(`pdfs-fuse/src/transfers.rs`).
|
||||
|
||||
### B-2 Pending ops and transfers
|
||||
|
||||
- `Request::ListPendingOps` → id, kind, path/name, attempts, `last_error` (classified as
|
||||
`ErrorKind`), `next_attempt_at`, parked flag, staged bytes.
|
||||
- `Request::RetryOp { id }` (set `next_attempt_at = now`, wake drain) and `RetryAllFailing`.
|
||||
- `Request::DiscardOp { id }` for a create/revision whose bytes should be dropped. Must save the
|
||||
staged blob to a local recovery folder first; never silent data loss (see `docs/RECOVERY.md`).
|
||||
- `TransferItem` gains a stable id and `Request::CancelTransfer { id }`.
|
||||
- Per-folder last error message on `SyncFolderInfo` (today the text only exists in the activity feed).
|
||||
|
||||
### B-3 Conflicts
|
||||
|
||||
- `Request::ListConflicts`: walk nodes whose name parses with `sweep.rs` `conflict_base_name`, for
|
||||
the mount and every mirror folder. Return copy path, original path (if it exists), both sizes and
|
||||
mtimes, whether content is identical (size + `content_sha1`, as the sweep already proves), and
|
||||
which side is local vs remote when known.
|
||||
- Optionally a `conflict` table written by `keep_as_conflict_copy` / `preserve_conflict_copy` so the
|
||||
origin (drain vs mirror, device) is recorded instead of inferred from names.
|
||||
- `Request::ResolveConflict { copy, keep: Original | Copy | Both }`:
|
||||
- Original: trash the copy (re-verify like the enforcing sweep does).
|
||||
- Copy: upload the copy's content as a new revision of the original, then trash the copy (the old
|
||||
content stays in version history, so this is reversible).
|
||||
- Both: rename the copy to a user-chosen name, drop the `(sync-conflict …)` stamp.
|
||||
- Activity rows for each resolution.
|
||||
|
||||
## 5. Shell and visual polish (all pages)
|
||||
|
||||
- **Per-page header bars.** Give each stack page its own `adw::ToolbarView` + `adw::HeaderBar` (or
|
||||
inject page widgets into the shared bar on page switch). Title = page name, or the current folder
|
||||
in My files. Page actions move into the header; the extra toolbar row disappears.
|
||||
- **One page skeleton.** Same margins everywhere (today 12 vs 18), same `Clamp` rule (lists that are
|
||||
settings-like clamp at 720, file/photo grids are full width), same status pages (`compact`,
|
||||
`vexpand`, crossfade), same Retry semantics (Retry reloads; "Start Proton Drive" is a separate,
|
||||
explicitly labelled button when the service is down).
|
||||
- **Loading.** `adw::Spinner` in the header; keep the old model until the new one lands; status page
|
||||
only on first load.
|
||||
- **Theme.** Drop the global `accent_*` override; offer "Use Proton purple accent" in Preferences.
|
||||
Replace hex colours with named colours; test light, dark and high contrast.
|
||||
- **Move the 70-line CSS string** in `main.rs:232` to `resources/style.css` in the gresource
|
||||
(`style-dark.css` if needed), so it can be edited and linted.
|
||||
- **Shortcuts.** Replace the hand-rolled cheatsheet with `adw::ShortcutsDialog` (or
|
||||
`GtkShortcutsWindow` before 1.8) listing all bindings, including F5/Ctrl+R and the viewer keys.
|
||||
- **Toasts vs dialogs.** Dialogs report their own errors inline (versions, share, add bookmark); a
|
||||
toast behind a modal is invisible. Pluralise properly (`ngettext`-ready helpers, no "item(s)").
|
||||
- **i18n groundwork.** Wrap user strings in a `gettext!`-style macro now, even without translations,
|
||||
so later localisation is mechanical.
|
||||
|
||||
## 6. Page-by-page plan
|
||||
|
||||
### My files
|
||||
|
||||
Header: `[‹ ›] Path bar (breadcrumbs, scroll to end, Ctrl+L to edit) … [search] [view ▾] [+ New ▾]`
|
||||
|
||||
- **New ▾** (`list-add-symbolic`, suggested): New folder (Ctrl+Shift+N), Upload files (Ctrl+U),
|
||||
Upload folder. Replaces three separate icons, fixes the "send" icon.
|
||||
- **View ▾** split button: Grid/List (Ctrl+1/2), Sort by Name/Size/Modified, ascending/descending,
|
||||
Folders first, zoom slider. Persist choice (config or GSettings).
|
||||
- **Folder ⋮ menu** (in header or background context menu): Refresh, Build thumbnails, Make folder
|
||||
available offline, Open in file manager, Share folder…. Build thumbnails leaves the main toolbar.
|
||||
- **Navigation.** Back/forward history (Alt+←/→), Up (Alt+↑); searching then pressing Back clears
|
||||
the search consistently.
|
||||
- **Context menus** as `gtk::PopoverMenu` from `gio::Menu` + actions: keyboard navigable, Menu /
|
||||
Shift+F10, same action set as details pane. Add a background menu. Order: Open, Open with…, Play
|
||||
(media) · Available offline (check item) · Share…, Copy link · Rename…, Move to…, Versions… ·
|
||||
Move to Trash (destructive).
|
||||
- **Offline state.** One concept "Available offline", toggle semantics. Badges: cloud-only = no
|
||||
badge or subtle `cloud-outline`; downloaded = check; available offline = pin/`folder-download`
|
||||
style filled icon. Use named colours. Folders can be made available offline too, or the toggle is
|
||||
hidden for them (fix `details.rs:259`).
|
||||
- **List view.** Sortable, resizable columns: Name, Status, Size, Modified (date + time), and
|
||||
Location for search results. Rubberband selection. One selection model shared between views.
|
||||
- **Details pane.** On wide windows a real side pane (breakpoint, not overlay); header bar with
|
||||
close; real thumbnail; sections: Info, Offline, Sharing (people count, link), Versions (latest 3 +
|
||||
"All versions…"). Same actions as the context menu.
|
||||
- **Selection bar** (≥1 selected, not ≥2): count + size, Available offline, Move to…, Share (single),
|
||||
Move to Trash; Select all (Ctrl+A). Drop the confirm dialog for trash since Undo exists (trash is
|
||||
reversible), or keep confirm and drop Undo — not both.
|
||||
- **Drag and drop.** Drop external files/folders to upload (reuse Takeout's `FileList` target), drag
|
||||
the whole selection, highlight drop targets, breadcrumbs accept drops, Undo toast on move.
|
||||
- **Move to…** a folder-picker dialog (tree/list of Drive folders) instead of a free-text path.
|
||||
- **Rename** pre-selects the stem, not the extension; drop the redundant "Rename “x”." body.
|
||||
- **Search.** Scope toggle (This folder / Everywhere), "Showing first 200 results" note, Location
|
||||
column, correct cached badges (search hits hardcode `cached: false`).
|
||||
- **Upload feedback.** Toast "Uploading 3 items" with "View" → Sync › Transfers.
|
||||
|
||||
### Photos (was Gallery)
|
||||
|
||||
- Header: title "Photos", `InlineViewSwitcher` Timeline | Albums in the header, Select toggle,
|
||||
`+ Upload` (fix: use `adw::ButtonContent`, today `.icon_name()` overwrites `.label()` at
|
||||
`photos.rs:426-432`), ⋮ menu: Import from Google Photos…, Refresh, Rebuild library.
|
||||
- Filters: a single filter dropdown/`ToggleGroup` (All, Photos, Videos, RAW, Favorites) instead of
|
||||
two segmented rows where "Photos" appears twice. Counts follow the active filter.
|
||||
- **Month scrubber** on the right edge that *jumps* (not filters); the month dropdown becomes a real
|
||||
"Filter by date" if kept.
|
||||
- Infinite scroll with prefetch (no "Load more" pill; page size ≥ 200; also trigger when the first
|
||||
page does not fill the viewport).
|
||||
- Tiles: `ContentFit::Cover` (crop) in justified rows, known aspect ratio from metadata to prevent
|
||||
reflow, placeholder as a tinted card of the final size, subtle hover (no 1.06 scale with heavy
|
||||
shadow on a 2 px gap grid), last row justified up to a max stretch.
|
||||
- Context menu on tiles: Open, Favorite, Add to album, Download/Save copy, Show in My files, Move to
|
||||
Trash. Selection: Shift range, Ctrl+A, bulk Favorite, Add to album, Download.
|
||||
- Empty states per filter ("No favorites yet", "No videos in June 2024") instead of the global
|
||||
"No photos yet" with upload buttons.
|
||||
- Import: runs show a banner on Photos ("Importing from Google Photos — 1,204 of 5,000 · View") and
|
||||
Back returns to where the user came from.
|
||||
- **Albums:** Create, rename, delete, remove from album (not trash). Fix state bugs: filter row
|
||||
hidden on the album grid, subtitle count, scroll position kept when switching back.
|
||||
- **Viewer:** `adw::Window` (or full-window overlay), controls auto-hide after 2 s idle, zoom/pan
|
||||
(pinch, Ctrl+scroll, double-click), info panel beside the image instead of over the top bar,
|
||||
pages beyond the loaded 60, counter against the real total, videos play inline with
|
||||
`gtk::Video`, trash with the same confirm/undo rule as the grid, real filename in "Save a copy",
|
||||
success toast, "Open with…" app chooser, favorite tooltip reflects state, readable dates.
|
||||
|
||||
### Shared with me / Shared by me
|
||||
|
||||
- Shared with me: header title shows the drill-down path, with back; invitations as a banner at
|
||||
the top ("2 invitations · Review") opening a list, Reject confirmed; bookmarks become a separate
|
||||
view or section via a switcher. Rows: Open, Save to My files, Available offline where supported.
|
||||
- Shared by me: rows activatable (open), suffix "Manage" becomes a menu with Copy link, Manage
|
||||
access…, Stop sharing….
|
||||
- Share dialog: inline validation, loading spinner, confirm "Remove access" and "Delete link",
|
||||
human role names, link expiry.
|
||||
|
||||
### Computers
|
||||
|
||||
- Show the real device name for "This computer" with rename inline.
|
||||
- Other computers: one suffix menu (Rename, Use this identity…, Remove…). Remove uses a destructive
|
||||
confirm that requires typing the device name since it deletes backups.
|
||||
- "Restore folders" becomes a per-device action ("Restore to this computer…") with an inline-empty
|
||||
state when nothing is restorable, and moves the "Add folder" responsibility fully to Sync.
|
||||
- Terminology: "computer" everywhere in UI copy.
|
||||
|
||||
### Trash
|
||||
|
||||
- Same skeleton as other list pages (clamped `boxed-list`), sort by deleted date, multi-select with
|
||||
Restore / Delete permanently, "Empty Trash…" moved into a header ⋮ menu or kept as a flat button
|
||||
with a destructive confirm, proper plurals, Retry on error.
|
||||
|
||||
### Login
|
||||
|
||||
- Human error messages mapped from error kinds, spinner in the button, "Create account" and "Forgot
|
||||
password" links, recovery-code hint in 2FA, cancelling CAPTCHA returns to the form with a message.
|
||||
|
||||
## 7. Tray
|
||||
|
||||
- Symbolic icon variants: `pdfs-synced`, `pdfs-syncing`, `pdfs-paused`, `pdfs-offline`,
|
||||
`pdfs-attention`; tooltip with the one-line state; `NeedsAttention` status for issues.
|
||||
- Menu: status line · "N issues — View" (opens Sync › Issues) · Pause sync ▸ (1 h, until tomorrow,
|
||||
until resumed) / Resume · Open Proton Drive · Open folder · "Sign in…" when logged out (no
|
||||
"Connect") · Stop Proton Drive (confirm) · "Hide tray icon" instead of "Quit" (daemon keeps
|
||||
running and the label must not suggest otherwise).
|
||||
|
||||
## 8. Glossary (one term, one icon)
|
||||
|
||||
| Concept | UI term | Icon |
|
||||
|---|---|---|
|
||||
| Root of Drive | My files | `folder-symbolic` |
|
||||
| Background service | Proton Drive (running / stopped) — never "daemon" or "mount service" in UI | — |
|
||||
| Cloud-only file | Online only | none / `cloud-outline` |
|
||||
| Pinned file | Available offline | pin/download-filled custom icon, not stars |
|
||||
| Upload | Upload files / Upload folder | `document-upload`-style arrow up, custom pair |
|
||||
| Folder sync mode | Synced / On-demand | — |
|
||||
| Stop syncing a folder | Stop syncing… | `media-playback-stop-symbolic` in a menu, not trash |
|
||||
| Remove a computer | Remove computer… | destructive, in menu |
|
||||
| Conflict copy | Conflict | `dialog-warning-symbolic` + warning colour |
|
||||
| Failure | Failed / Needs attention | `dialog-error-symbolic` + error colour |
|
||||
| Pause | Pause sync / Resume sync | `media-playback-pause-symbolic` / `-start-` |
|
||||
| Photos section | Photos | `image-x-generic-symbolic` |
|
||||
| Sharing | Shared by me / Shared with me | distinct icons (`emblem-shared` / `folder-publicshare`) |
|
||||
|
||||
Spelling: US English throughout ("Favorite", "color"), matching the `pdfs favorite` command and
|
||||
Proton's own apps. Title Case for
|
||||
dialog headings and buttons in dialogs, Sentence case for menu items and rows (HIG).
|
||||
|
||||
## 9. Bugs found during the audit (quick wins)
|
||||
|
||||
1. Photos Upload pill loses its label (`photos.rs:426-432`); same for viewer "Show on map"
|
||||
(`photo_viewer.rs:594-598`).
|
||||
2. Takeout Back always goes to Settings, sidebar highlights Settings when opened from Photos
|
||||
(`main.rs:779`, `takeout.rs:131-136`).
|
||||
3. Thumbnail-build completion message computed then hidden (`browser.rs:1632-1667`).
|
||||
4. Search results show cached files as cloud-only (`browser.rs:2344`).
|
||||
5. Details "Available offline" switch shown for folders (`details.rs:259`).
|
||||
6. Grid and list keep separate selections; details pane can describe a hidden-view item.
|
||||
7. F2 silently does nothing with multiple items selected.
|
||||
8. Filter changes on the Albums grid swap to the timeline while the Albums toggle stays active
|
||||
(`photos.rs:958-997`).
|
||||
9. Subtitle "60 photos" next to "All 1,416" (loaded count vs total, `photos.rs:2007-2015`); viewer
|
||||
counter "12 of 60".
|
||||
10. Viewer info panel covers Close/Trash/Save (`photo_viewer.rs:588` vs `648`).
|
||||
11. Locations mode switch and removal reload Computers, not Locations; a rejected mode switch stays
|
||||
flipped until the next tick (`devices.rs:612`, `devices.rs:809`, `locations.rs:374`).
|
||||
12. Going on-demand deletes local copies without confirmation.
|
||||
13. Retry on Not-connected silently restarts the systemd unit; Locations' Retry does not.
|
||||
14. Toasts from Versions/Share dialogs land behind the modal.
|
||||
15. "Emptied trash —" dangling dash, "item(s)", "archive(s)", raw revision id as version title,
|
||||
raw lowercase link role.
|
||||
16. Bulk context menu shows a separator directly under the header when a folder is selected.
|
||||
17. Sign out, Reject invitation, Remove access, Delete link, tray Disconnect: no confirmation.
|
||||
18. Stale doc comments (`devices.rs:46-48`, `locations.rs:329-331`, `albums.rs:4`,
|
||||
`browser.rs:1155`).
|
||||
|
||||
## 10. Phased roadmap
|
||||
|
||||
Each phase is shippable on its own. Estimates are rough, for one developer.
|
||||
|
||||
| Phase | Content | Crates | Size |
|
||||
|---|---|---|---|
|
||||
| 0 Quick wins | Section 9 bugs, glossary wording, icon swaps (upload, offline, stop syncing), confirmations, plurals | gui, tray | S (2–3 days) |
|
||||
| 1 Shell | libadwaita feature bump, per-page header bars, sidebar restructure + footer (status, quota, account), Preferences dialog, About debug info, theme/colour cleanup, CSS to gresource, shortcuts dialog | gui | M (1 week) |
|
||||
| 2 Sync hub + pause | B-1 global pause, B-2 list/retry pending ops, Sync page (status card, Issues, Transfers, Folders, History), tray states and pause menu, GUI uses `failing_*` / `parked_*` | core, fuse, gui, tray, cli | L (1.5–2 weeks) |
|
||||
| 3 Conflicts | B-3 list + resolve, Issues rows with a compare/resolve dialog (both versions side by side: size, date, device, preview for images/text), CLI `pdfs conflicts` | core, fuse, gui, cli | M–L |
|
||||
| 4 File browser | Header layout, New menu, sort/persisted view, PopoverMenu context menus, details side pane, DnD upload, folder picker for Move, search scope | gui | L |
|
||||
| 5 Photos | Rename, header switcher, filters, infinite scroll + scrubber, tile layout, context menu, album management, viewer rewrite (auto-hide, zoom, inline video) | gui (+ core for album mutations) | L |
|
||||
| 6 Rest | Shared pages, Computers, Trash, Login, per-folder pause, bandwidth limits, i18n wrapping | all | M |
|
||||
|
||||
Phase 2 is where the user-reported gaps (pause, resume, stuck items) get closed; phase 3 closes the
|
||||
conflict overview. Phases 0–1 fix most of the "unpolished" impression cheaply and should come first
|
||||
because later phases build on the new header/page skeleton.
|
||||
|
||||
### Verification per phase
|
||||
|
||||
- `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`,
|
||||
`cargo test --workspace`.
|
||||
- Backend phases: DB migration tests in `crates/pdfs-core/src/db/tests.rs` (paused state, conflict
|
||||
table if added), drain tests proving no op is claimed while paused, resolve-conflict tests proving
|
||||
the losing side stays recoverable (trash or version history).
|
||||
- GUI phases: before/after screenshots in `images/` (light and dark), keyboard-only walkthrough of
|
||||
each page, narrow-window check (sidebar collapse breakpoint).
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
# Build + install: cd packaging && makepkg -fi
|
||||
|
||||
pkgname=proton-drive-linux
|
||||
pkgver=1.11.1
|
||||
pkgver=2.0.0
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# AUR packages
|
||||
|
||||
Source of truth for the three AUR packages of this project. Each subdirectory is
|
||||
the full content of one AUR git repository (`pkgbase` = directory name):
|
||||
|
||||
| Directory | What it does | AUR repository |
|
||||
| --- | --- | --- |
|
||||
| `proton-drive-for-linux` | Builds the latest tagged release from source | `ssh://aur@aur.archlinux.org/proton-drive-for-linux.git` |
|
||||
| `proton-drive-for-linux-git` | Builds `main` from git | `ssh://aur@aur.archlinux.org/proton-drive-for-linux-git.git` |
|
||||
| `proton-drive-for-linux-bin` | Installs the binaries from the GitHub release | `ssh://aur@aur.archlinux.org/proton-drive-for-linux-bin.git` |
|
||||
|
||||
The three packages conflict with each other and with the in-tree
|
||||
`packaging/PKGBUILD` package (`proton-drive-linux`), and every one of them
|
||||
`provides` `proton-drive-for-linux`, `proton-drive-linux`, and `pdfs`.
|
||||
|
||||
Each directory tracks only `PKGBUILD`, `.SRCINFO`, `.gitignore`, and `LICENSE`
|
||||
(the 0BSD license of the packaging sources, per
|
||||
[RFC40](https://rfc.archlinux.page/0040-license-package-sources/)); the
|
||||
`.gitignore` ignores everything else so downloaded sources, `src/`, `pkg/`, and
|
||||
built packages never reach the AUR.
|
||||
|
||||
## Releasing a new version
|
||||
|
||||
1. Tag and publish the upstream release, so the source tarball and the
|
||||
`proton-drive-linux-<version>-x86_64.tar.gz` release asset exist.
|
||||
2. Run `./update.sh <version>` — it rewrites `pkgver`, resets `pkgrel=1`,
|
||||
refreshes the checksums of the release-based packages, and regenerates every
|
||||
`.SRCINFO`.
|
||||
3. Build and inspect the packages in a clean chroot:
|
||||
`cd proton-drive-for-linux && pkgctl build --clean` (needs `devtools`).
|
||||
4. Commit here, then publish with `./push.sh` (or `./push.sh proton-drive-for-linux-bin`
|
||||
for a single package).
|
||||
|
||||
`proton-drive-for-linux-git` carries a `pkgver()` function, so its `pkgver` is
|
||||
computed at build time; do not push a commit that only bumps it.
|
||||
|
||||
## Requirements for pushing
|
||||
|
||||
An SSH key registered with the AUR account and an entry in `~/.ssh/config`:
|
||||
|
||||
```
|
||||
Host aur.archlinux.org
|
||||
IdentityFile ~/.ssh/aur
|
||||
User aur
|
||||
```
|
||||
|
||||
`push.sh` creates a scratch clone of the AUR repository, copies the tracked
|
||||
files over it, commits, and pushes to `master`.
|
||||
@@ -0,0 +1,43 @@
|
||||
pkgbase = proton-drive-for-linux-bin
|
||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
||||
pkgver = 1.11.1
|
||||
pkgrel = 1
|
||||
url = https://github.com/narrrl/proton-drive-linux
|
||||
arch = x86_64
|
||||
license = MIT
|
||||
depends = fuse3
|
||||
depends = gtk4
|
||||
depends = libadwaita
|
||||
depends = webkitgtk-6.0
|
||||
depends = dbus
|
||||
depends = gcc-libs
|
||||
depends = glibc
|
||||
depends = hicolor-icon-theme
|
||||
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
||||
optdepends = gnome-keyring: credential storage over the Secret Service API
|
||||
optdepends = kwallet: credential storage over the Secret Service API
|
||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||
provides = proton-drive-for-linux=1.11.1
|
||||
provides = proton-drive-linux=1.11.1
|
||||
provides = pdfs
|
||||
conflicts = proton-drive-linux
|
||||
conflicts = proton-drive-for-linux
|
||||
conflicts = proton-drive-for-linux-git
|
||||
options = !strip
|
||||
options = !debug
|
||||
source = proton-drive-linux-1.11.1-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v1.11.1/proton-drive-linux-1.11.1-x86_64.tar.gz
|
||||
source = io.narl.proton-drive-linux.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v1.11.1/packaging/io.narl.proton-drive-linux.desktop
|
||||
source = io.narl.proton-drive-linux-tray.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v1.11.1/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||
source = io.narl.proton-drive-linux.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v1.11.1/packaging/io.narl.proton-drive-linux.svg
|
||||
source = proton-drive.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v1.11.1/packaging/proton-drive.service
|
||||
source = LICENSE-1.11.1::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v1.11.1/LICENSE
|
||||
sha256sums = 5fa20a17c0068ad1d74c51eb081b7e36c24b6bf18c4cb0e0b90d2e1c1d40c56f
|
||||
sha256sums = 034083fd2f77b0b69ae638fdaba2bd772209ca325a78ca17d050f221cab9650b
|
||||
sha256sums = 5c369d689926cbbd64803b55a9a6010ad59aded6c7bcfb3e8bd005c95ac10cc8
|
||||
sha256sums = 2bfdef503b4ac02f047adb022e02e81099970589f9d776c821b7751f1be6c12a
|
||||
sha256sums = 25fa3bd9ba52df534be9d8bc66f77d7502fe1423177237d0d14b673dedeb1b60
|
||||
sha256sums = 02c419e7f9af08dda720e4f852f3e57cf5a69e192605440f750e9d244fa2817c
|
||||
|
||||
pkgname = proton-drive-for-linux-bin
|
||||
@@ -0,0 +1,7 @@
|
||||
# Keep the AUR repository to the sources only: ignore everything, then
|
||||
# force-add the tracked files with `git add -f`.
|
||||
*
|
||||
!.gitignore
|
||||
!.SRCINFO
|
||||
!LICENSE
|
||||
!PKGBUILD
|
||||
@@ -0,0 +1,12 @@
|
||||
Copyright (C) 2026 Nils Pukropp <contact@narl.io>
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux-bin
|
||||
pkgver=1.11.1
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/narrrl/proton-drive-linux"
|
||||
license=('MIT')
|
||||
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
|
||||
'hicolor-icon-theme')
|
||||
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
||||
'gnome-keyring: credential storage over the Secret Service API'
|
||||
'kwallet: credential storage over the Secret Service API'
|
||||
'xdg-utils: "Open folder" action in the tray menu'
|
||||
'fzf: search-as-you-type launcher (pdfs-prompt --fzf)'
|
||||
'foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding')
|
||||
provides=("${pkgname%-bin}=$pkgver" "proton-drive-linux=$pkgver" 'pdfs')
|
||||
conflicts=('proton-drive-linux' 'proton-drive-for-linux' 'proton-drive-for-linux-git')
|
||||
# Upstream ships the binaries already built on Ubuntu 24.04; do not rewrite them.
|
||||
options=('!strip' '!debug')
|
||||
_tag="v$pkgver"
|
||||
_raw="https://raw.githubusercontent.com/narrrl/proton-drive-linux/$_tag"
|
||||
# The release tarball holds the four binaries only, so the desktop integration
|
||||
# files come straight from the tagged tree.
|
||||
source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$url/releases/download/$_tag/proton-drive-linux-$pkgver-x86_64.tar.gz"
|
||||
"io.narl.proton-drive-linux.desktop::$_raw/packaging/io.narl.proton-drive-linux.desktop"
|
||||
"io.narl.proton-drive-linux-tray.desktop::$_raw/packaging/io.narl.proton-drive-linux-tray.desktop"
|
||||
"io.narl.proton-drive-linux.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
||||
"proton-drive.service::$_raw/packaging/proton-drive.service"
|
||||
"LICENSE-$pkgver::$_raw/LICENSE")
|
||||
sha256sums=('5fa20a17c0068ad1d74c51eb081b7e36c24b6bf18c4cb0e0b90d2e1c1d40c56f'
|
||||
'034083fd2f77b0b69ae638fdaba2bd772209ca325a78ca17d050f221cab9650b'
|
||||
'5c369d689926cbbd64803b55a9a6010ad59aded6c7bcfb3e8bd005c95ac10cc8'
|
||||
'2bfdef503b4ac02f047adb022e02e81099970589f9d776c821b7751f1be6c12a'
|
||||
'25fa3bd9ba52df534be9d8bc66f77d7502fe1423177237d0d14b673dedeb1b60'
|
||||
'02c419e7f9af08dda720e4f852f3e57cf5a69e192605440f750e9d244fa2817c')
|
||||
|
||||
package() {
|
||||
install -Dm755 "$srcdir/pdfs" "$pkgdir/usr/bin/pdfs"
|
||||
install -Dm755 "$srcdir/pdfs-tray" "$pkgdir/usr/bin/pdfs-tray"
|
||||
install -Dm755 "$srcdir/pdfs-app" "$pkgdir/usr/bin/pdfs-app"
|
||||
install -Dm755 "$srcdir/pdfs-prompt" "$pkgdir/usr/bin/pdfs-prompt"
|
||||
|
||||
install -Dm644 "$srcdir/io.narl.proton-drive-linux.desktop" \
|
||||
"$pkgdir/usr/share/applications/io.narl.proton-drive-linux.desktop"
|
||||
install -Dm644 "$srcdir/io.narl.proton-drive-linux-tray.desktop" \
|
||||
"$pkgdir/etc/xdg/autostart/io.narl.proton-drive-linux-tray.desktop"
|
||||
install -Dm644 "$srcdir/io.narl.proton-drive-linux.svg" \
|
||||
"$pkgdir/usr/share/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg"
|
||||
|
||||
# systemd user unit for the auto-mount daemon; enabled per user, not by pacman.
|
||||
install -Dm644 "$srcdir/proton-drive.service" \
|
||||
"$pkgdir/usr/lib/systemd/user/proton-drive.service"
|
||||
|
||||
install -Dm644 "$srcdir/LICENSE-$pkgver" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
pkgbase = proton-drive-for-linux-git
|
||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
||||
pkgver = 1.11.1.r0.g860d29c
|
||||
pkgrel = 1
|
||||
url = https://github.com/narrrl/proton-drive-linux
|
||||
arch = x86_64
|
||||
license = MIT
|
||||
makedepends = cargo
|
||||
makedepends = git
|
||||
depends = fuse3
|
||||
depends = gtk4
|
||||
depends = libadwaita
|
||||
depends = webkitgtk-6.0
|
||||
depends = dbus
|
||||
depends = gcc-libs
|
||||
depends = glibc
|
||||
depends = hicolor-icon-theme
|
||||
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
||||
optdepends = gnome-keyring: credential storage over the Secret Service API
|
||||
optdepends = kwallet: credential storage over the Secret Service API
|
||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||
provides = proton-drive-for-linux=1.11.1.r0.g860d29c
|
||||
provides = proton-drive-linux=1.11.1.r0.g860d29c
|
||||
provides = pdfs
|
||||
conflicts = proton-drive-linux
|
||||
conflicts = proton-drive-for-linux
|
||||
conflicts = proton-drive-for-linux-bin
|
||||
options = !lto
|
||||
source = proton-drive-for-linux-git::git+https://github.com/narrrl/proton-drive-linux.git
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = proton-drive-for-linux-git
|
||||
@@ -0,0 +1,7 @@
|
||||
# Keep the AUR repository to the sources only: ignore everything, then
|
||||
# force-add the tracked files with `git add -f`.
|
||||
*
|
||||
!.gitignore
|
||||
!.SRCINFO
|
||||
!LICENSE
|
||||
!PKGBUILD
|
||||
@@ -0,0 +1,12 @@
|
||||
Copyright (C) 2026 Nils Pukropp <contact@narl.io>
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux-git
|
||||
pkgver=1.11.1.r0.g860d29c
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/narrrl/proton-drive-linux"
|
||||
license=('MIT')
|
||||
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
|
||||
'hicolor-icon-theme')
|
||||
makedepends=('cargo' 'git')
|
||||
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
||||
'gnome-keyring: credential storage over the Secret Service API'
|
||||
'kwallet: credential storage over the Secret Service API'
|
||||
'xdg-utils: "Open folder" action in the tray menu'
|
||||
'fzf: search-as-you-type launcher (pdfs-prompt --fzf)'
|
||||
'foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding')
|
||||
provides=("${pkgname%-git}=$pkgver" "proton-drive-linux=$pkgver" 'pdfs')
|
||||
conflicts=('proton-drive-linux' 'proton-drive-for-linux' 'proton-drive-for-linux-bin')
|
||||
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
||||
options=('!lto')
|
||||
source=("$pkgname::git+$url.git")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
pkgver() {
|
||||
cd "$pkgname"
|
||||
git describe --long --abbrev=7 --tags 2>/dev/null |
|
||||
sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' ||
|
||||
printf '0.0.0.r%s.g%s' "$(git rev-list --count HEAD)" "$(git rev-parse --short=7 HEAD)"
|
||||
}
|
||||
|
||||
prepare() {
|
||||
cd "$pkgname"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "$pkgname"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo build --frozen --release \
|
||||
--bin pdfs \
|
||||
--bin pdfs-tray \
|
||||
--bin pdfs-app \
|
||||
--bin pdfs-prompt
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "$pkgname"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo test --frozen --workspace
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$pkgname"
|
||||
|
||||
install -Dm755 target/release/pdfs "$pkgdir/usr/bin/pdfs"
|
||||
install -Dm755 target/release/pdfs-tray "$pkgdir/usr/bin/pdfs-tray"
|
||||
install -Dm755 target/release/pdfs-app "$pkgdir/usr/bin/pdfs-app"
|
||||
install -Dm755 target/release/pdfs-prompt "$pkgdir/usr/bin/pdfs-prompt"
|
||||
|
||||
install -Dm644 packaging/io.narl.proton-drive-linux.desktop \
|
||||
"$pkgdir/usr/share/applications/io.narl.proton-drive-linux.desktop"
|
||||
install -Dm644 packaging/io.narl.proton-drive-linux-tray.desktop \
|
||||
"$pkgdir/etc/xdg/autostart/io.narl.proton-drive-linux-tray.desktop"
|
||||
install -Dm644 packaging/io.narl.proton-drive-linux.svg \
|
||||
"$pkgdir/usr/share/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg"
|
||||
|
||||
# systemd user unit for the auto-mount daemon; enabled per user, not by pacman.
|
||||
install -Dm644 packaging/proton-drive.service \
|
||||
"$pkgdir/usr/lib/systemd/user/proton-drive.service"
|
||||
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
pkgbase = proton-drive-for-linux
|
||||
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
|
||||
pkgver = 1.11.1
|
||||
pkgrel = 1
|
||||
url = https://github.com/narrrl/proton-drive-linux
|
||||
arch = x86_64
|
||||
license = MIT
|
||||
makedepends = cargo
|
||||
depends = fuse3
|
||||
depends = gtk4
|
||||
depends = libadwaita
|
||||
depends = webkitgtk-6.0
|
||||
depends = dbus
|
||||
depends = gcc-libs
|
||||
depends = glibc
|
||||
depends = hicolor-icon-theme
|
||||
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
||||
optdepends = gnome-keyring: credential storage over the Secret Service API
|
||||
optdepends = kwallet: credential storage over the Secret Service API
|
||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||
provides = proton-drive-linux=1.11.1
|
||||
provides = pdfs
|
||||
conflicts = proton-drive-linux
|
||||
conflicts = proton-drive-for-linux-bin
|
||||
conflicts = proton-drive-for-linux-git
|
||||
options = !lto
|
||||
source = proton-drive-for-linux-1.11.1.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v1.11.1.tar.gz
|
||||
sha256sums = 1481379b2b70102ea6345a00b1092f73511f6ba99d6fba9a3f185ad6d28caaf2
|
||||
|
||||
pkgname = proton-drive-for-linux
|
||||
@@ -0,0 +1,7 @@
|
||||
# Keep the AUR repository to the sources only: ignore everything, then
|
||||
# force-add the tracked files with `git add -f`.
|
||||
*
|
||||
!.gitignore
|
||||
!.SRCINFO
|
||||
!LICENSE
|
||||
!PKGBUILD
|
||||
@@ -0,0 +1,12 @@
|
||||
Copyright (C) 2026 Nils Pukropp <contact@narl.io>
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux
|
||||
pkgver=1.11.1
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/narrrl/proton-drive-linux"
|
||||
license=('MIT')
|
||||
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
|
||||
'hicolor-icon-theme')
|
||||
makedepends=('cargo')
|
||||
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
||||
'gnome-keyring: credential storage over the Secret Service API'
|
||||
'kwallet: credential storage over the Secret Service API'
|
||||
'xdg-utils: "Open folder" action in the tray menu'
|
||||
'fzf: search-as-you-type launcher (pdfs-prompt --fzf)'
|
||||
'foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding')
|
||||
provides=("proton-drive-linux=$pkgver" 'pdfs')
|
||||
conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-linux-git')
|
||||
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
|
||||
options=('!lto')
|
||||
source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz")
|
||||
sha256sums=('1481379b2b70102ea6345a00b1092f73511f6ba99d6fba9a3f185ad6d28caaf2')
|
||||
|
||||
_srcname="proton-drive-linux-$pkgver"
|
||||
|
||||
prepare() {
|
||||
cd "$_srcname"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "$_srcname"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo build --frozen --release \
|
||||
--bin pdfs \
|
||||
--bin pdfs-tray \
|
||||
--bin pdfs-app \
|
||||
--bin pdfs-prompt
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "$_srcname"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo test --frozen --workspace
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$_srcname"
|
||||
|
||||
install -Dm755 target/release/pdfs "$pkgdir/usr/bin/pdfs"
|
||||
install -Dm755 target/release/pdfs-tray "$pkgdir/usr/bin/pdfs-tray"
|
||||
install -Dm755 target/release/pdfs-app "$pkgdir/usr/bin/pdfs-app"
|
||||
install -Dm755 target/release/pdfs-prompt "$pkgdir/usr/bin/pdfs-prompt"
|
||||
|
||||
install -Dm644 packaging/io.narl.proton-drive-linux.desktop \
|
||||
"$pkgdir/usr/share/applications/io.narl.proton-drive-linux.desktop"
|
||||
install -Dm644 packaging/io.narl.proton-drive-linux-tray.desktop \
|
||||
"$pkgdir/etc/xdg/autostart/io.narl.proton-drive-linux-tray.desktop"
|
||||
install -Dm644 packaging/io.narl.proton-drive-linux.svg \
|
||||
"$pkgdir/usr/share/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg"
|
||||
|
||||
# systemd user unit for the auto-mount daemon; enabled per user, not by pacman.
|
||||
install -Dm644 packaging/proton-drive.service \
|
||||
"$pkgdir/usr/lib/systemd/user/proton-drive.service"
|
||||
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
}
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish the packages in this directory to their AUR repositories.
|
||||
#
|
||||
# Usage: ./push.sh [pkgbase ...] (default: all three)
|
||||
# MESSAGE="..." ./push.sh (custom commit message)
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$(readlink -f "$0")")"
|
||||
|
||||
packages=("$@")
|
||||
if [[ ${#packages[@]} -eq 0 ]]; then
|
||||
packages=(proton-drive-for-linux proton-drive-for-linux-git proton-drive-for-linux-bin)
|
||||
fi
|
||||
|
||||
workdir="$(mktemp -d)"
|
||||
trap 'rm -rf "$workdir"' EXIT
|
||||
|
||||
for pkg in "${packages[@]}"; do
|
||||
test -f "$pkg/PKGBUILD" || { echo "no such package: $pkg" >&2; exit 1; }
|
||||
|
||||
# A fresh clone every time: the AUR repository, not this checkout, is what the
|
||||
# commit history has to build on.
|
||||
git -c init.defaultBranch=master clone -q "ssh://aur@aur.archlinux.org/$pkg.git" "$workdir/$pkg"
|
||||
install -m644 "$pkg/PKGBUILD" "$pkg/.SRCINFO" "$pkg/.gitignore" "$pkg/LICENSE" "$workdir/$pkg/"
|
||||
|
||||
git -C "$workdir/$pkg" add -f PKGBUILD .SRCINFO .gitignore LICENSE
|
||||
if git -C "$workdir/$pkg" diff --cached --quiet; then
|
||||
echo "$pkg: no changes"
|
||||
continue
|
||||
fi
|
||||
|
||||
version="$(sed -n 's/^\tpkgver = //p' "$pkg/.SRCINFO" | head -n1)"
|
||||
git -C "$workdir/$pkg" commit -q -m "${MESSAGE:-Update to $version}"
|
||||
git -C "$workdir/$pkg" push -q origin HEAD:master
|
||||
echo "$pkg: pushed ${MESSAGE:-$version}"
|
||||
done
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# Point the AUR packages at a new upstream release: rewrite pkgver, reset
|
||||
# pkgrel, refresh the checksums that depend on the version, and regenerate
|
||||
# every .SRCINFO.
|
||||
#
|
||||
# Usage: ./update.sh 1.12.0
|
||||
set -euo pipefail
|
||||
|
||||
version="${1:-}"
|
||||
if [[ -z $version ]]; then
|
||||
echo "usage: ${0##*/} <version>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
cd "$(dirname "$(readlink -f "$0")")"
|
||||
|
||||
# The -git package derives its pkgver from git describe, so only the two
|
||||
# release-based packages are versioned here.
|
||||
for pkg in proton-drive-for-linux proton-drive-for-linux-bin; do
|
||||
sed -i -e "s/^pkgver=.*/pkgver=$version/" -e "s/^pkgrel=.*/pkgrel=1/" "$pkg/PKGBUILD"
|
||||
(cd "$pkg" && updpkgsums)
|
||||
done
|
||||
|
||||
for pkg in proton-drive-for-linux proton-drive-for-linux-git proton-drive-for-linux-bin; do
|
||||
(cd "$pkg" && makepkg --printsrcinfo > .SRCINFO)
|
||||
echo "updated $pkg"
|
||||
done
|
||||
@@ -1,26 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Proton Drive for Linux">
|
||||
<defs>
|
||||
<linearGradient id="pdl-bg" x1="0" y1="0" x2="0" y2="128" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#8B72FF"/>
|
||||
<stop offset="1" stop-color="#5A38E8"/>
|
||||
<linearGradient id="pdl-face" x1="12" y1="12" x2="116" y2="112" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#A48BFF"/>
|
||||
<stop offset="0.55" stop-color="#7552FF"/>
|
||||
<stop offset="1" stop-color="#5B36F2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="pdl-cloud" x1="0" y1="34" x2="0" y2="88" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FFFFFF"/>
|
||||
<stop offset="1" stop-color="#ECE6FF"/>
|
||||
</linearGradient>
|
||||
<!-- the cloud, with the arrow cut out of it as negative space -->
|
||||
<mask id="pdl-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="128" height="128">
|
||||
<g fill="#FFFFFF">
|
||||
<circle cx="66" cy="57" r="23"/>
|
||||
<circle cx="42" cy="68" r="16"/>
|
||||
<circle cx="88" cy="69" r="15"/>
|
||||
<rect x="26" y="68" width="77" height="20" rx="10"/>
|
||||
</g>
|
||||
<g stroke="#000000" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M64 49v26"/>
|
||||
<path d="M54.5 65.5l9.5 9.5 9.5-9.5"/>
|
||||
</g>
|
||||
</mask>
|
||||
</defs>
|
||||
|
||||
<rect width="128" height="128" rx="29" fill="url(#pdl-bg)"/>
|
||||
<!-- base: a rounded tile with a darker lower edge for depth -->
|
||||
<rect x="12" y="16" width="104" height="100" rx="26" fill="#4424C0"/>
|
||||
<rect x="12" y="12" width="104" height="100" rx="26" fill="url(#pdl-face)"/>
|
||||
<rect x="12.5" y="12.5" width="103" height="99" rx="25.5" stroke="#FFFFFF" stroke-opacity="0.2"/>
|
||||
|
||||
<!-- cloud: the remote drive (symmetric about x=64) -->
|
||||
<g fill="#FFFFFF">
|
||||
<circle cx="64" cy="42" r="20"/>
|
||||
<circle cx="44" cy="52" r="14"/>
|
||||
<circle cx="84" cy="52" r="14"/>
|
||||
<rect x="32" y="50" width="64" height="16" rx="8"/>
|
||||
</g>
|
||||
|
||||
<!-- pull: files arrive only when opened -->
|
||||
<g stroke="#FFFFFF" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M64 75v17"/>
|
||||
<path d="M53 84l11 11 11-11"/>
|
||||
<path d="M45 108h38" stroke-opacity="0.55"/>
|
||||
</g>
|
||||
<!-- cloud shadow, then the cloud itself -->
|
||||
<rect y="3" width="128" height="128" fill="#2E1596" fill-opacity="0.3" mask="url(#pdl-cut)"/>
|
||||
<rect width="128" height="128" fill="url(#pdl-cloud)" mask="url(#pdl-cut)"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 990 B After Width: | Height: | Size: 1.8 KiB |
Reference in new issue
Block a user